loopable / web /src /query /QueryPage.tsx
fsanyoto's picture
Deploy AIOS web (React glide grid + FastAPI slice)
7c1820c verified
Raw
History Blame
11.8 kB
// ---------------------------------------------------------------------------
// query/QueryPage.tsx β€” THE QUERY MODULE, which is the DATABASE MODULE (owner item 2,
// 2026-08-14).
//
// Owner, verbatim: *"the query module should exactly BE looking like the database module,
// COMPLETELY, with all the views etc. The only difference is that user isn't the one
// creating the view, it's the AI that they prompt in the AI assistant module."*
//
// β›” "EXACTLY" AND "COMPLETELY" ARE ONLY SATISFIABLE ONE WAY: by mounting THE SAME frame
// and THE SAME grid the database route mounts β€” `shell-db-frame` + `DbHead` +
// `OverlayProvider` + `CustomerGrid` β€” so every view kind, the view sidebar, filters,
// grouping, the row detail, the export, the undo stack and everything else arrive by
// being the same component rather than by being rebuilt. A hand-rolled list of AI views
// beside a read-only preview would satisfy the sentence's words and none of its meaning.
// `DbHead` and `gridScopeFor` moved out of `Shell.tsx` into `shell/dbFrame.tsx` for this;
// nothing about them changed.
//
// β›” WHAT THIS PAGE IS *NOT* ANY MORE: the ask box. It used to carry the question field,
// the read-back and the Keep/Discard pair, above a list of rows β€” which is a form, not a
// database. All of that moved to `assistant/AssistantPage.tsx`, the surface the owner
// names as the one difference between this module and a database ("the AI that they
// prompt in the AI assistant module"). This page reads `GET /query` only to know WHICH
// views the assistant built and which database each belongs to.
//
// β›” THE VIEW IS SELECTED THROUGH `VIEW_OPEN_EVENT`, NOT A PROP, and that is a re-use
// rather than a shortcut. That channel's contract: *"the SHELL routes to the table and
// the GRID owns view selection, so neither has to learn the other's state."* Two traps
// ride with it and both are handled here:
// Β· the listener guards on `detail.topic !== scope` β€” the GRID SCOPE spelling, not the
// registry key β€” and drops anything else SILENTLY. `gridScopeFor` is what makes the
// two agree; emitting the saved row's `scope` verbatim would select nothing on
// `customer_data` / `product_data` and look exactly like a click that "worked".
// Β· the listener also drops a view it has not loaded yet, and there is no ack. So the
// emit rides `retryEmit` on THIS module's own ladder (`VIEW_SELECT_RETRY_MS`), which is
// longer than the inbox's for a measured reason β€” see that constant.
//
// ⚠ NO CLIENT UNION OVER `kind` β€” the server's vocabulary arrives as a string (the
// wave-9 law), so a kind we have no label for renders as itself rather than dropping the
// row from the picker.
// ---------------------------------------------------------------------------
import { useCallback, useEffect, useRef, useState } from "react";
import { VIEW_OPEN_EVENT, signal } from "../apiContract";
import type { QueryOpenDetail } from "../apiContract";
import { QUERY_OPEN_EVENT } from "../apiContract";
import CustomerGrid from "../customer-grid/CustomerGrid";
import { OverlayProvider } from "../customer-grid/OverlaySurface";
import { retryEmit } from "../inbox/inboxModel";
import { DbHead, gridScopeFor } from "../shell/dbFrame";
import { databaseEntries } from "../shell/nav";
import type { NavEntry } from "../shell/nav";
import { fetchQueries } from "./queryApi";
import type { SavedQuery } from "./queryApi";
// β›” THE TWO PIECES THIS MODULE ADDS LIVE IN A LEAF, not here β€” see `queryParts.tsx`'s header.
// This file imports `CustomerGrid`, so anything defined beside it can only be render-tested by
// dragging the whole spreadsheet engine into plain node, which does not survive the trip.
import { QueryEmpty, QueryPicker } from "./queryParts";
import "./query.css";
export interface QueryPageProps {
/**
* β›” REQUIRED, and it is the nav's own entries. An optional prop here would degrade to
* "the feature does not exist" the first time a mount forgot it, which is
* indistinguishable from never having been built. It is also what makes this a legal
* CHROME route: the labels and icons this page draws are the ones the SERVER already
* sent, so it renders nothing that was not already granted.
*/
granted: NavEntry[];
}
/**
* ⭐⭐ THIS MODULE'S OWN RE-EMIT LADDER (ms), and it is LONGER than the inbox's on purpose.
*
* β›” THE NUMBERS COME FROM A MEASUREMENT, NOT FROM CAUTION. `OPEN_RETRY_MS` ends at 2,600 ms and
* is right for its case: a notification click into a grid that is usually already warm. This
* module's case is the opposite β€” it keys `CustomerGrid` FRESH on every view switch, so the
* workspace read is cold, and CLAUDE.md records `ut_assembly` at **799 ms warm / 20.6 s cold**
* with live database switches at 1.8–7.3 s. A 2.6 s ladder against a 20.6 s read runs out before
* the grid has views to select from, the listener's `views.some(...)` guard drops every attempt,
* and there is NO ACK to notice it with.
*
* β›” THE FAILURE THAT BUYS IS WORSE THAN A SLOW PAINT, which is why the ladder is the fix rather
* than a spinner: the header would say *"View: Big accounts"* while the grid painted its own
* default view. A wrong claim, silently, with the picker asserting it. (Worse than the case this
* ladder was borrowed from β€” there the header made no competing claim.)
*
* ⚠ RE-EMITTING IS FREE: `selectView` early-returns once the view is active and the listener
* drops a duplicate, so the tail rungs cost nothing on the ordinary warm path β€” they are only
* ever reached by a load that is genuinely still in flight.
*/
export const VIEW_SELECT_RETRY_MS = [0, 250, 700, 1500, 2600, 5000, 9000, 14000, 21000] as const;
export default function QueryPage({ granted }: QueryPageProps) {
const [views, setViews] = useState<SavedQuery[]>([]);
const [activeId, setActiveId] = useState("");
const [loaded, setLoaded] = useState(false);
const emitCancel = useRef<(() => void) | null>(null);
const entries = databaseEntries(granted);
const entryOf = (key: string) => entries.find((e) => e.key === key);
/**
* β›” ONLY THE VIEWS WHOSE DATABASE THE NAV NAMED. Two things depend on that entry β€” the
* header's label and the grid's icon β€” and without it `dbLabelOf` would fall back to the raw
* store key, painting `ut_leads` as the name of a database. The picker's own render test
* asserts against exactly that leak (*"never the raw store key"*); the header is one file over
* and would have been unguarded.
*
* ⚠ IT IS ALSO THE CHROME LAW, applied on this end too: *"a chrome route renders nothing the
* server did not already grant."* `GET /query` prunes against the wall, so this normally drops
* nothing at all; it bites when `/nav` came back `degraded: ["databases"]` β€” a 200 whose
* database list is missing β€” and rendering a grid we cannot even name from that state would be
* guessing. `unnamed` carries the count so the empty face can say WHICH silence this is
* instead of claiming the workspace has no views.
*/
const known = views.filter((v) => entryOf(v.scope));
const unnamed = views.length - known.length;
// β›” `?? known[0]` β€” NOT just the find. `activeId` is seeded by the effect below, so the FIRST
// render after a non-empty fetch has `activeId === ""` and `loaded === true`, which painted
// "No views yet" for one frame at somebody who has views. That is this file's own two-facts-
// two-sentences rule broken by render ordering. The effect stays as the correctness guard for
// an id that was pruned away; this is what stops a wrong sentence reaching a screen at all.
const active = known.find((v) => v.id === activeId) ?? known[0] ?? null;
const dbLabelOf = (key: string) => entryOf(key)?.label ?? key;
useEffect(() => {
let alive = true;
void (async () => {
const res = await fetchQueries();
if (!alive) return;
setLoaded(true);
if (res.ok) setViews(res.value.views);
})();
return () => {
alive = false;
};
}, []);
// The first view is the landing one. ⚠ Pinned against the CURRENT list rather than set
// once: the index prunes on read (a grant withdrawn, a database deleted), so an id that
// was valid a moment ago can leave β€” and a header naming a view the grid is not showing
// is worse than no selection at all.
useEffect(() => {
if (known.length && !known.some((v) => v.id === activeId)) setActiveId(known[0].id);
}, [known, activeId]);
/**
* β›” THE EMIT, AND ITS TOPIC IS THE **GRID SCOPE**. See the header: the listener compares
* against its own `scope` prop, so a registry key here selects nothing and reports
* nothing. The ladder covers the other half β€” the grid drops a view it has not loaded.
*/
useEffect(() => {
if (!active) return;
emitCancel.current?.();
const topic = gridScopeFor(active.scope);
emitCancel.current = retryEmit(
() => {
signal(VIEW_OPEN_EVENT, { topic, viewId: active.viewId });
},
undefined,
VIEW_SELECT_RETRY_MS
);
return () => emitCancel.current?.();
}, [active]);
/** The assistant's "Open in Query" click-through: route, then ASK. An event naming a
* query this page does not have is DROPPED β€” the list prunes on read, and a stale
* click must do nothing rather than select something else. */
useEffect(() => {
const onOpen = (e: Event) => {
const detail = (e as CustomEvent<QueryOpenDetail>).detail;
const qid = detail?.qid;
if (!qid || !views.some((v) => v.id === qid)) return;
setActiveId(qid);
};
window.addEventListener(QUERY_OPEN_EVENT, onOpen);
return () => window.removeEventListener(QUERY_OPEN_EVENT, onOpen);
}, [views]);
const pick = useCallback((qid: string) => setActiveId(qid), []);
if (!active) return <QueryEmpty loaded={loaded} unnamed={unnamed} />;
const entry = entryOf(active.scope);
return (
// β›” THE SAME THREE CLASSES AS THE DATABASE ROUTE (`Shell.tsx`'s native branch), in the
// same nesting. `.shell-db-frame > .shell-grid-host` is what gives glide its height;
// renaming them for tidiness would silently collapse the grid to nothing.
<div className="shell-db-frame">
<DbHead
label={dbLabelOf(active.scope)}
{...(entry?.icon ? { icon: entry.icon } : {})}
aside={
<QueryPicker
views={known}
/* ⚠ `active.id`, NOT `activeId`. They differ on exactly one render β€” the first after
a non-empty fetch, when the seeding effect has not run and `activeId` is still "".
A `<select>` whose value matches no option renders BLANK, so the header would show
an empty picker over a grid that is loading a real view. Same one-frame ordering
bug as the `?? known[0]` above, one control over. */
activeId={active.id}
dbLabelOf={dbLabelOf}
onPick={pick}
/>
}
/>
<div className="shell-grid-host">
<OverlayProvider>
{/* ⚠ `key` ON THE SCOPE, so switching to a view on a DIFFERENT database remounts
rather than re-pointing a grid that still holds the old table's rows, fields and
undo stack β€” the same reason the shell keys this component on the route. */}
<CustomerGrid
key={gridScopeFor(active.scope)}
scope={gridScopeFor(active.scope)}
/>
</OverlayProvider>
</div>
</div>
);
}