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>

  );

}