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>
);
}
|