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([]); const [citations, setCitations] = useState([]); 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(() => 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).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 ; /* ⛔ `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 (
{/* ⭐⭐ 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 `

` 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. */}

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

{hostedRail ? null : ( 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]]. */}
); }