File size: 8,229 Bytes
9b857f7 852b0d3 9b857f7 852b0d3 9b857f7 852b0d3 9b857f7 852b0d3 9b857f7 852b0d3 9b857f7 852b0d3 9b857f7 852b0d3 9b857f7 852b0d3 9b857f7 852b0d3 9b857f7 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 | 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 { queryCitationLabel } from "../customer-grid/queryPreview";
import { DbHead, 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, QueryProvenance, 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);
const cited = citations.filter((citation) => active.citationIds.includes(citation.id));
const provenance = cited.map(queryCitationLabel).join(" Β· ")
|| `${active.source.label} Β· snapshot ${String(active.source.source_version)} Β· retrieved ${active.source.retrieved_at}`;
return (
<div className="shell-db-frame">
<DbHead label={active.source.label} {...(entry?.icon ? { icon: entry.icon } : {})} />
<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} />
)}
<div className="qy-surface">
<QueryProvenance view={active} detail={provenance} />
<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>
);
}
|