File size: 11,683 Bytes
f2db836
aef86ea
 
 
f2db836
 
 
 
 
aef86ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f2db836
 
 
 
aef86ea
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
import { Suspense, lazy, useCallback, useEffect, useMemo, useState } from "react";

import type { QueryOpenDetail } from "../apiContract";
import { QUERY_OPEN_EVENT } from "../apiContract";
// ⭐⭐ WAVE 37 Β· T48 β€” THIS ONE MADE THE PAGE'S OWN `lazy()` DECORATIVE. `QueryPage` is loaded
// lazily by the shell, and then imported `CustomerGrid` EAGERLY at the top level, so the grid rode
// into the entry chunk anyway and the split bought nothing. A lazy surface that eagerly imports the
// biggest module in the tree is not split; it is split on paper.
const CustomerGrid = lazy(() => import("../customer-grid/CustomerGrid"));
import { OverlayProvider } from "../customer-grid/OverlaySurface";
import { gridScopeFor } from "../shell/dbFrame";
import { databaseEntries } from "../shell/nav";
import type { NavEntry } from "../shell/nav";
import { bindingFor, deleteQuery, fetchQueries, QUERY_BINDING_EVENT } from "./queryApi";
import type { QueryCitation, SavedQuery } from "./queryApi";
import { QueryEmpty, queryGroups, QueryRail } from "./queryParts";
import type { QueryGroup } from "./queryParts";
import "./query.css";

export interface QueryPageProps {
  granted: NavEntry[];
  /**

   * ⭐ R14 β€” the merged Assistant surface hosts the view list in its OWN left panel, so Query

   * renders no rail here. It is a flag rather than a second component because the SELECTION

   * contract does not change: the host emits `QUERY_OPEN_EVENT` through `openBuiltView`, which is

   * the same public qid hand-off an external link uses, and the listener below answers both.

   */
  hostedRail?: boolean;
  /** Bumped by the host after it deletes a view, so the two lists cannot drift apart. */
  refreshToken?: number;
  /**

   * β›” THE HOST'S SELECTION, AND IT IS A GUARANTEE THE EVENT CANNOT GIVE. `QUERY_OPEN_EVENT` stays

   * the published qid contract and still answers external callers, but it is a RACE: `retryEmit`'s

   * default ladder tops out at 2,600 ms, tuned for a click into a grid that is usually warm, while

   * the hosted workspace is a LAZY chunk over a cold grid that CLAUDE.md measures painting at

   * ~12 s. Every dispatch lands before the listener exists, none is acked, and the rail then

   * highlights a view the main column is not showing, 200 OK and silent. A prop arrives in the

   * same commit as the mount.

   */
  selectedId?: string;
}

/**

 * Query is a database frame over a virtual artefact.  Its public contract for B is the

 * `QUERY_BINDING_EVENT`: `dataScope` reads the one source database and `workspaceBinding` names

 * the opaque Query artefact.  The two must never be collapsed into a source-native workspace.

 *

 * ⭐⭐ OWNER ITEM 3 (2026-08-15) β€” THE RAIL IS THE NAVIGATION. See `queryParts.QueryRail`: the

 * top-right dropdown is gone AND replaced, rather than gone and left as an implicit `views[0]`.

 */
