loopable / web /src /query /QueryPage.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
cf17b22 verified
Raw
History Blame
11 kB
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>
);
}