| // --------------------------------------------------------------------------- | |
| // 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> | |
| ); | |
| } | |