File size: 11,836 Bytes
7c1820c | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 | // ---------------------------------------------------------------------------
// 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>
);
}
|