export default function QueryPage({ granted, hostedRail, refreshToken, selectedId }: QueryPageProps) {
  const [views, setViews] = useState<SavedQuery[]>([]);
  const [citations, setCitations] = useState<QueryCitation[]>([]);
  const [activeId, setActiveId] = useState("");
  const [loaded, setLoaded] = useState(false);
  const entries = databaseEntries(granted);
  const entryOf = (key: string) => entries.find((entry) => entry.key === key);
  const known = views.filter((view) => entryOf(view.source.database));
  const active = known.find((view) => view.id === activeId) ?? known[0] ?? null;

  const groups = useMemo<QueryGroup[]>(() => queryGroups(views, entries), [views, entries]);

  const reload = useCallback(async () => {
    const result = await fetchQueries();
    setLoaded(true);
    if (result.ok) {
      setViews(result.value.views);
      setCitations(result.value.citations);
    }
  }, []);

  useEffect(() => { void reload(); }, [reload]);

  /** The host owns the rail in the merged surface; a delete there must not leave this list stale. */
  useEffect(() => { if (refreshToken) void reload(); }, [refreshToken, reload]);

  /**

   * Set BEFORE the fetch lands, deliberately: `known` is empty until then, so the fallback below

   * does not fire, and by the time it can the id it would replace is already the right one.

   */
  useEffect(() => { if (selectedId) setActiveId(selectedId); }, [selectedId]);

  useEffect(() => {
    if (known.length && !known.some((view) => view.id === activeId)) setActiveId(known[0].id);
  }, [known, activeId]);

  /**

   * β›”β›” THE BINDING GOES DOWN AS A PROP, AND THAT IS NOT A TIDY-UP.

   * `CustomerGrid` registers its `QUERY_BINDING_EVENT` listener only when `isQueryPreviewRoute()`

   * is true, i.e. only while the hash starts `#/query`. R14 moves this surface INSIDE

   * `#/assistant`, where that predicate is false β€” so on the event path alone the grid would

   * receive no binding, `isQueryPreview` would be false, `previewReadOnly` with it, and the

   * merged surface would render a fully EDITABLE database grid over an immutable AI artefact,

   * 200 OK, with the immutability contract intact on paper. Every WRITE guard in the grid keys on

   * `queryBinding`; only the listener keys on the route. A prop is gated on neither.

   *

   * ⚠ Memoised because `CustomerGrid`'s preview effect lists `queryBinding` in its deps: a fresh

   * object per render re-enters it on every unrelated state change on this page.

   */
  const binding = useMemo(
    () => (active ? bindingFor(active, citations) : undefined), [active, citations]);

  useEffect(() => {
    if (!binding) return;
    // Still published for B, which is what `QUERY_BINDING_EVENT` is: one dispatch, not a ladder.
    // The 21 s retry ladder that used to be here existed for ONE failure β€” the grid mounting
    // after the event was fired β€” and a prop delivered in the same commit cannot have it.
    window.dispatchEvent(new CustomEvent(QUERY_BINDING_EVENT, { detail: binding }));
  }, [binding]);

  useEffect(() => {
    const onOpen = (event: Event) => {
      const qid = (event as CustomEvent<QueryOpenDetail>).detail?.qid;
      if (!qid) return;
      // β›” The assistant can hand over an artefact this page has not fetched yet β€” it was created
      // seconds ago, in the OTHER surface. Selecting it optimistically and reloading is what makes
      // "ask, then click the preview" work on a first visit; without the reload the id is unknown,
      // the guard drops it, and the click silently does nothing [[wrong-parent-not-broken-control]].
      setActiveId(qid);
      if (!views.some((view) => view.id === qid)) void reload();
    };
    window.addEventListener(QUERY_OPEN_EVENT, onOpen);
    return () => window.removeEventListener(QUERY_OPEN_EVENT, onOpen);
  }, [views, reload]);

  /** A renamed or duplicated artefact, merged in place: the rail owns the call, the page the list. */
  const upsert = useCallback((view: SavedQuery) => {
    setViews((current) => [view, ...current.filter((row) => row.id !== view.id)]);
  }, []);

  const remove = useCallback(async (qid: string) => {
    const result = await deleteQuery(qid);
    if (!result.ok) return;
    setViews((current) => current.filter((view) => view.id !== qid));
    setActiveId((current) => (current === qid ? "" : current));
  }, []);

  if (!active) return <QueryEmpty loaded={loaded} unnamed={views.length - known.length} />;
  /* β›” `const entry = entryOf(active.source.database)` STOOD HERE AND WENT WITH THE HEADER

     (W36-T67). Its ONLY reader was the deleted header's icon prop; `entryOf` itself survives

     because `known` above filters the view list with it. A lookup kept alive for a deleted

     consumer is

     the residue an unmount leaves when only the JSX is deleted, and tsc named it in one line. */
  return (
    <div className="shell-db-frame">

      {/*

        ⭐⭐ WAVE 36 Β· W36-T67 (owner item 7) β€” THE UNIVERSAL DATABASE HEADER IS UNMOUNTED HERE,

        AND ON THIS SURFACE IT WAS ALREADY A SECOND COPY OF SOMETHING VISIBLE.

        ⚠ THE COMPONENT IS NOT NAMED ANYWHERE IN THIS FILE ANY MORE, DELIBERATELY. F's W36-T51

        deletes it and finds its last references by searching; a comment carrying the symbol is

        a hit that costs somebody a read, and this very file already warns about prose becoming

        its own marker one block below [[prose-that-becomes-its-own-marker]].



        β›” ITEM 7'S OWN REASONING DOES NOT TRANSFER TO A QUERY, which is why this needed its own

        ticket rather than following the database one. Owner: the header is *"a waste of white

        space. User should be able to tell what database they are in just by seeing the first

        Unique ID field."* A Query has no Unique ID column of its own to lean on.

        ⭐ WHAT IT LEANS ON INSTEAD IS ALREADY ON SCREEN, and it is richer than the band that went:

        `queryGroups` groups the rail BY SOURCE DATABASE and heads each group with that database's

        label AND its icon β€” owner item 3 of 2026-08-15, *"each View correspond to the relevant

        database"*. So `label={active.source.label}` was the same fact, painted twice, one of them

        costing a full-width band. The `hostedRail` case loses nothing either: the Assistant hosts

        the SAME `QueryRail` component, grouping included.

        ⚠ THE ONE THING THAT WAS NOT DUPLICATED IS THE HEADING ITSELF. That header rendered an

        `<h1>` its own comment called "the first time the work surface has NAMED itself"; deleting

        it outright would leave this page with no heading in the accessibility tree at all. So the

        name survives as an `h1` that costs NO vertical space, which is the half of the owner's

        instruction this surface can honour exactly. `flex: 1 1 auto` on `.shell-grid-host` hands

        the reclaimed band to the grid with no CSS change.

      */}

      <h1 className="lp-sr-only">

        {active.name || "Query"} on {active.source.label}

      </h1>

      <div className="shell-grid-host">

        <div className="qy-workspace">

          {hostedRail ? null : (

            <QueryRail groups={groups} activeId={active.id} onSelect={setActiveId}

              onDelete={(id) => void remove(id)} onChanged={upsert} />

          )}

          {/* β›” NO PROVENANCE ROW HERE (W35-T21, owner item 4 / R2). The "Sources" disclosure and

              the snapshot line it opened are deleted; a Query view is a database view now, and a

              database view carries no banner. The citations still travel β€” `binding` above is

              built from this artefact AND the fetched citation list β€” and the reader still SEES

              them in the chat, under the answer that used them (`AssistantPage::CitationLine`).

              ⚠ Do NOT paste that builder call into a comment: `verify_grid_ux.py`'s NC mutates the

              FIRST occurrence of it, so a second copy in prose keeps the scan green over a page

              that stopped binding citations [[prose-that-becomes-its-own-marker]]. */}

          <div className="qy-surface">

            <div className="qy-grid-host">

              <OverlayProvider>

                <Suspense fallback={<div className="lp-boot" aria-hidden="true" />}>

                  <CustomerGrid key={gridScopeFor(active.source.database)}

                    scope={gridScopeFor(active.source.database)} queryBinding={binding} />

                </Suspense>

              </OverlayProvider>

            </div>

          </div>

        </div>

      </div>

    </div>

  );

}