File size: 10,964 Bytes
9b857f7
852b0d3
 
 
 
 
cf17b22
852b0d3
 
 
 
2bdff0c
852b0d3
 
 
9b857f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852b0d3
 
 
 
 
 
 
 
 
9b857f7
852b0d3
 
 
 
 
 
 
 
 
9b857f7
852b0d3
 
 
 
 
 
 
 
 
 
 
 
9b857f7
 
 
 
 
 
 
 
 
852b0d3
 
 
 
9b857f7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852b0d3
9b857f7
 
 
 
 
 
852b0d3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9b857f7
 
 
 
 
852b0d3
 
 
 
 
 
 
 
cf17b22
 
 
 
 
852b0d3
 
cf17b22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
852b0d3
 
9b857f7
 
 
 
2bdff0c
 
 
 
 
 
 
 
852b0d3
 
 
9b857f7
 
852b0d3
 
 
 
 
 
 
 
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
import { useCallback, useEffect, useMemo, useState } from "react";

import type { QueryOpenDetail } from "../apiContract";
import { QUERY_OPEN_EVENT } from "../apiContract";
import CustomerGrid from "../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>
                <CustomerGrid key={gridScopeFor(active.source.database)}
                  scope={gridScopeFor(active.source.database)} queryBinding={binding} />
              </OverlayProvider>
            </div>
          </div>
        </div>
      </div>
    </div>
  );
}