// --------------------------------------------------------------------------- // 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([]); 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).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 ; 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.
` 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} /> } />
{/* ⚠ `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. */}
); }