diff --git "a/web/src/customer-grid/CustomerGrid.tsx" "b/web/src/customer-grid/CustomerGrid.tsx" --- "a/web/src/customer-grid/CustomerGrid.tsx" +++ "b/web/src/customer-grid/CustomerGrid.tsx" @@ -1,7715 +1,8185 @@ -import { - memo, - useCallback, - useEffect, - useLayoutEffect, - useMemo, - useRef, - useState, -} from "react"; -import { DataEditor, GridCellKind } from "@glideapps/glide-data-grid"; -import type { - CellClickedEventArgs, - DataEditorRef, - EditableGridCell, - GridKeyEventArgs, - GridMouseEventArgs, - GridSelection, - DrawHeaderCallback, - HeaderClickedEventArgs, - Item, - Rectangle, - Theme, -} from "@glideapps/glide-data-grid"; -import "@glideapps/glide-data-grid/dist/index.css"; - -import { useCustomerData } from "./useCustomerData"; -import type { SurfaceScope } from "./apiBridge"; -import { mutateQueryWorkspace, QUERY_BINDING_EVENT, refuseQueryMutation } from "../query/queryApi"; -import type { QueryVirtualBinding } from "../query/queryApi"; -import { acceptsQueryPreview, routeQueryViewMutation } from "./queryPreview"; -// ⭐ WAVE 30 · W30-T42 (contract C2) — the WINDOWED grid's arithmetic, pure and node-run in -// `verify_grid_ux.py`, because every one of these decisions is taken inside a callback where -// only its own source text could otherwise be checked. -import { WINDOW_ROWS } from "./apiBridge"; -import { - EMPTY_WINDOW_PREDICATE, limitSummary, lockedRecordsNote, nextWindowOffset, - windowedCapabilityNote, windowedFoldNote, windowPredicateKey, -} from "./counts"; -// ⭐ W35-T29/T30 (C5) — the record star's client, and its own stylesheet imported HERE, by the -// module that renders its classes. W35-T22 in this same wave was the cost of doing otherwise. -import { useRecordStars } from "./recordStars"; -// ⚠ ALIASED, and the collision is worth naming: `./Stars`'s `StarIcon` is the RATING atom (a -// 5-point star for a 1..5 field) and this one is the record MARK. Two different meanings, one -// noun, already both in this file — importing the second under its own name would have shadowed -// the first and silently repainted every rating picker. -import { StarIcon as RecordStarMark } from "../ui/icons"; -import "./rowStar.css"; -// ⭐ W36-T04 (owner item 6) — the code-script View's sheet, carried by the MOUNT rather -// than by `viewModes.tsx` where the component lives: that module is loaded under node by -// `verify_grid_ux.py`, and a side-effect CSS import survives into the emitted CommonJS. -import "./scriptView.css"; -import { ScriptView } from "./ScriptViewPanel"; -import { ViewAgentPanel, seedName, seedScript } from "./ViewAgentPanel"; -import { - createScriptView, listScriptViews, readScriptView, runScriptView, saveScriptView, -} from "./scriptViews"; -import type { ScriptRun, ScriptView as ScriptViewRecord, ScriptViewRow } from "./scriptViews"; -import { defaultViewConfig, useGridColumns } from "./useGridColumns"; -import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay, - unresolvedConditions, useVisibleRows } from "./useVisibleRows"; -import { computeAggs } from "./aggregations"; -import type { CohortSets } from "./useVisibleRows"; -import type { MeasureSets } from "./useVisibleRows"; -import { useGetCellContent } from "./useGetCellContent"; -import { useGridSelection } from "./useGridSelection"; -import Toolbar from "./Toolbar"; -import JsonViewer from "./JsonViewer"; -import RecordDetail from "./RecordDetail"; -import ViewSidebar from "./ViewSidebar"; -import ColumnMenu from "./ColumnMenu"; -import type { ColumnMenuState } from "./ColumnMenu"; -import { HEADER_ICONS } from "./iconShapes"; -import { emitHostEvent, eventId } from "./hostBridge"; -// ⭐ WAVE 27 item 21 (R14) — which of this table's views carry an alert. The SERVER's -// list is the only answer; the rail's alert door is a one-way create. -import { fetchAlerts } from "../alerts/alertsApi"; -import { NAV_MINIMIZE_EVENT, ROWS_STALE_EVENT, TOAST_EVENT, VIEW_OPEN_EVENT, - WORKSPACE_STALE_EVENT, signal } - from "../apiContract"; -import type { ViewOpenDetail } from "../apiContract"; -import { addTableField, addTableRow, deleteTableField, deleteTableRow, fetchLinkTargets, - fetchRollupSources, patchTableField, enrichField } from "./apiBridge"; -import type { LinkTarget, RollupSourceOffer } from "./apiBridge"; -import SelectFromFile from "./SelectFromFile"; -import ImportDialog from "./ImportDialog"; -import { FormInterface } from "./FormInterface"; -import type { FormSpec } from "./FormInterface"; -// Owner item 16 / R4 / C-UNDO — the stack and every inverse. Pure, so a node gate can run it. -import { describe, directed, popRedo, popUndo, pushUndo, stackFor } from "./undoStack"; -import type { CellChange, UndoBook, UndoEntry, UndoRow, UndoValue } from "./undoStack"; -import { exportFilename, runExport, triggerDownload } from "./export"; -import type { ExportFormat } from "./export"; -// owner item 3 (2026-08-03) — a time-series view exports its SHEET, not the rows under it. -import { buildTsCsv, tsSheetToTable } from "./timeSeriesData"; -import type { TsSheet } from "./timeSeriesData"; -import { echoReemit, reconcileEchoView, resolveActiveView } from "./viewEcho"; -import { pruneStamps, reconcileFields } from "./optimism"; -import { applyViewOrder, isSyntheticFolderId, newFolderId, pruneFolderStamps, reconcileFolders, - resolveFolderId } from "./folders"; -import type { FolderStamps } from "./folders"; -// ⭐ WAVE 27 item 22 — the cells the active view's filter forces on a record added under it. -import { filterSeedValues } from "./filterSeed"; -import { adoptNewFields, adoptNewViews, pruneTombstones, reconcileHeldFields, - reconcileHeldViews, seedLocalViews, stampTombstone } - from "./liveWorkspace"; -import type { Tombstones } from "./liveWorkspace"; -import type { GridFolder, ViewPermissions } from "./types"; -import type { FieldStamps } from "./optimism"; -import { evalFormula, orderFormulas, parseFormula } from "./formulaEngine"; -import type { FormulaAst } from "./formulaEngine"; -import { CalendarView, KanbanView, ListView, ModeSwitch } from "./viewModes"; -import { SwipeView } from "./SwipeView"; -import type { SwipeSpec } from "./SwipeView"; -// The chart engine lives in `viz/` since EXIT wave 2 (W2-5/Y3) — the grid is now -// one of its two callers, the Y1 page envelope being the other. -import { DashboardView } from "../viz/DashboardView"; -import { cleanCharts } from "../viz/chartData"; -import type { ChartSpec } from "../viz/chartData"; -import { MapView } from "./MapView"; -/* ═══ W18-C CATALOG ═══ (owner item 4, contract C6) */ -import { CatalogView } from "./CatalogView"; -import { CATALOG_CODE_FIELD } from "./catalogData"; -import type { CatalogSpec } from "./types"; -/* ═══ end W18-C CATALOG ═══ */ -import { lightTheme, STATUS_ROW_THEME, HOVER_ROW_THEME, HOVER_NEUTRAL, - ACTIVE_ROW_NEUTRAL, CUSTOM_FIELD_MARK } from "./theme"; -import { avatarInitials, formatDisplay, imageCellRenderer, ratingCellRenderer, setAvatarRepaint, - userCellRenderer } from "./cells"; -import { optionTint, pickTint } from "./choiceColors"; -import { - EMPTY_GRID_COPY_PROVENANCE, - markGridCopy, - observeCopyEvent, - planFieldPaste, - pasteRowCount, -} from "./clipboard"; -import { cellTipText, expandButtonRect, starButtonRect, - GROUP_HEADER_FONT, GROUP_LABEL_PAD, headerMarkLayout, - headerMarkSizes, tipLeft } from "./overlayPlacement"; -import { AnchoredOverlay, BodyPortal, useOverlayLayer } from "./OverlaySurface"; -import type { AnchorRect } from "./OverlaySurface"; -import { StarIcon } from "./Stars"; -import { ALL_VIEW_ID, MAX_CALENDAR_METRICS, allViewName, - MAX_FROZEN, choiceOptions, choiceVocabulary, clampFrozenCount, cleanDisplay, formulaOf, - topicForScope, - isDateFamilyType, isFilterGroup, isGroupableField, isMachineOwned, - isDerivedLink, isUserSchemaField, TOTAL_GROUP_KEY, - isNumericFieldType, - machineFoundRows, reFindConsequences, - isPickType, mayEditField, - isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock, - measureColumnIndex, ratingMax, ruleColumnKeys, - tableMode, uniqueDisplayName } from "./types"; -import type { - DisplayMode, - DisplaySpec, - Field, - HostEvent, - FieldScope, - FieldType, - FilterNode, - FilterRule, - Row, - RowHeightMode, - SavedView, - ViewConfig, -} from "./types"; -// Item 7 (C-TS). Default-exported because it is a leaf VIEW like MapView, and because RECORD -// imports the same default for the record-detail Insights tab (C-EMBED). -import TimeSeriesPanel from "./TimeSeriesPanel"; -import type { WindowSpec } from "./windows"; -import { windowLabel } from "./windows"; - -const ROW_PX: Record = { short: 28, medium: 34, tall: 48 }; - -/** - * Wave-14 R7 — **a button label is ONE LINE.** Every button row in this file is a flex row with - * no width reservation, so on a narrow grid box the items shrink and their labels wrap: - * "Remove from cohort" becomes two lines, the row grows, and the bar stops being a bar. It only - * happens below a width no fixed-size screenshot is taken at, which is why it survived. - * - * ⚠ Applied INLINE rather than on `.cg-btn`, where it belongs: `index.css` is PANEL's fence this - * wave. A mailbox line asks them to hoist it onto `.cg-btn` globally — after which this is - * redundant, and harmless, because it says exactly the same thing. - */ -const ONE_LINE = { whiteSpace: "nowrap" } as const; - -/** - * How many VISIBLE columns glide freezes. Module-level and used twice on purpose (item 15): the - * `freezeColumns` prop and the group bar's label fit must read ONE answer, because the label is - * clipped to exactly this strip and a second copy of the clamp would let the two disagree by a - * column — which shows up as a label cut early for no visible reason. - */ -function frozenCountOf(config: ViewConfig): number { - return Math.min(MAX_FROZEN, Math.max(1, config.frozenCount ?? 1)); -} -/* WAVE 21 item 3 (R6): the id and the NAME both moved to `types.ts` — the id because a second - copy of a pinned literal is the drift class this repo gates against, the name because it is - now topic-derived and the host mints the same string. */ - -/** - * owner item 2 (2026-08-03) — the measure-cell skeleton's pulse, and its ceiling. - * - * 140ms against the 4-step colour ramp is a ~0.6s cycle: a wait, not a strobe. The ceiling is - * ~60s, after which the cells fall back to ordinary blanks — a measure that has not resolved in - * a minute is not "still loading", and a shimmer that never ends promises a number that is not - * coming. Shared constant so the two are read in one place rather than tuned apart. - */ -const PULSE_MS = 140; -const PULSE_MAX_TICKS = Math.round(60_000 / PULSE_MS); -/** Referentially stable, so the fallback does not change `getCellContent`'s identity per render - * and repaint the canvas forever. */ -const NO_PENDING_KEYS: ReadonlySet = new Set(); - -/** - * I2 — measure a cell's text in GLIDE'S OWN font, so "is this cut off?" is a fact rather than - * a character-count guess (a guess is wrong in both directions: "IIIII" is narrow, "WWWWW" is - * wide, and being wrong means either a missing tip or a tip over text you can already read). - * One lazily-built offscreen context for the whole module — `measureText` is cheap, but - * creating a canvas per mouse-move would not be. - */ -let _tipCtx: CanvasRenderingContext2D | null | undefined; -function measureCellText(text: string): number { - if (_tipCtx === undefined) { - _tipCtx = document.createElement("canvas").getContext("2d"); - // glide's default `baseFontStyle` is 13px; the family comes from our own theme. - if (_tipCtx) _tipCtx.font = `13px ${lightTheme.fontFamily ?? "Inter, sans-serif"}`; - } - return _tipCtx ? _tipCtx.measureText(text).width : 0; -} -/** - * Item 15 — the GROUP BAR's measurer, and it is a SECOND context on purpose. - * - * `measureCellText` above is set to plain "13px", which is right for ordinary cells and WRONG for - * a group bar: that row carries `baseFontStyle: GROUP_HEADER_FONT` (semibold), and glide paints a - * cell with the merged theme's font. Measuring the fit with the lighter weight under-truncates - * and the label overflows into the frozen strip's hard clip — the exact failure `fitGroupLabel` - * exists to prevent. Font string built from the SAME constant the cell's themeOverride uses, so - * the two cannot drift. - */ -let _groupCtx: CanvasRenderingContext2D | null | undefined; -function measureGroupText(text: string): number { - if (_groupCtx === undefined) { - _groupCtx = document.createElement("canvas").getContext("2d"); - if (_groupCtx) - _groupCtx.font = `${GROUP_HEADER_FONT} ${lightTheme.fontFamily ?? "Inter, sans-serif"}`; - } - return _groupCtx ? _groupCtx.measureText(text).width : 0; -} -/** - * Owner item 8 (2026-07-27): the first render shows this many rows; "See more" reveals the rest. - * A DISPLAY cap only — the pipeline still runs over the whole book, the toolbar count is still - * the full matched count, and the cap is stated beside the control that lifts it, which is what - * keeps [[no-unverifiable-aggregates]] satisfied: nothing is silently truncated. - */ -const DISPLAY_PAGE = 50; -const DISPLAY_STEP = 250; - -/* ═══════════════════════════════════════════════════════════════════════════════════════════ - ═══ W18-B VOID ═══ (wave 18, owner item 1b) — the two constants the void geometry needs - that belong to the BROWSER and to GLIDE rather than to us. Both are mirrors, and both say - here what they are mirroring, because a mirror that does not name its original is how the - two copies stop agreeing. - ═══════════════════════════════════════════════════════════════════════════════════════════ */ - -/** The header band. `headerHeight={36}` at the DataEditor mount is the original. - * Not `+ groupHeaderHeight`: glide adds that only when `enableGroups` is on, which it turns on - * when a COLUMN carries a `group` — `useGridColumns` never sets one (scrolling-data-grid.js:13). */ -const HEADER_PX = 36; - -/** glide's own row-marker width ladder, `data-editor.js:103`, verbatim. - * - * ⚠ It is passed BACK to glide as `rowMarkerWidth` rather than merely predicted here. The - * marker column is part of the content width the void's left edge is measured from, so a - * four-pixel disagreement between what glide draws and what we compute is a four-pixel seam of - * white against the tint — visible, and invisible to every gate. Pinning makes the two the same - * number by construction; mirroring the ladder (rather than pinning one constant) is what keeps - * the pin from changing the marker column's width on tables of more than 100 rows. */ -function rowMarkerPx(rows: number): number { - return rows > 10_000 ? 48 : rows > 1000 ? 44 : rows > 100 ? 36 : 32; -} - -/** The scrollbar gutter this browser steals from a scrollable box, measured once. - * - * ⛔ WHY THE VOID HAS TO KNOW. The void rectangles sit ON TOP of glide's scroller, so a - * rectangle drawn to the box's own edge paints over the scrollbar and hides it. The two cases - * are CROSSED, which is the part that is easy to get backwards: the BOTTOM void has to stop - * short when the columns overflow (a horizontal scrollbar), and the RIGHT void has to stop - * short when the rows overflow (a vertical one). Both happen in ordinary use — twelve columns - * filtered to five rows is exactly the owner's case. - * - * 0 on overlay-scrollbar platforms (macOS, touch), which is correct: nothing is stolen there. */ -let scrollbarGutterPx: number | null = null; -function scrollbarGutter(): number { - if (scrollbarGutterPx !== null) return scrollbarGutterPx; - if (typeof document === "undefined") return 0; - const probe = document.createElement("div"); - probe.style.cssText = - "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll"; - document.body.append(probe); - scrollbarGutterPx = probe.offsetWidth - probe.clientWidth; - probe.remove(); - return scrollbarGutterPx; -} -/* ═══ end W18-B VOID (module scope) ═══ */ - -interface LocalWorkspace { - fields: Field[]; - views: SavedView[]; - activeViewId: string; - /** When this copy was written (browser clock — same-machine freshness only, - * never date semantics). Absent on copies from before the echo reconcile. */ - savedAt?: number; - /** Wave-6 item 3c — per-key freshness for this browser's own field-def - * writes (edits + tombstones), consumed by reconcileFields at init. */ - fieldStamps?: FieldStamps; - /** BUG-1 (wave 11) — viewId → the filter tree already pushed back to the host, so the - * echo re-emit happens once per user edit rather than once per remount. NOT a timestamp: - * `savedAt` beside it is rewritten on every init, so any time-based bound is refreshed by - * the remount it is meant to bound. See viewEcho.echoReemit. */ - reemitted?: Record; - /** 2026-08-04 — viewId → when THIS browser deleted it. The views half of the tombstone - * rule fields and folders already had, needed the moment the rail adopts host views - * after mount (liveWorkspace.ts). Persisted, not a ref, because a remount inside the - * echo window would otherwise re-adopt a view the user deleted a second ago. */ - viewTombstones?: Tombstones; - /** D-19 (wave 20) — viewId → when THIS browser last WROTE it. The counterpart of the - * tombstones above: they stop a deleted view coming back, this stops a just-created one - * being dropped by the host-list rule at init, before its upsert has round-tripped. - * Same window, same prune, same browser-clock caveat. */ - viewWrites?: Tombstones; - /** ⭐ W36-T03 — when THIS browser last MOVED to a different view. Its own stamp, because - * `savedAt` above is rewritten on every field/view/config write and so cannot bound anything - * (the trap `viewEcho.filtersKey` documents). Absent on copies written before this wave, which - * `resolveActiveView` reads as "not fresh" — the safe direction: the server's answer wins. */ - activeViewAt?: number; -} - -function sameConfig(a: ViewConfig, b: ViewConfig): boolean { - return JSON.stringify(a) === JSON.stringify(b); -} - -function normalizeConfig(config: Partial | undefined, fields: Field[]): ViewConfig { - const base = defaultViewConfig(fields); - return { - ...base, - ...config, - filters: Array.isArray(config?.filters) ? config.filters : [], - // Legacy views persisted a flat rule array with an implicit AND — anything - // other than an explicit "or" normalizes to "and", so old views are unchanged. - filterConj: config?.filterConj === "or" ? "or" : "and", - sorts: Array.isArray(config?.sorts) ? config.sorts : [], - order: Array.isArray(config?.order) && config.order.length ? config.order : base.order, - visible: - Array.isArray(config?.visible) && config.visible.length - ? config.visible - : base.visible, - widths: config?.widths && typeof config.widths === "object" ? config.widths : {}, - memberPids: Array.isArray(config?.memberPids) ? config.memberPids : [], - // Wave-6 items 10/11. Both normalize junk to ABSENT (grid / 1 frozen), so - // a legacy view is byte-identical to itself after a round trip. W13 (C4 as - // amended): cleanDisplay keeps grid-carried field picks. - display: cleanDisplay(config?.display), - frozenCount: clampFrozenCount(config?.frozenCount), - }; -} - -/** - * The pinned system view, as this browser mints it when no host copy has arrived. - * - * WAVE 21 item 3 (R6) — the NAME is the scope's now (`allViewName`), not the literal "All - * customers" this minted on every topic including `ut_*` databases of anything at all. The - * ID is unchanged and deliberately so (see `ALL_VIEW_ID`). - */ -/** - * ⭐ W36-T04/T05 — a script view AS THE RAIL SEES IT. One builder, because three doors open these - * (a rail click through `selectView`, the agent panel's list, and the create that follows a - * request) and a second spelling of this config is how two of them would end up disagreeing about - * which mode is active. ⛔ `display.mode` is what makes the grid stand DOWN: `displayMode` reads - * it, and without it the grid paints underneath the script panel. - */ -function scriptProjectionView(row: { id: string; name: string }): SavedView { - return { - id: row.id, - name: row.name, - kind: "custom", - config: { ...defaultViewConfig([]), display: { mode: "script" } }, - }; -} - -function allRecordsView(fields: Field[], scope: string): SavedView { - return { - id: ALL_VIEW_ID, - name: allViewName(scope), - kind: "system", - locked: true, - config: defaultViewConfig(fields), - }; -} - -/** Adapt E's already-validated Query view to the existing renderer without creating a native view. */ -function queryPreviewView(binding: QueryVirtualBinding, fields: Field[]): SavedView { - const raw = binding.view; - const keys = new Set(fields.map((field) => field.key)); - const config = defaultViewConfig(fields); - const visible = Array.isArray(raw.visible) - ? raw.visible.filter((key): key is string => typeof key === "string" && keys.has(key)) - : []; - if (visible.length) { - config.visible = visible; - config.order = [...visible, ...config.order.filter((key) => !visible.includes(key))]; - } - config.filters = Array.isArray(raw.filters) ? raw.filters as FilterNode[] : []; - config.filterConj = raw.filterConj === "or" ? "or" : "and"; - config.sorts = Array.isArray(raw.sorts) - ? raw.sorts.filter((item): item is { colId: string; dir?: unknown } => - !!item && typeof item === "object" && typeof (item as { colId?: unknown }).colId === "string" - && keys.has((item as { colId: string }).colId) - ).map((item) => ({ colId: item.colId, dir: item.dir === "desc" ? "desc" : "asc" })) - : []; - config.groupBy = typeof raw.groupBy === "string" && keys.has(raw.groupBy) ? raw.groupBy : null; - if (raw.display && typeof raw.display === "object") config.display = raw.display as DisplaySpec; - /** - * ⭐⭐ W35-T25 — THE MEMBERS AN EDIT CAN MOVE, READ BACK FROM THE SERVER'S COPY. - * - * R2 made the spec editable and `routes_query.QUERY_EDITABLE_SPEC` is the allow-list it may - * move. Every one of them has to be read HERE too, or the round trip is one-directional: the - * edit posts, the server stores it, and the next mount rebuilds the view from the AI's members - * alone — so a resize would survive a reload on THIS browser (the local bucket) and vanish on - * any other, which is the worst of the three possible behaviours because it looks like it works. - * - * ⚠ Each is validated against the CURRENT field list, exactly like the members above: the - * server cleans on write, this cleans on read, and neither trusts the other's vintage. - */ - if (Array.isArray(raw.order)) { - const order = raw.order.filter((key): key is string => typeof key === "string" && keys.has(key)); - if (order.length) config.order = [...order, ...config.order.filter((key) => !order.includes(key))]; - } - if (raw.widths && typeof raw.widths === "object") { - const widths: Record = {}; - for (const [key, width] of Object.entries(raw.widths as Record)) - if (keys.has(key) && typeof width === "number" && width > 0) widths[key] = width; - config.widths = widths; - } - if (typeof raw.colorBy === "string" && keys.has(raw.colorBy)) config.colorBy = raw.colorBy; - if (typeof raw.rowHeightMode === "string") - config.rowHeightMode = raw.rowHeightMode as ViewConfig["rowHeightMode"]; - if (typeof raw.frozenCount === "number") config.frozenCount = clampFrozenCount(raw.frozenCount); - return { - id: binding.artifactId, - name: typeof raw.name === "string" && raw.name ? raw.name : binding.source.label, - kind: "custom", - locked: true, - config, - }; -} - -function readLocal(storageKey: string): LocalWorkspace | null { - try { - const raw = localStorage.getItem(`aios-grid:${storageKey}`); - return raw ? (JSON.parse(raw) as LocalWorkspace) : null; - } catch { - return null; - } -} - -function writeLocal(storageKey: string, value: LocalWorkspace): void { - try { - localStorage.setItem(`aios-grid:${storageKey}`, JSON.stringify(value)); - } catch { - // Storage can be blocked in hardened/opaque iframes. The host bridge remains durable. - } -} - -function nextViewId(): string { - return `view_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; -} - -/** A multiselect cell's SET, out of its comma-joined string (the `multi` contract). */ -function splitMulti(v: string): string[] { - return v - .split(",") - .map((s) => s.trim()) - .filter((s) => s !== ""); -} - -/** Wave-5 item 3 — does any leaf of the filter tree name this column? - * - * ⚠ Wave-20 item 2 RETARGETED the note that stood here ("measure leaves carry measure keys, - * which are never field keys, so they cannot false-positive"). True about false POSITIVES, - * and precisely why it was a false NEGATIVE: a measure column's own condition names the - * MEASURE, so this answered "not filtered" for a column the user had visibly filtered, and - * the menu never offered "Don't filter by this field" on it. Both doors now go through - * `ruleColumnKeys` — the same resolution the tint uses, so the menu and the colour can never - * disagree about which columns a filter is about. */ -function treeNamesField( - nodes: FilterNode[], - key: string, - measureCols: Map -): boolean { - for (const n of nodes) { - if (isFilterGroup(n)) { - if (treeNamesField(n.children, key, measureCols)) return true; - } else if (ruleColumnKeys(n as FilterRule, measureCols).includes(key)) return true; - } - return false; -} - -/** Wave-5 item 3 — "Don't filter by this field": drop every leaf naming the column, prune - * groups that end up empty. Returns new arrays throughout (the config is state). */ -function dropFieldFromTree( - nodes: FilterNode[], - key: string, - measureCols: Map -): FilterNode[] { - const out: FilterNode[] = []; - for (const n of nodes) { - if (isFilterGroup(n)) { - const children = dropFieldFromTree(n.children, key, measureCols); - if (children.length) out.push({ ...n, children }); - } else if (!ruleColumnKeys(n as FilterRule, measureCols).includes(key)) { - out.push(n); - } - } - return out; -} - -// The header sprite map moved to iconShapes.ts (wave-8 I20/I21) — it is data + -// SVG-source builders with no React in it, which is what lets verify_icons.py -// import it under node and assert the (i) actually contrasts. - -function slugify(label: string): string { - return ( - label - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_|_$/g, "") - .slice(0, 28) || "field" - ); -} - -interface FieldBuildExtra { - formula?: string; - max?: number; - scope?: FieldScope; - label?: string; - colorCodeOptions?: boolean; - optionColors?: Record; - /** - * ⭐⭐ 2026-08-09 — THE RELATIONAL BAGS, WHICH THIS INTERFACE USED TO DROP ON THE FLOOR. - * - * `ColumnMenu.FieldConfigExtra` has carried them since 2026-08-07 and `extraFor` builds a - * complete bag; this type — the receiving end of the same `extra` argument — never declared - * either key, so `buildOverlayField` could not copy what it could not see. The measured result - * on nurilab: a column stored as - * `{key:'custom_video_views_90j26', label:'Video Views', type:'rollup', source:'overlay'}` - * with NO bag, in one user's workspace stratum, which `compute_relation_cells` never reads. - * Named, configured, rendering, and permanently blank — the owner's *"the Rollup doesn't - * work"*. ⛔ Both halves were needed: this type, and the `createField` route below. - */ - link?: Record; - rollup?: Record; - /** Item 15 (C-RENAME) — the option renames this save carries, by row identity. Consumed by - * `retypeField` (which emits `choice_rename`) and ignored by every create path: a field - * being CREATED has no values to migrate. */ - renames?: { from: string; to: string }[]; -} - -/** - * One constructor for a user-created field, shared by "insert field" and Change-field's - * "New field" half (wave-2 item 5) so the two doors cannot drift. A `multiselect` carries its - * declared options AND `multi: true` — the cell is a comma-joined SET, and the flag is what the - * grouping contract keys on (belt to the type-derived brace in groupRows). - * - * Wave-5: `formula` and `created_time` are built in the HOST's OWN emission shape (source - * 'odoo' + derived — aios_grid.READONLY_CUSTOM_TYPES), so the optimistic local def and the - * next payload's echo are byte-identical and nothing restyles on the round trip. `rating` - * carries top-level `max`, `formula` carries top-level `formula` (the ~20:20 contract - * amendment: options stays the select-family list). Wave-6 item 6: both types are now - * `filterable: true` — the host flipped its emission, and conditions on them evaluate in the - * client engine over computedRows (this table's counts are client-mode; a windowed table's - * columns come from the semantic model, so the SQL engine never sees these types). - */ -/** - * ⭐⭐ 2026-08-09 (owner ruling) — DOES THE PRE-SET LOCK CLOSE THIS COLUMN'S SCHEMA? - * - * Owner: *"No rollup field should be uneditable, everything is custom and changeable always."* - * A ROLLUP holds no data of its own — it is a question asked of other rows, re-askable at any - * time, and re-asking it costs nothing because the answer is recomputed from the authoritative - * store on the next pass. `preset` still closes every column that HOLDS something: retyping a - * pre-set `followers` column would strand real measurements in a column that can no longer read - * them. - * - * ⛔ THE CLIENT MIRROR OF `core.user_tables.preset_editable`, and `verify_rollup_editor.py` - * holds the two in step. This check has THREE enforcement points (here, `may_edit_field`, and - * `routes_tables._field_or_refuse`'s sentence); a client that kept hiding the editor while the - * route allowed the PATCH would read as "you fixed nothing", which is the failure mode a fix in - * one of three places always wears. - */ -function isSchemaLocked(field: Field): boolean { - return field.automation?.preset === true && field.type !== "rollup"; -} - -function buildOverlayField( - label: string, - type: FieldType, - options?: string[], - extra?: FieldBuildExtra -): Field { - const key = `custom_${slugify(label)}_${Math.random().toString(36).slice(2, 7)}`; - if (type === "formula" || type === "created_time") { - return { - key, - label, - type, - source: "odoo", - derived: true, - filterable: true, - default: true, - custom: true, - ...(type === "formula" - ? { agg: "sum", ...(extra?.formula ? { formula: extra.formula } : {}) } - : {}), - }; - } - return { - key, - label, - type, - source: "overlay", - default: true, - custom: true, - agg: ["currency", "int"].includes(type) ? "sum" : undefined, - // The relational bags ride the local definition too, so the optimistic field and the - // server's echo describe the same column. Without them the grid held a bagless twin of a - // field the store had configured, and `mayEditField`/`isDerivedLink` read the twin. - ...(extra?.link ? { link: extra.link as Field["link"] } : {}), - ...(extra?.rollup ? { rollup: extra.rollup as Field["rollup"] } : {}), - ...((type === "select" || type === "multiselect") && options?.length ? { options } : {}), - ...((type === "select" || type === "multiselect") - ? { - colorCodeOptions: extra?.colorCodeOptions !== false, - ...(extra?.optionColors && Object.keys(extra.optionColors).length - ? { optionColors: extra.optionColors } - : {}), - } - : {}), - ...(type === "multiselect" ? { multi: true } : {}), - ...(type === "rating" ? { max: extra?.max ?? 5 } : {}), - }; -} - -/** The surface this grid draws. `cohort` is the same table over hand-curated SETS — the server - * confirms it by stamping `workspace.cohortMode`, which is the only thing the body below reads. - * - * MEMOIZED (owner item 1, 2026-07-31): the shell re-renders on every chrome state flip — nav - * collapse, toast, settings — and an unmemoized grid re-rendered its whole 3,400-line tree - * each time. Measured cost: 200ms-5s of main-thread block, which ate the 240ms rail-fold - * transition whole (the fold froze, then SNAPPED — the exact "static" the owner named). The - * props surface is one stable string, so memo makes chrome state changes free; the grid still - * re-renders for its own state (edits, resize) and remounts on route change via `key`. */ -interface CustomerGridProps { - scope?: SurfaceScope; - /** A Query-owned immutable artefact. `scope` remains the renderer's mapped data scope. */ - queryBinding?: QueryVirtualBinding; - /** Read-only Grid view embedded in a linked-record modal. It keeps the standard - * filter/sort/search toolbar while withdrawing schema and row mutations. */ - embedded?: boolean; - /** Exact linked pids to project from the target database. */ - embeddedRecordIds?: readonly number[]; - /** Selection mode used by an editable ordinary-Link modal. */ - embeddedSelectable?: boolean; - embeddedSelectedIds?: readonly number[]; - onEmbeddedSelectionChange?: (recordIds: number[]) => void; -} - -interface LinkGridModalProps { - label: string; - table: SurfaceScope; - recordIds: readonly number[]; - editable?: boolean; - single?: boolean; - onSave?: (recordIds: number[]) => void; - onClose: () => void; -} - -function LinkGridModal({ label, table, recordIds, editable = false, single = false, - onSave, onClose }: LinkGridModalProps) { - const panelRef = useRef(null); - const [selectedIds, setSelectedIds] = useState(() => [...recordIds]); - const changeSelectedIds = useCallback((ids: number[]) => { - const next = single ? ids.slice(-1) : ids; - setSelectedIds((current) => - current.join(",") === next.join(",") ? current : next - ); - }, [single]); - useOverlayLayer({ - panelRef, - onDismiss: onClose, - dismissOnOutside: true, - initialFocus: "[data-overlay-autofocus]", - trapFocus: true, - }); - return ( - -
-
-
-
-
{label}
-
- {(editable ? selectedIds.length : recordIds.length).toLocaleString()} linked{ - single ? " (one allowed)" : "" - } {(editable ? selectedIds.length : recordIds.length) === 1 ? "record" : "records"} -
-
- -
-
- -
- {editable ? ( -
- - -
- ) : null} -
-
-
- ); -} - -function isQueryPreviewRoute(): boolean { - return typeof window !== "undefined" && window.location.hash.startsWith("#/query"); -} - -function CustomerGrid(props: CustomerGridProps = {}) { - const scope = props.scope ?? "customer"; - const queryRoute = isQueryPreviewRoute(); - const [eventBinding, setEventBinding] = useState(); - const [eventRejected, setEventRejected] = useState(false); - useEffect(() => { - if (!queryRoute || props.queryBinding) return; - const receive = (event: Event) => { - const candidate = (event as CustomEvent).detail; - if (!acceptsQueryPreview(candidate, scope)) { - setEventBinding(undefined); - setEventRejected(true); - return; - } - setEventBinding(candidate); - setEventRejected(false); - }; - window.addEventListener(QUERY_BINDING_EVENT, receive); - return () => window.removeEventListener(QUERY_BINDING_EVENT, receive); - }, [queryRoute, props.queryBinding, scope]); - - const queryBinding = props.queryBinding ?? eventBinding; - if (queryBinding && !acceptsQueryPreview(queryBinding, scope)) { - return ( -
- This Query preview is unavailable because its source binding is invalid. -
- ); - } - if (queryRoute && !props.queryBinding && !queryBinding) { - return ( -
- {eventRejected - ? "This Query preview is unavailable because its source binding was refused." - : "Opening Query preview…"} -
- ); - } - return ; -} - -function CustomerGridSurface({ - scope = "customer", - queryBinding, - embedded = false, - embeddedRecordIds, - embeddedSelectable = false, - embeddedSelectedIds = [], - onEmbeddedSelectionChange, -}: CustomerGridProps = {}) { - const isQueryPreview = queryBinding !== undefined; - /** - * ⭐⭐ W35-T23 (owner item 4 / R2) — **`previewReadOnly` IS GONE, AND THE SPLIT IS THE TICKET.** - * - * It used to be `embedded || isQueryPreview`: ONE flag switching off view persistence, field - * editing, add-row and the toolbar's right element for two surfaces that are not the same - * thing. Owner: *"I should be able to interact in each of the View under Query as well, exactly - * like how I would be able to interact with it under Database view."* R2 makes Query views - * live — resize, sort, group, hide, row height, export, edit a cell, add a row — **subject to - * the SOURCE database's own locks**, which is the predicate that does the work now. - * - * ⛔ TWO QUESTIONS, NOT ONE, AND CONFLATING THEM IS WHAT MADE THE SINGLE FLAG WRONG: - * · **`embedded`** — *may this surface WRITE anything at all?* A linked-record grid inside a - * modal may not, and keeps every refusal it has today. Unchanged, deliberately. - * · **`hostWorkspace`** — *does this surface own the SOURCE DATABASE's workspace?* Its views, - * its `storageKey`, its view rail, its alert list. **A Query surface does NOT**, and that - * has nothing to do with read-only: the artefact has its own single view, its own storage - * key (`query:`) and its own transport. Opening `includeWorkspace` for Query would - * load the source's view list into a Query surface — two surfaces sharing one view list, - * which is the trap this ticket names by name. - * - * ⚠ SO A QUERY SURFACE IS `!embedded && !hostWorkspace`: it writes, and it writes somewhere - * else. Any new guard added below has to answer WHICH of the two questions it is asking; if - * the answer is "both", it is probably asking the wrong one. - */ - const hostWorkspace = !embedded && !isQueryPreview; - // Wave 16 C-TOPIC: which TABLE this tree is drawing, derived from the one scope prop. - const topic = topicForScope(scope); - const { - fields: payloadFields, - rawRows: fetchedRows, - payload, - loading, - overlayEdits, - setOverlayEdits, - patchOverlay, - requestWindow, - } = useCustomerData(scope, { - // ⚠ `bindSurface` is the MODULE-scope write target every emitted event carries. A Query - // surface's cell edits belong to the SOURCE database, so it must bind — the linked-record - // grid must not, because its parent still owns that scope. - bindSurface: !embedded, - // ⛔ The one flag that stays on the WIDE predicate: neither surface reads the source - // database's own saved views. - includeWorkspace: hostWorkspace, - // `writable: false` blocks `patchOverlay`, i.e. every cell edit. R2 wants them in Query. - writable: !embedded, - }); - const embeddedIdsKey = embeddedRecordIds?.join(",") ?? ""; - /** - * ⭐ WAVE 27 · OWNER ITEM 2 (contract C2) — **ROWS THIS BROWSER JUST CREATED**, held locally - * until the server's own copy comes back. - * - * ⛔ THE OWNER'S WORDS: *"adding a new record visually takes too long, I need to be able to - * spam it."* The old path awaited the POST, then fired `ROWS_STALE_EVENT` and re-read the - * WHOLE table — so every "+" cost a round trip plus a full refetch before anything moved, and - * pressing it five times queued five full refetches of a table that grew by five rows. - * - * ⚠ ADD-ONLY AND SELF-PRUNING, which is `liveWorkspace.ts`'s discipline applied to rows: a - * pending row is merged in only while the fetched payload does NOT carry its pid, and the - * effect below drops it the moment the server's copy arrives. There is no third state and no - * merge of values — the server's row wins outright, because by then it IS this row. - */ - const [pendingRows, setPendingRows] = useState([]); - const rawRows = useMemo(() => { - const base = embeddedRecordIds - ? fetchedRows.filter((row) => new Set(embeddedRecordIds).has(row.pid)) - : fetchedRows; - if (!pendingRows.length) return base; - const known = new Set(base.map((r) => r.pid)); - const extra = pendingRows.filter((r) => !known.has(r.pid)); - return extra.length ? [...base, ...extra] : base; - // The scalar key keeps this stable when a caller reconstructs the same id list. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [fetchedRows, embeddedIdsKey, pendingRows]); - useEffect(() => { - // ⚠ PRUNED AGAINST THE FETCHED PAYLOAD, not against `rawRows` — `rawRows` contains the - // pending rows, so it can never report that one has been absorbed. Pruning against the - // memo's own output is the loop that keeps a placeholder alive forever. - if (!pendingRows.length) return; - const known = new Set(fetchedRows.map((r) => r.pid)); - if (!pendingRows.some((r) => known.has(r.pid))) return; - setPendingRows((cur) => cur.filter((r) => !known.has(r.pid))); - }, [fetchedRows, pendingRows]); - /** - * The rows as of the last render, for callbacks that must not be rebuilt per payload. - * `appendRow` reads it to mint the next row id the way the server does (`max + 1`); putting - * `rawRows` in that callback's dependency list would give the trailing "+" a new identity on - * every refetch, which is a re-render of the grid for a value only used at click time. - */ - const rowsRef = useRef(rawRows); - rowsRef.current = rawRows; - /** - * ⭐ WAVE 27 · ITEM 2 — THE NEXT ROW ID, ADVANCED SYNCHRONOUSLY AT MINT TIME. - * - * ⛔ A RENDER-TIME SNAPSHOT IS NOT ENOUGH FOR THE GESTURE THIS ITEM IS ABOUT. `rowsRef.current` - * is assigned during RENDER, so two "+" presses that land before React commits the first - * `setPendingRows` both read the same list and both mint the same id. The owner's words were - * *"I need to be able to spam it"* — that IS the failing case, and the damage is not a - * duplicate row: `minted` is the identity key the failure and re-anchor branches filter and - * map on, so ONE refused write would withdraw BOTH rows and one taken id would rewrite both. - * - * ⚠ SEEDED FROM THE ROWS ON EVERY MINT, then advanced: `Math.max(seen, storeMax) + 1`. Reading - * the store's max each time is what keeps the client in step after a refetch brings rows in - * from elsewhere; the ref is what stops a burst from colliding with itself in between. - */ - const nextRidRef = useRef(0); - const mintRid = useCallback(() => { - const storeMax = rowsRef.current.reduce((m, r) => Math.max(m, r.pid), 0); - nextRidRef.current = Math.max(nextRidRef.current, storeMax) + 1; - return nextRidRef.current; - }, []); - /** - * Withdraw rows this browser is still holding locally, because they have been DELETED. - * - * ⛔ THE PRUNE EFFECT CANNOT DO THIS AND THAT IS THE WHOLE BUG IT FIXES. Pruning is "the - * server's payload now CONTAINS this row, so drop my copy" — a deleted row will never appear - * in a payload again, so a pending row that is deleted before its first refetch stays merged - * into `rawRows` for the life of the mount. Add a record, press Ctrl+Z, and the row sits there - * looking undeleted while the store has already lost it. - * ⚠ CALLED AT EVERY `deleteTableRow` SITE (the selection delete and undo's `rowDelete` - * inverse), because those are the only two doors that remove a row — and the undo one is - * exactly the path that produces a still-pending victim. - */ - const withdrawPending = useCallback((rids: (string | number)[]) => { - if (!rids.length) return; - const gone = new Set(rids.map((r) => Number(r))); - setPendingRows((cur) => (cur.some((r) => gone.has(r.pid)) - ? cur.filter((r) => !gone.has(r.pid)) - : cur)); - }, []); - /** - * ⭐ WAVE 27 · OWNER ITEM 21 / RULING R14 — WHICH VIEWS ON THIS TABLE CARRY AN ALERT. - * - * ⛔ THE SERVER'S LIST IS THE ONLY ANSWER. The rail's "Alert me about new records…" is a - * one-way CREATE (it emits an event the shell turns into a POST) and nothing in the grid - * hears the outcome — a `no_filter` refusal is a real answer, so an optimistic local set - * would paint a badge on a view that has no alert. `GET /alerts` is the state. - * - * ⚠ RE-READ ON FOCUS, the same idiom the shell's own inbox uses. An alert created or deleted - * in another surface (the Alerts pane owns the delete door) reaches this rail on the next - * focus rather than instantly; the alternative was for the grid to guess at the outcome of a - * request another component made. - * ⚠ EMBEDDED GRIDS DO NOT ASK. A linked-record grid inside a modal is not a table anyone - * alerts on, and a second fetch per relation cell would be a workspace read per click. - */ - /** - * ⭐⭐ W35-T29 + T30 · CONTRACT C5 (R5, R6) — the RECORD stars for this database. - * - * ⛔ PER USER, unlike every other star in the product. A database, an agent or a Query view is - * starred through `config.important` on ONE record, so starring a SHARED view stars it for - * everybody who can see it; a record star lives in the caller's own per-username stratum and two - * accounts see two different sets. One word on screen, two scopes underneath (mailbox E-3). - * ⛔ AND IT IS ITS OWN DOOR, NOT A VIEW WRITE (D-170): a view write on a read-through grid 409s - * because it materialises the pool, and this must work on `ut_odoo_gl_lines`. - * - * ⚠ OFF FOR AN EMBEDDED GRID. A linked-record picker inside a modal is not a table anybody keeps - * marks on, and the read would be one request per relation cell. - */ - const recordStars = useRecordStars(scope, !embedded); - const [alerted, setAlerted] = useState([]); - useEffect(() => { - // `hostWorkspace`, not `embedded`: the alert badges paint on the SOURCE database's view rail, - // which a Query surface does not render at all (`hideViews`). Fetching them would be a - // workspace read whose answer has nowhere to go. - if (!hostWorkspace) return; - let live = true; - const pull = () => { - void fetchAlerts().then((r) => { - if (!live || !r.ok) return; // a failed read leaves the badges as they were - setAlerted(r.value.filter((a) => a.topic === scope).map((a) => a.viewId)); - }); - }; - pull(); - window.addEventListener("focus", pull); - return () => { - live = false; - window.removeEventListener("focus", pull); - }; - }, [hostWorkspace, scope]); - - const [fields, setFields] = useState([]); - const [views, setViews] = useState([]); - const [activeViewId, setActiveViewId] = useState(ALL_VIEW_ID); - const [config, setConfig] = useState(() => defaultViewConfig([])); - /** W36-T02 — the live `config`, readable from an effect that must not DEPEND on it. The - * after-mount reconcile needs to know whether an edit is unsaved before it re-seeds the open - * view; taking `config` as a dependency would re-run that whole pass on every drag and - * keystroke instead of only when the host payload moved. */ - const configRef = useRef(config); - configRef.current = config; - const [workspaceReady, setWorkspaceReady] = useState(false); - const [saveState, setSaveState] = useState<"saved" | "saving">("saved"); - const [search, setSearch] = useState(""); - const [collapsed, setCollapsed] = useState>(new Set()); - const [hoverRow, setHoverRow] = useState(undefined); - /** - * Owner item 19 — the hover-only Expand button: which record it opens and where it sits, in - * VIEWPORT coordinates (glide's `getBounds` space, the same one `.cg-header-tip` uses). - * - * A real DOM button rather than a mark painted into the canvas, and that is the whole design: - * a drawn affordance needs a second hit-test that drifts out of step with the drawing, and - * neither `tsc` nor a screenshot can see the drift — the button is painted, and clicking it - * does nothing ([[ui-invisible-to-assertions]]). An element IS its own hit test. - */ - const [expandAt, setExpandAt] = useState< - { pid: number; x: number; y: number; size: number } | null - >(null); - /** - * ⭐ W35-T29 — the same idea at the other end of the same cell: which record the hovered star - * belongs to, and where it sits. A separate state from `expandAt` because the two have - * different minimum row heights (this mark is smaller), so a compact row can carry one and not - * the other, and a shared state would have to pick. - */ - const [starHover, setStarHover] = useState< - { pid: number; x: number; y: number; size: number } | null - >(null); - const [detailPid, setDetailPid] = useState(null); - const [columnMenu, setColumnMenu] = useState(null); - /** Open choice list for a `select` / `user` cell — see onCellClicked. */ - const [picker, setPicker] = useState<{ - pid: number; - fieldKey: string; - anchor: AnchorRect; - } | null>(null); - /** Owner item 5 (2026-07-31) — closing a picker RETURNS FOCUS TO THE GRID, so Enter keeps - * navigating (pick → Enter → next record) instead of stranding focus on a dead overlay. */ - const closePicker = useCallback(() => { - setPicker(null); - requestAnimationFrame(() => gridRef.current?.focus()); - }, []); - /** ⭐ Wave-23 C7 — which `json` cell the big viewer is open on. NOT anchored like the picker - * above: a document is not a choice list, so it opens as a centred modal (the record drawer's - * surface) rather than a popover the size of the cell it came from. */ - const [jsonAt, setJsonAt] = useState<{ pid: number; fieldKey: string } | null>(null); - const closeJson = useCallback(() => { - setJsonAt(null); - requestAnimationFrame(() => gridRef.current?.focus()); - }, []); - /** A relation opens as the target database's real Grid view, projected to the linked ids. - * This is intentionally distinct from JsonViewer: filters, sorts, search, column visibility, - * and the standard cell renderers all remain available inside the large modal. */ - const [linkAt, setLinkAt] = useState<{ pid: number; fieldKey: string } | null>(null); - const closeLink = useCallback(() => { - setLinkAt(null); - requestAnimationFrame(() => gridRef.current?.focus()); - }, []); - /** WAVE 21 item 11 (R10) — is "Select records from a list" open? Opened from the view - * rail's "…" and closed by the dialog; the SELECTION it produces outlives it. */ - const [selectFromFile, setSelectFromFile] = useState(false); - const [importOpen, setImportOpen] = useState(false); - /** The selection bar's "Add to cohort" popover (owner item 2's purpose for the checkboxes). */ - const [selAddOpen, setSelAddOpen] = useState(false); - const [selListName, setSelListName] = useState(""); - const selAddRef = useRef(null); - /** Owner item 9 (wave 20) — its counterpart: "Remove from cohort…", in ORDINARY views. */ - const [selRemoveOpen, setSelRemoveOpen] = useState(false); - const selRemoveRef = useRef(null); - /** Owner item 4 / C-ADDROW — the pid the trailing "+" just created, so the cursor can land on - * it once the re-read actually brings it back (the row does not exist locally before that). */ - const [newRowPid, setNewRowPid] = useState(null); - /** - * ⭐ WARN-THEN-ALLOW on delete (owner, 2026-08-06) — the SIGNATURE of the selection currently - * armed, or `""`. - * - * A signature and not a boolean, deliberately: it is the pid list itself, so changing the - * selection DISARMS automatically. A boolean would stay true while you re-selected, and the - * second Delete would then destroy a set nobody had been warned about — which is worse than no - * warning at all, because the warning is what made it feel checked. - */ - const [delArmed, setDelArmed] = useState(""); - /** Wave-2 item 2c — cohort mode: the active cohort and the "+ Add customers" picker. Picks - * ACCUMULATE across searches (search, tick, search again, confirm once). */ - const [activeCohortId, setActiveCohortId] = useState(null); - const [addCustOpen, setAddCustOpen] = useState(false); - const [addCustQuery, setAddCustQuery] = useState(""); - const [addCustPicked, setAddCustPicked] = useState>(new Set()); - const addCustRef = useRef(null); - /** Wave-5 item 1 — "Filter by this field": a monotonic signal the Toolbar consumes (it - * appends the seeded condition and opens the builder). Wave-6 item 6c: a MEASURE-carrying - * column seeds the equivalent measure condition instead (same measure key + window). */ - const [filterSeed, setFilterSeed] = useState< - | { key?: string; measure?: { key: string; window: WindowSpec }; n: number } - | null - >(null); - /** Wave-6 item 4 — the header-row "+" (insert at end): anchors the create-only menu. */ - const [plusMenu, setPlusMenu] = useState(null); - /** - * Wave-6 item 3c — per-key freshness of this browser's own field-def writes, persisted with - * the workspace copy and consumed by reconcileFields at init. A ref, not state: stamps ride - * along with the setFields that caused them; nothing renders from a stamp. - */ - const fieldStampsRef = useRef<{ edited: Record; deleted: Record }>( - { edited: {}, deleted: {} } - ); - /** Wave-5 item 6 — the header description tip. Floated, pointer-events:none — a tooltip - * that can receive the pointer swallows the NEXT click ([[ui-invisible-to-assertions]]). */ - const [headerTip, setHeaderTip] = useState<{ text: string; x: number; y: number } | null>( - null - ); - /** Wave-9 I2 — the CELL tip: hovering a cell whose text is cut off reveals it in full. - * Same contract as the header tip above (floated, pointer-events:none, aria-hidden) for - * the same reason — a tooltip that can receive the pointer swallows the NEXT click, which - * this surface has already paid for once ([[ui-invisible-to-assertions]]). */ - const [cellTip, setCellTip] = useState<{ text: string; x: number; y: number } | null>(null); - - /** BUG-1 — viewId → the filter tree we have already pushed back to the host. Persisted with - * the local workspace because the remount it guards against destroys any ref. */ - const reemittedRef = useRef>({}); - /** 2026-08-04 — views THIS browser deleted, by id. Read by the live adopt below AND by - * init, so the mount path and the after-mount path answer "was this deleted here?" the - * same way. Persisted with the local workspace for the same reason `reemitted` is. */ - const viewTombstonesRef = useRef({}); - /** D-19 — viewId → when this browser last wrote it (see LocalWorkspace.viewWrites). */ - const viewWritesRef = useRef({}); - const initializedKey = useRef(null); - /** W36-T03 — when this browser last MOVED to a different view, and the id it moved to. Kept as - * a ref rather than state because nothing renders from it; read by the persist effect. */ - const activeViewAtRef = useRef(undefined); - const activeViewSeenRef = useRef(null); - const saveTimer = useRef(null); - const gridRef = useRef(null); - const gridBoxRef = useRef(null); - /** Internal clipboard provenance. The browser exposes pasted text, not its source column; - * recording the grid's own copy keeps cross-field paste fail-closed without blocking - * ordinary external text pasted into an editable field. */ - const copyProvenanceRef = useRef(EMPTY_GRID_COPY_PROVENANCE); - useEffect(() => { - const onCopy = () => { - // The native copy event follows our grid key event. Preserve that provenance once; - // every later copy (address bar, another app surface, another input) invalidates it. - copyProvenanceRef.current = observeCopyEvent(copyProvenanceRef.current, Date.now()); - }; - const onWindowBlur = () => { - copyProvenanceRef.current = EMPTY_GRID_COPY_PROVENANCE; - }; - document.addEventListener("copy", onCopy, true); - window.addEventListener("blur", onWindowBlur); - return () => { - document.removeEventListener("copy", onCopy, true); - window.removeEventListener("blur", onWindowBlur); - }; - }, []); - const [gridSize, setGridSize] = useState({ width: 800, height: 600 }); - - /** - * ⛔ WAVE 21 item 3 (3c) — THE FALLBACK KEY IS THE SCOPE'S, and the literal it replaces is - * the cross-table bleed the owner reported as "RI fields on a new database". - * - * MEASURED, not theorised. `fetchWorkspace` collapses 403 / 5xx / network / unreadable into - * one silent `null` — deliberately, because the workspace is an ENHANCEMENT of the table and - * an older host simply does not serve one. But `null` also meant "no `storageKey`", and the - * fallback was the LITERAL `"customer-grid-standalone"` — ONE localStorage bucket for every - * scope this component can be mounted at. So on a tenant whose `/workspace` 403s for `ut_*` - * (routes_grid gated the route on `module_gate("customer_data")` — C4 fixes that half), the - * FIRST user table to be opened writes its views and custom fields into that bucket and the - * SECOND one reads them back as its own. Two databases with nothing to do with each other, - * sharing a schema, with no error anywhere. - * - * Scoping the key makes that structurally impossible: a bucket belongs to one surface, so - * the worst a failed workspace read can now do is show THIS table its own last-known local - * copy. The prefix marks it as the local-only stratum — a key the server never issues, so it - * can never collide with a real `storageKey` (those are `_table_workspace`-shaped). - * - * ⚠ Existing browsers lose the contents of the old shared bucket. That is the point: every - * byte in it is a workspace some other surface persisted, and there is no way to tell whose. - */ - const storageKey = isQueryPreview && queryBinding - ? `query:${queryBinding.workspaceBinding.key}` - : payload?.workspace?.storageKey ?? `local:${scope}`; - - // Initialize once per permission/data scope. Host state wins; local state - // fills only missing objects and keeps the standalone path useful. - useEffect(() => { - if (!payload || payloadFields.length === 0 || initializedKey.current === storageKey) return; - if (isQueryPreview && queryBinding) { - /** - * ⭐⭐ W35-T23 + T25 (R2/R3) — THE SERVER OWNS A QUERY ARTEFACT'S SPEC, AND THIS SURFACE - * KEEPS NO SECOND COPY. That is the whole of "a resize survives a reload" now. - * - * `binding.view` IS the stored spec (`routes_query` accepts a `view_upsert` of the - * artefact's own view and `queryPreviewView` reads every editable member back off it), so a - * width dragged on one device is there on the next, on any device, without a local bucket. - * - * ⛔⛔ THE FIRST DRAFT SEEDED FROM `readLocal(storageKey)` AND IT WAS WRONG IN THE WORST - * SHAPE THIS REPO CATALOGUES — works, then does not, then works again. The local copy had to - * be gated on `binding.edited` so a Revert was not silently undone on that device; but - * `binding` is memoised on `active`, and NOTHING in the autosave path refetches the artefact - * — so after the FIRST edit in a session the flag was still `false`, and navigating away and - * back inside that session dropped the width while a full reload brought it back. A second - * store for one spec, plus a flag to arbitrate between them, is the shape R4 forbids one - * ruling over. There is one store now and nothing to arbitrate. - * - * ⚠ THE COST, STATED: an edit whose POST fails is lost at the next mount, and the reader is - * TOLD (the autosave toasts the failure). That is the honest trade against a local copy that - * can disagree with the server about what the artefact is. - */ - const view = queryPreviewView(queryBinding, payloadFields); - setFields(payloadFields); - setViews([view]); - setActiveViewId(view.id); - setConfig(view.config); - setWorkspaceReady(true); - initializedKey.current = storageKey; - return; - } - const local = embedded ? null : readLocal(storageKey); - // Item 3c: the def half of the no-blip layer. A RECENT local stamp beats a - // lagged host echo (rename survives, retype holds, a delete stays deleted); - // a caught-up echo returns host objects byte-identical (see optimism.ts). - const stamps = pruneStamps(local?.fieldStamps, Date.now()); - fieldStampsRef.current = { - edited: { ...(stamps.edited ?? {}) }, - deleted: { ...(stamps.deleted ?? {}) }, - }; - const initialFields = reconcileFields( - payloadFields, - local?.fields ?? [], - stamps, - Date.now(), - payload.workspace != null - ); - const hostViews = embedded ? [] : payload.workspace?.views ?? []; - const byId = new Map(); - byId.set(ALL_VIEW_ID, allRecordsView(initialFields, scope)); - // D-19 — the HOST'S LIST DECIDES WHICH VIEWS EXIST. A local copy the host no longer - // names is a ghost (deleted elsewhere, share revoked, store moved) and is dropped here, - // guarded by this browser's own recent writes so an optimistic create survives. - viewWritesRef.current = pruneTombstones(local?.viewWrites, Date.now()); - for (const view of seedLocalViews(local?.views ?? [], hostViews, viewWritesRef.current, - Date.now(), payload.workspace != null)) - byId.set(view.id, view); - // Host state wins — EXCEPT when it is a lagged echo of this browser's own - // in-flight edit, where taking it would turn a just-completed measure rule - // valueless: inactive, no pending marker, whole-book count. A rerun replaces - // the iframe, so this init runs after every host round trip; the reconcile - // is what keeps a rule with a typed rhs PENDING rather than inactive across - // that remount (see viewEcho.ts). - // - // BUG-1 (wave 11) — which views carry a FILTER TREE the host copy does not have, and have - // not already been told about. `echoReemit` owns both guards (see viewEcho.ts): identity - // alone would also fire on a display-only reconcile, and a time-based bound cannot work - // because the local-workspace effect below restamps `savedAt` on every init. - const reemit = new Map(); - // 2026-08-04 — the views tombstone map, seeded from the persisted copy so a remount - // INSIDE the echo window cannot walk back a view this browser just deleted. Init had - // no such guard before (fields and folders both did), which was survivable only while - // every host round trip was itself a remount. - const tombstones = pruneTombstones(local?.viewTombstones, Date.now()); - viewTombstonesRef.current = tombstones; - for (const view of hostViews) { - // Both doors, the `reconcileFields` rule: a tombstone drops the host copy AND any - // local copy, so a delete that raced localStorage cannot re-enter through either. - if (tombstones[view.id] !== undefined) { - byId.delete(view.id); - continue; - } - const merged = reconcileEchoView( - view, byId.get(view.id), local?.savedAt, Date.now() - ); - const key = echoReemit(merged, view, local?.reemitted?.[view.id]); - if (key !== null) reemit.set(view.id, key); - byId.set(view.id, merged); - } - const initialViews = [...byId.values()].map((view) => ({ - ...view, - config: normalizeConfig(view.config, initialFields), - })); - /** - * ⭐⭐ W36-T03 — the local preference is now BOUNDED, like every other local stratum here. - * It used to win unconditionally, so two machines on one account could not be brought onto - * the same view by anything the user did on either of them. `view_select` writes the server - * copy (`table_store.save_active_view`), so "where the user left off survives a new browser" - * — the 2026-07-31 owner item — is served by rung 2 and by rung 3 when the store had nothing. - */ - const requested = resolveActiveView( - { id: local?.activeViewId, at: local?.activeViewAt }, - payload.workspace?.activeViewId, ALL_VIEW_ID, Date.now()); - const active = initialViews.find((view) => view.id === requested) ?? initialViews[0]; - /** - * ⭐ W36-T03 — seed the bound's two refs from what we just RESOLVED, and stamp nothing here. - * ⛔ Stamping at init would refresh the window on every mount, so a browser that reloads - * every couple of minutes would never yield to the server and the bound would be decorative. - * The stamp is only CARRIED, and only when we actually honoured this browser's own choice — - * if the server's answer won, the old stamp is about a different view and is dropped. - */ - activeViewSeenRef.current = active.id; - activeViewAtRef.current = - active.id === local?.activeViewId ? local?.activeViewAt : undefined; - setFields(initialFields); - setViews(initialViews); - setActiveViewId(active.id); - setConfig(active.config); - setWorkspaceReady(true); - initializedKey.current = storageKey; - - /** - * BUG-1 (wave 11) — TELL THE HOST. The reconcile above was right about the state and silent - * about the consequence: it restored a rule this browser completed and the host copy does - * not have, and then nothing sent it. The autosave effect cannot: init seeds `views` AND - * `config` from the same reconciled object, so `sameConfig` reports "saved" and no upsert - * is ever emitted. The rule stays ACTIVE client-side, PENDING forever, and unknown to - * `_cl_resolve_measures` — which is exactly W11-HOST's live trace, a pending view carrying - * no measure rule at all while the saved-view path resolves correctly. - * - * ⚠ THIS TERMINATES, and NOT because the host will store what we send. It might not: a - * measure key outside this user's BU offer is stripped by `clean_filter_tree`, so the host - * copy never gains the rule and `lagged` stays true forever. Termination comes from the - * IDEMPOTENCE KEY — the filter tree itself, persisted per view — so we tell the host about - * a given tree exactly once and only speak again when the USER edits the conditions. - * ⚠ A ref cannot hold that: the remount this exists to survive destroys it. It rides in - * localStorage beside the views, and `writeLocal` below carries it forward. - * - * Emitted from the NORMALIZED copy (`initialViews`), never from the raw host view: that is - * the config the client is actually filtering with, and sending anything else would ask the - * host to resolve a question nobody on screen is asking. - */ - if (hostWorkspace && reemit.size > 0) { - const stamped = { ...(local?.reemitted ?? {}) }; - for (const view of initialViews) { - const key = reemit.get(view.id); - if (key === undefined) continue; - viewWritesRef.current = stampTombstone(viewWritesRef.current, view.id, - Date.now()); - viewWritesRef.current = stampTombstone(viewWritesRef.current, view.id, Date.now()); - emitHostEvent({ id: eventId("view"), type: "view_upsert", view }); - stamped[view.id] = key; - } - reemittedRef.current = stamped; - } else { - reemittedRef.current = { ...(local?.reemitted ?? {}) }; - } - }, [payload, payloadFields, storageKey, hostWorkspace, scope, isQueryPreview, queryBinding]); - - /** - * ⭐ W36-T03 — the view-preference stamp, and it is its OWN effect on purpose. - * - * ⛔ IT CANNOT LIVE INSIDE THE PERSIST EFFECT BELOW. `verify_grid_ux.py` pins that effect's - * guard as immediately followed by `writeLocal(storageKey` — the W35-T23 scan proving a Query - * surface writes no second copy of an artefact's spec — so a statement between the two turns - * that gate red, and that file is in no lane's fence to repair. Declared FIRST, so React runs - * it before the persist effect in the same commit and the ref it writes is already current. - * - * ⚠ Only a real MOVE stamps. `seen` is seeded by init from the id init itself resolved, so the - * first pass after a mount is never mistaken for the user changing view — which matters, - * because a stamp refreshed on every mount would make the freshness bound decorative. - */ - useEffect(() => { - if (!workspaceReady || !hostWorkspace) return; - if (activeViewSeenRef.current === activeViewId) return; - activeViewSeenRef.current = activeViewId; - activeViewAtRef.current = Date.now(); - }, [workspaceReady, hostWorkspace, activeViewId]); - - useEffect(() => { - // ⛔ `hostWorkspace`, and a Query surface is deliberately EXCLUDED (W35-T23 + T25). Its spec - // lives on the server, which is the only copy — writing a second one here would be a store - // with no reader, and gating a read on which of the two is fresher is the bug the init - // effect's note above describes. A LOCKED source's own workspace is a different question and - // is unaffected: that is `hostWorkspace` true, `recordsMutable` false. - if (!workspaceReady || !hostWorkspace) return; - writeLocal(storageKey, { - fields, - views, - activeViewId, - savedAt: Date.now(), - fieldStamps: pruneStamps(fieldStampsRef.current, Date.now()), - // BUG-1 — carried forward, never recomputed here. Dropping it would restore the loop: - // the next init would find no record of what it already told the host and say it again. - reemitted: reemittedRef.current, - // Pruned on the way out, like every other stamp map here: the persisted blob is a - // recent window, never an archive of everything this tab ever deleted. - viewTombstones: pruneTombstones(viewTombstonesRef.current, Date.now()), - // D-19's other half — without persisting these, a reload inside the echo window would - // drop a view this browser created seconds ago. - viewWrites: pruneTombstones(viewWritesRef.current, Date.now()), - // ⭐ W36-T03 — carried, not recomputed. This effect fires on every field, view and config - // write, so stamping `Date.now()` here would refresh the bound on activity that has nothing - // to do with which view is open — which is exactly why `savedAt` beside it cannot serve as - // the bound. The ref below moves only when `activeViewId` actually changes. - activeViewAt: activeViewAtRef.current, - }); - }, [workspaceReady, storageKey, fields, views, activeViewId, hostWorkspace]); - - /** - * ⭐ THE LIVE WORKSPACE (owner report, 2026-08-04) — what has APPEARED since we mounted. - * - * The init effect above runs ONCE per mount (`initializedKey`), which under Streamlit was - * indistinguishable from "once per host round trip" because a rerun replaces the iframe. - * The standalone shell has no rerun: this component stays mounted until `key={active.key}` - * changes in Shell.tsx — switching modules. So a cohort created here reached the store, - * came back in the very next `/workspace` re-read, sat in `payload.workspace.views`, and - * never reached the rail. That is the owner's report, exactly: it appears after you visit - * another module and come back, because that is the only thing that remounts this tree. - * - * ⚠ FIELDS FIRST, AND THE ORDER IS LOAD-BEARING. `fields_from_workspace(ws, - * cohorts=bool(cohort_lists))` emits the derived "Locked views" column only once a cohort - * EXISTS — so a user's FIRST cohort changes the field contract in the same payload that - * carries the new view. The adopted views are normalized against `nextFields`, not against - * the `fields` state (which React has not committed yet), or a projected `config.order` - * naming the new column would be normalized against a list that does not have it. - * - * ⚠ Add-only, tombstoned, and identity-stable — see liveWorkspace.ts for why each of those - * is a correctness property rather than a nicety. - */ - const hostWorkspaceViews = payload?.workspace?.views; - useEffect(() => { - // `hostWorkspace`: this adopts views out of `payload.workspace`, which a Query surface never - // fetches. Keyed on `embedded` it would read a workspace that is not there. - if (!workspaceReady || !hostWorkspace) return; - const now = Date.now(); - /** - * ⭐⭐ W36-T02 (owner item 1) — CORRECT, THEN ADOPT. The two steps answer two questions and - * this effect used to ask only the second one. - * - * ⛔ THE DEFECT, reproduced in `_test/gridUx.test.ts` T01/D before it was fixed: both merges - * below are add-only, and add-only was implemented as "skip any id I already hold" — which - * also means CANNOT CORRECT. So an edit made on the owner's machine reached this browser (the - * change-token poller notices and `reread()` fetches it) and was then thrown away, leaving a - * pre-edit filter tree and a pre-edit `agg` in place until a remount. Measured - * `{rows: 2, totals: false}` against a reload's `{rows: 4, totals: true, sum: "$4,000"}` off - * the SAME server state — the owner's *"only when I refresh it works"*, both symptoms, one - * cause. ⚠ The PRD blamed an init RACE; that one is real but self-healing, because - * `storageKey` is derived from the workspace and re-arms the guard. See mailbox/B.md B-1. - */ - const heldFields = reconcileHeldFields(fields, payloadFields, fieldStampsRef.current, now); - const nextFields = adoptNewFields( - heldFields, payloadFields, fieldStampsRef.current.deleted, now - ); - if (nextFields !== fields) setFields(nextFields); - const normalize = (config: Partial | undefined) => - normalizeConfig(config, nextFields); - const heldViews = reconcileHeldViews(views, hostWorkspaceViews, viewWritesRef.current, - now, normalize); - const nextViews = adoptNewViews(heldViews, hostWorkspaceViews, viewTombstonesRef.current, - now, normalize); - if (nextViews !== views) setViews(nextViews); - /** - * ⛔⛔ AND THE OPEN VIEW HAS TO FOLLOW ITS OWN DEFINITION, or the correction above is - * invisible. `config` is separate state, seeded at init and re-seeded from `views` ONLY when - * the user switches view (`selectView`) — so a corrected `views` array alone changes nothing - * on screen for the view already open, which is the half of this defect that a fix to the - * merge would silently leave behind (measured: the `adoptNewViews` negative control moves the - * identity assertion and NOT the row count). - * - * ⚠ `sameConfig(before.config, config)` IS THE UNSAVED-EDIT GUARD, and it is the autosave's - * own predicate rather than a second one. While a person is mid-edit the autosave has already - * diverged `config` from the view it is debouncing, so this refuses to touch it; the instant - * that write commits, `viewWrites` is stamped and `reconcileHeldViews` yields to the local row - * for a whole echo window anyway. Two guards, neither invented here. - */ - if (nextViews !== views) { - const before = views.find((view) => view.id === activeViewId); - const after = nextViews.find((view) => view.id === activeViewId); - // ⚠ `configRef`, NOT `config`. Reading the live value through a ref keeps `config` OUT of - // the dependency list, and that is a cost decision rather than a style one: `config` moves - // on every drag, sort and filter keystroke, and each re-entry would re-stringify every held - // field and view for an answer that cannot have changed — the host payload did not move. - if (before && after && before !== after && sameConfig(before.config, configRef.current)) - setConfig(after.config); - } - // ⚠ TERMINATION IS THE IDENTITY CONTRACT, not a flag: once corrected, both merges return - // their input by reference and `nextViews === views`, so the next pass sets nothing and the - // effect stops. That is why `views` can be a dependency at all. - }, [workspaceReady, hostWorkspaceViews, payloadFields, fields, views, activeViewId, - hostWorkspace]); - - /** Item 3c — stamp a def write / a delete. Pruned at every touch so the persisted blob - * stays a recent window, never an archive. */ - const stampFieldEdit = useCallback((key: string) => { - const s = pruneStamps(fieldStampsRef.current, Date.now()); - fieldStampsRef.current = { - edited: { ...(s.edited ?? {}), [key]: Date.now() }, - deleted: { ...(s.deleted ?? {}) }, - }; - }, []); - const stampFieldDelete = useCallback((key: string) => { - const s = pruneStamps(fieldStampsRef.current, Date.now()); - const edited = { ...(s.edited ?? {}) }; - delete edited[key]; - fieldStampsRef.current = { - edited, - deleted: { ...(s.deleted ?? {}), [key]: Date.now() }, - }; - }, []); - - const updateConfig = useCallback( - (next: ViewConfig) => { - if (isQueryPreview && queryBinding) { - signal(TOAST_EVENT, refuseQueryMutation(queryBinding, "update").message); - return; - } - if (next.groupBy !== config.groupBy) setCollapsed(new Set()); - setConfig(next); - }, - [config.groupBy, isQueryPreview, queryBinding] - ); - - const { - visibleCols, - fieldByKey, - order, - visible, - lockedKey, - onColumnResize, - onColumnMoved, - onColumnProposeMove, - setColumnVisible, - insertColumn, - } = useGridColumns(fields, config, updateConfig); - - /** - * ⭐ Wave-20 owner item 4 (ruling R8, contract C-ADDROW) — **THE GHOST ROW.** - * - * A trailing "+" row at the bottom of the grid, the way Airtable grows a table, replacing the - * "Add record" button that used to sit in the shell's header bar (S4 deleted it this wave). - * - * ⛔ USER DATABASES ONLY. A connector's rows are read-synced from Odoo; a "+" there could only - * refuse, and R8 names that a fake affordance. The test is the SCOPE (`ut_*`), which is also - * the only scope with a rows endpoint to POST to — so the affordance and the capability come - * from the same fact rather than from two lists that can drift. - * - * ⚠ THE ROW IS NOT ADDED LOCALLY. `rawRows` is the server's answer, and a client-invented row - * would have no rid, no defaults and no place in anyone else's copy. The POST clears the rows - * cache and fires `ROWS_STALE_EVENT`, `useCustomerData` re-reads, and the row arrives with the - * id the store gave it. The cursor then follows it (`newRowPid` + the effect below) — which is - * why the pid is remembered instead of glide's `"bottom"` being returned here: at the moment - * this resolves, the appended row does not exist yet, so "bottom" would land on the last OLD - * row. - */ - const isUserTable = !embedded && scope.startsWith("ut_"); - const recordsMutable = payload?.recordsMutable !== false; - const canMutateRecords = isUserTable && recordsMutable; - /** - * ⭐ 2026-08-07 — the databases a `link` column may point at, fetched when the column menu - * OPENS rather than on every render of the grid. - * - * ⚠ ON DEMAND IS THE WHOLE DESIGN. `GET /tables` walks every table in the tenant and returns - * their full field lists; hanging that off the grid's mount would put a workspace-wide read - * behind every page view for a picker almost nobody opens. The column menu is the only surface - * that needs it, so it is the surface that asks. - * ⚠ The list is left standing once fetched — a database created in another tab mid-session is - * a staleness a menu re-open corrects, and re-fetching per open would spend the same read - * repeatedly for a list that changes about once a week. - */ - const [linkTargets, setLinkTargets] = useState([]); - useEffect(() => { - // ⛔⛔ 2026-08-09 — `plusMenu` TOO, AND THAT OMISSION WAS THE WHOLE BUG. Owner: *"I am not - // able to see Choose Column for Post rows link"*. There are TWO doors into this editor — - // a column's own menu (`columnMenu`) and the "+" Add-field button (`plusMenu`) — and this - // effect knew about one. Via "+", `linkTargets` stayed `[]`, so `target` never resolved and - // the rollup Column picker held nothing but its placeholder, permanently. Editing an - // EXISTING column worked, which is what made it look like a rollup bug rather than a fetch - // that never fired. MEASURED on the live app: 5 selects rendered, "Column to roll up" with - // exactly 1 option while `GET /tables` returns `ut_ig_posts` with 22 fields. - // ⚠ A picker that is EMPTY and a picker whose data never loaded look identical, which is - // why this survived: both render "Choose a column…" over nothing. - if ((!columnMenu && !plusMenu) || linkTargets.length) return; - let live = true; - void fetchLinkTargets().then((t) => { - if (live) setLinkTargets(t); - }); - return () => { - live = false; - }; - }, [columnMenu, plusMenu, linkTargets.length]); - /** - * ⭐⭐ 2026-08-09 — the READ-THROUGH rollup's offer, fetched on the same terms as the link - * targets above: only when the column menu opens, and once per session. - * - * ⚠ THE GUARD IS A SEPARATE `asked` FLAG, NOT `topics.length`. An empty offer is the CORRECT - * and common answer (a tenant with nothing connected), so guarding on the length would re-ask - * on every single menu open for exactly the workspaces where the answer can never change — - * the one case the "fetch once" rule exists for. - */ - const [rollupSourceOffer, setRollupSourceOffer] = - useState({ topics: [], windows: [] }); - const rollupOfferAsked = useRef(false); - useEffect(() => { - // ⛔ SAME TWO DOORS as the link targets above. This one is less visible because an empty - // offer is the correct answer for a tenant with nothing connected — so a source offer that - // never loaded is indistinguishable from one that is legitimately empty, and the mode switch - // simply never appears. Fixing only the link half would have left that asymmetry in place. - if ((!columnMenu && !plusMenu) || rollupOfferAsked.current) return; - rollupOfferAsked.current = true; - let live = true; - void fetchRollupSources().then((o) => { - if (live) setRollupSourceOffer(o); - }); - return () => { - live = false; - }; - }, [columnMenu, plusMenu]); - /** - * ⭐ WAVE 27 · OWNER ITEM 22 — the values the ACTIVE VIEW forces on a new record. - * - * ⛔ THE DEFECT: "+" posted `{values: {}}` on every view, so a record added while a filter was - * on was created, was real, and was invisible — the button read as broken and the row the user - * then typed into did not exist as far as they could see. `filterSeed.ts` owns the derivation - * and its whole design is how much it REFUSES to derive (negations, ranges, `or` branches, - * contradictions, machine columns — each with its reason at the clause). - * - * ⚠ ONE FUNCTION, TWO CALLERS, deliberately: item 2's optimistic add must insert the row - * carrying these same cells, or the row it paints locally fails the filter it was added under - * and disappears on the next read — the exact blip C2 exists to prevent. - */ - const seedValues = useCallback( - () => filterSeedValues(config.filters, config.filterConj, fields, payload?.viewer), - [config.filters, config.filterConj, fields, payload?.viewer] - ); - /** - * ⭐ WAVE 27 · OWNER ITEM 2 (contract C2) — the "+" paints its row NOW. - * - * ⛔ THE ID IS MINTED THE WAY THE SERVER MINTS IT — `max(numeric row id) + 1`, which is - * `core.user_tables.add_row`'s own rule — and then POSTed as `{rid, values}` through the door - * the undo path already opened (C-ADDROW). That is what makes the optimism honest rather than - * hopeful: on the overwhelmingly common single-writer path the server stores exactly the id - * this browser drew, so the row on screen and the row in the store are the same record from - * the first frame. - * - * ⚠ AND WHEN THEY ARE NOT, IT RE-ANCHORS. Two people adding at once means the second POST - * finds the id taken; `add_row` falls back to `max+1` and ANSWERS with what it actually wrote - * (its own note says so), so the local row is re-keyed to the returned pid. Assuming the - * requested id came back is the one way this could leave a row on screen that no longer - * matches anything in the store. - * - * ⛔ NO `ROWS_STALE_EVENT` ON THE HAPPY PATH. That event re-reads the whole table, which is - * the cost item 2 exists to remove — and re-reading would also un-paint the row for the - * duration of the fetch, which is the NO-BLIP law's exact subject. The pending row is pruned - * by the effect above when a payload that already contains it arrives, from whatever cause. - * - * ⚠ THE NARROW RACE, STATED: a cell PATCHed within the POST's round trip could reach the - * server before the row exists. The window is now ~20ms (A moved the relation refresh off - * this path), it needs a keystroke inside it, and the PATCH's failure is a rolled-back - * overlay edit rather than lost data — the alternative, blocking the paint until the POST - * resolves, is the defect being fixed. - */ - const appendRow = useCallback(async (): Promise => { - if (!canMutateRecords) return undefined; - const seeded = seedValues(); - const minted = mintRid(); - const optimistic: Row = { ...seeded, pid: minted }; - setPendingRows((cur) => [...cur, optimistic]); - setNewRowPid(minted); - const made = await addTableRow(scope, seeded, minted); - if (!made) { - // `addTableRow` already said WHY, in the server's own words (a row cap, a refused profile - // cell, an unreachable host). The row is withdrawn rather than left standing: a row that - // survives its own failed write is the lie this whole path has to avoid. - setPendingRows((cur) => cur.filter((r) => r.pid !== minted)); - setNewRowPid(null); - return undefined; - } - if (made.pid !== minted) { - setPendingRows((cur) => - cur.map((r) => (r.pid === minted ? { ...r, pid: made.pid } : r)) - ); - setNewRowPid(made.pid); - } - // R4 — the append is undoable: Ctrl+Z deletes the row it just created, and a redo restores - // it under the SAME rid (the server's `{rid}` passthrough, C-ADDROW). - // ⛔ ITEM 22 MOVED THIS LINE. It read `values: {}` with a comment saying "an empty new record - // carries no values" — true until this wave, and now false: a row added on a filtered view is - // born holding the cells that filter forces. `redo` replays `r.values` verbatim - // (`:1569`), so leaving the literal would have made Ctrl+Z → Ctrl+Y restore the row under the - // right id with its seeded cells silently dropped — [[undo-capture-before-the-write]] in its - // quietest form, since the id and the row count would both be right. - undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), { - kind: "rowAdd", table: scope, rows: [{ rid: made.rid, values: seeded }], - }); - // ⛔ NO `signal(ROWS_STALE_EVENT)` HERE ANY MORE (item 2 / C2). It re-read the whole table - // on every "+", which is the whole cost the owner reported — and every OTHER caller of it - // still fires, so a row created by an automation, another tab or an undo still arrives. - // The one thing that used to depend on this refetch was the cursor landing on the new row, - // and the row is now on screen before the POST resolves, so it lands immediately. - // `undefined` is returned rather than glide's "bottom": "bottom" is resolved against the - // rows glide knew about when the gesture started, which is the row BEFORE this one. - return undefined; - }, [canMutateRecords, scope, seedValues, mintRid]); - - /* wave20 item 2 — measure key + window -> the columns that display it. Built ONCE per field - list and handed to every consumer of "which column is this rule about", so the tint - (`columnTones`, inside the hook above) and the column menu's filter doors resolve a - measure condition the same way. See `types.ruleColumnKeys`. */ - const measureCols = useMemo(() => measureColumnIndex(fields), [fields]); - - // Airtable behavior: configuration changes to the active view autosave. - useEffect(() => { - // ⭐⭐ W35-T23 — a Query view AUTOSAVES like any other view; only its TRANSPORT differs. - if (!workspaceReady || embedded) return; - const active = views.find((view) => view.id === activeViewId); - if (!active || sameConfig(active.config, config)) { - setSaveState("saved"); - return; - } - setSaveState("saving"); - if (saveTimer.current !== null) window.clearTimeout(saveTimer.current); - saveTimer.current = window.setTimeout(() => { - const updated = { ...active, config }; - setViews((current) => - current.map((view) => (view.id === updated.id ? updated : view)) - ); - viewWritesRef.current = stampTombstone(viewWritesRef.current, updated.id, - Date.now()); - // ⛔ THE ROUTER, NEVER A BARE `emitHostEvent`. A raw host event carries the MODULE write - // scope, which for a Query surface is the SOURCE database — so autosaving a resize would - // have written a view into the source's own workspace under the artefact's id. The router - // is what keeps "which store does this spec belong to" answered in one place. - // ⛔⛔ WAVE 35 QA — THE SUBJECT OF A QUERY WRITE IS THE ARTEFACT, AND STAMPING IT IS WHAT - // MAKES W35-T23 TRUE. This emitted `updated` unchanged, whose `id` is the SOURCE database's - // active view (the grid mounts on `gridScopeFor(active.source.database)`, so `views` are the - // source's). `routeQueryViewMutation` requires `view.id === binding.artifactId` and the - // server requires the same — so EVERY spec write on a Query surface was refused before it - // left the browser, and the person saw *"A Query view holds one saved answer…"*, the message - // written for a genuine duplicate-view attempt. - // ⚠ MEASURED, not deduced: resize, sort, group, hide and row height all failed identically - // on a pre-wave artefact AND a brand-new one — six for six, zero network writes, `edited` - // never flipping. The owner's item was *"I should be able to interact in each of the View - // under Query as well, exactly like how I would be able to interact with it under Database - // view"*, and the router's own docstring already says these are one thing to it. - // ⚠ ONLY the emitted subject is re-identified. `setViews` and the tombstone keep using - // `updated.id`, because the LOCAL row really is the source's view — the artefact id is who - // the WRITE is about, not who the grid is showing. - const subject = queryBinding ? { ...updated, id: queryBinding.artifactId } : updated; - const routed = routeQueryViewMutation(queryBinding, scope, { - id: eventId(queryBinding ? "query-view-update" : "view"), - type: "view_upsert", - view: subject as unknown as Record, - }, { query: mutateQueryWorkspace, native: emitHostEvent }); - if (routed.channel === "refused") - signal(TOAST_EVENT, routed.refusal?.message ?? "That view change could not be saved."); - // ⚠ REPORTED, not swallowed. A write that fails in silence reads exactly like a read that - // never happened [[lost-write-looks-like-failed-read]], and this is the one path a person - // triggers by dragging a column edge. - else if (routed.channel === "query") - void routed.result.then((result) => { - if (!result.ok) signal(TOAST_EVENT, result.message); - }); - setSaveState("saved"); - saveTimer.current = null; - }, 420); - return () => { - if (saveTimer.current !== null) window.clearTimeout(saveTimer.current); - }; - }, [workspaceReady, activeViewId, config, views, embedded, queryBinding, scope]); - - // Numeric dimensions force a glide relayout when either the component frame - // or Streamlit's main column changes width (notably sidebar collapse). - useLayoutEffect(() => { - const element = gridBoxRef.current; - if (!element) return; - const measure = () => { - const rect = element.getBoundingClientRect(); - if (rect.width > 0 && rect.height > 0) { - setGridSize({ - width: Math.floor(rect.width), - height: Math.floor(rect.height), - }); - } - }; - measure(); - const observer = new ResizeObserver(measure); - observer.observe(element); - window.addEventListener("resize", measure); - return () => { - observer.disconnect(); - window.removeEventListener("resize", measure); - }; - }, [workspaceReady]); - - // CG-3: one mode per table, decided by whether the payload is a window. See types.ts - // `TableMode` for why both engines running at once is the failure this prevents. - const mode = tableMode(payload?.counts); - const serverWindowed = mode === "server-windowed"; - // Owner items 4+6 — the Cohort page's grid renders without the Views sidebar. - const hideViews = !hostWorkspace || payload?.workspace?.hideViews === true; - // Wave-6 item 10 — how this view displays. A WINDOWED table is always the grid: list/ - // calendar/kanban compute over the whole matched set, and one page is not it (CG-3's rule, - // the same reason grouping is off there). - const displaySpec = cleanDisplay(config.display); - const displayMode: DisplayMode = serverWindowed ? "grid" : displaySpec?.mode ?? "grid"; - - // ═══════════════════════════════════════════════════════════════════════════════════════ - // ⭐⭐ WAVE 30 · W30-T42 (contract C2) — PAGING A READ-THROUGH GRID. - // ═══════════════════════════════════════════════════════════════════════════════════════ - // - // The client engine is already refused in this mode (`useVisibleRows` passes rows straight - // through), which is C2's "F may render no client-side predicate over a windowed grid". That - // clause on its own would leave a filter chip INERT: the user narrows 32,826 orders, nothing - // filters, and the count keeps reading 32,826 — honest about the scope and silent about the - // question. So the predicate has to reach the evaluator that CAN answer it, which is SQL. - // - // ⛔ THE SAVED VIEW'S OWN OBJECTS GO ON THE WIRE, UNTRANSLATED (see `windowRowsPath`). - const windowPredicate = useMemo( - () => windowPredicateKey(config.filters, config.filterConj, config.sorts, search), - [config.filters, config.filterConj, config.sorts, search] - ); - const windowRequest = useCallback( - (offset: number) => ({ - offset, - limit: WINDOW_ROWS, - filters: config.filters, - filterConj: config.filterConj, - sorts: config.sorts, - search, - }), - [config.filters, config.filterConj, config.sorts, search] - ); - const sentPredicate = useRef(null); - useEffect(() => { - if (!serverWindowed) { - sentPredicate.current = null; - return; - } - if (sentPredicate.current === windowPredicate) return; - // ⚠ THE FIRST WINDOW WENT OUT WITH NO QUERY ARGS AT ALL — `config` is this component's own - // state, seeded from a workspace call that had not landed when the rows request left. So a - // view carrying no filter, no sort and no search has ALREADY been answered, and firing here - // would spend a round trip to receive the bytes on screen. Any other predicate is a real - // question and goes out. One function owns both the key and the empty case, so "no - // predicate" cannot be spelled two ways. - if (sentPredicate.current === null && windowPredicate === EMPTY_WINDOW_PREDICATE) { - sentPredicate.current = windowPredicate; - return; - } - sentPredicate.current = windowPredicate; - requestWindow(windowRequest(0)); - }, [serverWindowed, windowPredicate, windowRequest, requestWindow]); - - /** - * The scroll → next window. glide reports the visible RECTANGLE; `nextWindowOffset` decides. - * - * ⚠ `counts.shown` IS THE LOADED COUNT, not `rawRows.length`. They are the same number today - * and would stop being one the moment anything layers a row in that the server did not send - * (`pendingRows` does exactly that on editable tables) — and the offset this produces is a - * promise to the server about where our contiguous run ends. It has to come from the merge - * that built the run. - */ - const onVisibleRegionChanged = useCallback( - (range: Rectangle) => { - if (!serverWindowed) return; - const offset = nextWindowOffset({ - lastVisibleRow: range.y + range.height, - loaded: payload?.counts?.shown ?? 0, - matched: payload?.counts?.matched ?? 0, - limit: WINDOW_ROWS, - }); - if (offset !== null) requestWindow(windowRequest(offset)); - }, - [serverWindowed, payload?.counts?.shown, payload?.counts?.matched, requestWindow, - windowRequest] - ); - - // CG-8 — measure conditions, answered by the host as `{ruleId: pid[]}`. Converted to Sets - // once here rather than per row: the pipeline runs this over every row of the book. - // - // ⚠ REPLACED, not merged. Merging looks like the safer choice — "hold the previous result" - // is the design — but it holds it FOREVER: the host omits a rule's id when it could not - // resolve it (a warming store, a scope change mid-flight), and a merged map keeps yesterday's - // answer under today's question with `pendingMeasures` reporting zero, so the marker never - // shows. That is the widening sin wearing a confident count. - // - // Holding the previous result across a LOCAL edit does not need the merge and never did: the - // memo is keyed on `payload.measureSets` identity, which does not change while the user is - // typing, so the ref already carries the last answers through to the next payload. Merge and - // replace therefore differ only in the dangerous case. - const measures = useMemo(() => payload?.measures ?? [], [payload?.measures]); - const measureSetsRef = useRef({}); - const measureSets = useMemo(() => { - const incoming = payload?.measureSets; - if (incoming) { - const next: MeasureSets = {}; - for (const [ruleId, pids] of Object.entries(incoming)) next[ruleId] = new Set(pids); - measureSetsRef.current = next; - } - return measureSetsRef.current; - }, [payload?.measureSets]); - const pendingMeasureCount = useMemo( - () => pendingMeasures(config.filters, measureSets), - [config.filters, measureSets] - ); - // BUG-1 (wave 11) — the two key sets, published for the handshake. See the `.cg-shell` - // attributes below for why they are DOM attributes and not console logs. - const measureRuleKeys = useMemo( - () => activeMeasureRuleIds(config.filters).join(","), - [config.filters] - ); - const measureSetKeys = useMemo(() => Object.keys(measureSets).join(","), [measureSets]); - - // Owner item 5. Cohort membership rides `workspace.lists`, the same array the "Add to list" - // picker already reads, so the names offered and the sets tested come from one place. A - // cohort with no `pids` is a target you can add to but cannot filter on; the engine treats it - // as unanswerable (matches nothing) rather than absent (matches everything). - const lists = useMemo(() => payload?.workspace?.lists ?? [], [payload?.workspace?.lists]); - /* wave17 R1 / C-LOCKV — `railCohortId` is gone. It held the TRANSIENT lock the retired - Cohorts section applied on click: a lock that lived in component state and was never - persisted. A locked view now carries its own `config.cohortLock`, so SELECTING the view - is the lock and the saved config is the only source. One mechanism, and it survives a - reload — which the transient one never did. */ - const cohortSets = useMemo(() => { - const out: CohortSets = {}; - for (const l of lists) if (l.pids) out[l.id] = new Set(l.pids); - return out; - }, [lists]); - // The TENANT'S today. Never `new Date()`: a browser a day ahead of the server would resolve - // "the past month" to a different month than the host summed, and the count would deny the - // rows with nothing erroring. - const today = payload?.today; - // Wave-5 item 1 — who is looking (host-computed). ONE editability verdict for every edit - // door (cells, pickers, drawer): types.mayEditField — stratum + read-only-by-nature + - // permissions vs the viewer, fail-closed on restricted fields when the viewer is unknown. - const viewer = payload?.viewer; - const canEditField = useCallback( - (f: Field): boolean => !embedded && recordsMutable && mayEditField(f, viewer), - [embedded, recordsMutable, viewer] - ); - const unresolvedCount = useMemo( - () => unresolvedConditions(config.filters, { cohortSets, today }), - [config.filters, cohortSets, today] - ); - - // Wave-5 items 9/11 — CLIENT-COMPUTED cells, injected AT their field keys so the whole - // pipeline (filter/search/sort/group), the cells and the drawer read them like any other - // value. Formulas parse ONCE per definition change (never per row); evaluation reads the - // row WITH this session's overlay edits layered, so editing a referenced field recomputes - // live. A formula that does not parse, or errors on a row, yields BLANK — never a wrong - // number (formulaEngine.ts). `created_time` copies the row's `_created`. - // 2026-07-31 (owner item 2): formulas may reference OTHER formulas now, so parse order is - // TOPOLOGICAL (orderFormulas) — a formula runs after the formulas it reads, cycle members - // never run (blank, never a stale number), and each row's results feed the next formula - // through a per-row scope. - const formulaAsts = useMemo(() => { - const sources = new Map(); - for (const f of fields) { - if (f.type !== "formula") continue; - const src = formulaOf(f); - if (src) sources.set(f.key, src); - } - const { order, cyclic } = orderFormulas(sources); - const out: { key: string; ast: FormulaAst }[] = []; - for (const key of order) { - if (cyclic.has(key)) continue; - const p = parseFormula(sources.get(key)!); - if (p.ok) out.push({ key, ast: p.ast }); - } - return out; - }, [fields]); - const createdTimeKeys = useMemo( - () => fields.filter((f) => f.type === "created_time").map((f) => f.key), - [fields] - ); - const computedRows = useMemo(() => { - if (formulaAsts.length === 0 && createdTimeKeys.length === 0) return rawRows; - const env = { today: payload?.today }; - return rawRows.map((r) => { - const edits = overlayEdits[r.pid]; - const scope: Row = edits ? { ...r, ...edits } : { ...r }; - const out: Row = { ...r }; - for (const k of createdTimeKeys) { - out[k] = (r._created as string | undefined) ?? null; - scope[k] = out[k]; - } - for (const { key, ast } of formulaAsts) { - const v = evalFormula(ast, (k) => scope[k], env); - out[key] = v; - scope[key] = v; // later formulas read this one's result — the topo order above - } - return out; - }); - }, [rawRows, overlayEdits, formulaAsts, createdTimeKeys, payload?.today]); - - // Wave-2 item 2c — COHORT MODE (the Cohort page). The host serves the WHOLE pool (rows + - // derived values over the pool); the ACTIVE cohort scopes the table to its pids CLIENT-side. - const cohortMode = payload?.workspace?.cohortMode === true; - useEffect(() => { - if (!cohortMode) return; - if (activeCohortId && lists.some((l) => l.id === activeCohortId)) return; - setActiveCohortId(lists[0]?.id ?? null); - }, [cohortMode, lists, activeCohortId]); - const activeCohort = cohortMode - ? lists.find((l) => l.id === activeCohortId) ?? null - : null; - const cohortMemberSet = useMemo( - () => new Set(activeCohort?.pids ?? []), - [activeCohort] - ); - // The FIXED subset is the scope. A cohort with no membership shows nothing — falling back to - // the whole pool would put 1,500 rows under a rail entry that says 12, which is the widening - // sin wearing a sidebar. Everything downstream (pipeline, counts, selection, detail) runs - // over this scope, so the toolbar count is the cohort's matched count by construction. - const scopedRows = useMemo( - () => - cohortMode - ? computedRows.filter((r) => cohortMemberSet.has(r.pid)) - : computedRows, - [cohortMode, computedRows, cohortMemberSet] - ); - - const { visibleRows, pidToIndex } = useVisibleRows( - scopedRows, - fields, - config.filters, - search, - config.sorts, - // grouping a WINDOW would headline a page's count/subtotals as the group's - serverWindowed ? null : config.groupBy, - collapsed, - config.memberPids, - config.filterConj, - serverWindowed, - measureSets, - cohortSets, - today, - // Item 12 (C-LOCK) — the 14th positional, RECORD's engine input. The lock intersects - // FIRST, so everything downstream (conditions, memberPids, the ranking domain) operates - // inside the cohort. Absent = today's behaviour exactly. - // wave17 R1 / C-LOCKV: the view's SAVED lock is now the only source. A locked view is a - // saved view whose `config.cohortLock` names its own id, so opening it IS applying the - // lock — the wave-15 transient rail pick that used to outrank this is gone with its rail. - config.cohortLock - ); - - // Owner item 8 — the DISPLAY cap. The pipeline above still ran over the whole book (counts, - // "Add to list" and selection-by-pid all depend on that); only what glide PAINTS is sliced. - // pidToIndex is rebuilt over the slice because every index consumer (selection, detail - // navigation, hover) is positional against what is actually on screen. - const [displayCap, setDisplayCap] = useState(DISPLAY_PAGE); - /** - * ⛔ W30-T42 — NO CLIENT CAP ON A WINDOWED TABLE, AND THE TWO CAPS WOULD HAVE FOUGHT. - * - * The display cap exists because the whole-book path holds every row and glide should not be - * asked to lay out 33,000 of them at once. In `server-windowed` mode the WINDOW is already - * that bound — one page of `WINDOW_ROWS` — so a second cap on top would stop the grid 50 rows - * in, under a "Showing first 50 of 200" bar, while the toolbar says "showing 200 of 32,826". - * Two truncation stories about one table, neither of them wrong, together a lie. - * - * ⛔ AND IT WOULD HAVE BROKEN THE PAGING OUTRIGHT: the scroll decision reads how far down the - * LOADED rows the viewport reaches, and a capped grid can never scroll past the cap, so the - * end of the window would be unreachable and the next window never requested. - */ - const capped = !serverWindowed && visibleRows.length > displayCap; - /** - * ⭐⭐ WAVE-29 T33 (owner item 17) — THE TOTALS ROW, and the cap fix it forced. - * - * ⛔ THE SUMMARY IS OVER ALL M MATCHED ROWS, NEVER THE N PAINTED. The pipeline above already - * ran over the whole book; only what glide paints is sliced. A total computed from the slice - * would silently describe the first `DISPLAY_PAGE` records while sitting under a bar that says - * "Showing first N of M" — an answer to a question nobody asked, wearing the right label. - * - * ⛔ AND THE SLICE ITSELF WAS ALREADY WRONG FOR GROUPS. `visibleRows` is FLATTENED (header, - * rows, footer, header, …), so a straight `slice(0, cap)` could cut a group between its last - * row and its footer — the subtotal simply vanished, for the groups furthest down, with no - * marker of any kind. `sliceForDisplay` counts DATA rows toward the cap and keeps the - * structural rows of every group it admits, so a group is whole or absent. - */ - const totalsAggs = useMemo( - () => computeAggs( - visibleRows.flatMap((vr) => (vr.kind === "data" ? [vr.record] : [])), - fields - ), - [visibleRows, fields] - ); - /** - * ⭐⭐ WAVE 30 · W30-T42 (contract C2 / the ticket's own named trap) — THE TOTALS ROW OVER A - * WINDOW IS DISCLOSED, NOT DELETED. - * - * ⛔ THE TRAP. `totalsAggs` folds `visibleRows`, which in `server-windowed` mode is exactly the - * rows this browser has loaded. Painted in the table's footer with no denominator, "the sum of - * whatever happens to be in memory" wears the sum of 32,826 orders — a fabricated aggregate, - * the thing [[no-unverifiable-aggregates]] exists to forbid. Grouping is refused in this mode - * for the same reason (a group header over a window subtotals the PAGE while claiming to - * describe the group), and that refusal stays: a group is a claim about a SET, and the client - * cannot see the set. - * - * ⛔ BUT ABSENT IS NOT ONE OF THE TWO HONEST STATES. T42's done-when offers exactly two — - * "reads from the server" or "says plainly that it covers the loaded window" — and the wire - * carries no aggregates (`rows/total/totalUnfiltered/offset/limit/limits`), so the second one - * is the one available. The fold is CORRECT for a question nobody asked; naming the question - * is what makes it honest, and the loaded rows are on screen and scrollable, so the number - * still drills to rows. The disclosure is `windowedFoldNote`, and it is bound to `showTotals` - * by ONE memo below rather than by two conditions that could drift apart — a totals row over a - * window with its sentence missing is the whole defect back again. - */ - const showTotals = useMemo( - () => !config.groupBy && Object.keys(totalsAggs).length > 0, - [config.groupBy, totalsAggs] - ); - /** The denominator sentence — non-null EXACTLY when a totals row is painted over a window that - * does not hold the whole matched set. Null on a whole-book table (the total is total) and on - * a window that happens to hold everything (`windowedFoldNote` refuses to invent a - * truncation that is not there). */ - const foldNote = useMemo( - () => - serverWindowed && showTotals - ? windowedFoldNote(payload?.counts?.shown ?? 0, payload?.counts?.matched ?? 0) - : null, - [serverWindowed, showTotals, payload?.counts?.shown, payload?.counts?.matched] - ); - /** R6's SECOND SENTENCE, on the client side of the wire: the limits D's route DECLARED on this - * response. Received and painted nothing = the limit is silent again, one layer further out. */ - // ⭐ W32-T03: `counts` rides along so the clamped-window clause can say "Showing 5,000 of - // 963,783 rows" rather than naming the server's own word for what was limited. Optional on the - // callee, so the sentence degrades to its cause rather than to silence when a payload has none. - const limitNote = useMemo( - () => limitSummary(payload?.limits, payload?.counts), - [payload?.limits, payload?.counts] - ); - /** - * ⛔ AND THE LIMIT NOBODY DECLARED, WHICH IS THE ONE A PERSON ACTUALLY NOTICES. Nine controls - * this component gates on `serverWindowed` vanish the moment a grid becomes a window — export, - * cohorts, folders, grouping, the four alternative views, select-from-file, alert badges — all - * of which were working on this grid the day before, because the whole table used to be in the - * browser. Every refusal is right on its own; a screen where eight buttons quietly disappear - * is not. See `windowedCapabilityNote` for the list and why it is not a guess. - */ - const capabilityNote = useMemo( - () => (serverWindowed ? windowedCapabilityNote(payload?.counts?.matched ?? 0) : null), - [serverWindowed, payload?.counts?.matched] - ); - /** - * ⭐⭐ W35-T24 (owner item 4 / R2, R6's second sentence) — WHY THERE IS NO TRAILING "+". - * - * ⛔ NOT SCOPED TO QUERY, DELIBERATELY, AND THAT IS WIDER THAN THE TICKET ASKED. The predicate - * is the SOURCE's (`records_mutable`, DESIGN.md §4's locked-database vocabulary), so the answer - * belongs to the database rather than to the surface looking at it. Scoping the sentence to - * Query would make one database say two different things depending on which door you opened it - * through, which is the failure a shared predicate exists to prevent. - * - * ⚠ `embedded` IS EXEMPT. A linked-record grid inside a modal has no "+" because it is a - * picker, not a table, and a footnote apologising for that would be chrome in a dialog. - */ - /** - * ⭐ W35-T30 (C5) — the list the RAIL renders: this workspace's views, plus the server's - * "Starred records" projection when there is one. See the `views=` prop below for why it is - * appended here and not merged into `views`. - */ - /** - * ⭐⭐ W36-T04 (owner item 6 · R3 · contract C3) — THE CODE-SCRIPT VIEWS OF THIS DATABASE. - * - * ⛔ THEY ARE NOT WORKSPACE VIEWS, and that is the design rather than a shortcut. A script - * view IS its source and its version history, which live in E's own per-database store - * (`/api/v1/script-views`); a workspace view holds a filter tree and a column order, and the - * two have nothing in common but a name in a rail. Keeping them apart is also what makes R3's - * "unlimited versions per database, so an agent can leave three attempts side by side" - * expressible at all, and it is why `aios_grid.DISPLAY_MODES` never needs the word `script` - * (see the hold recorded in `types.ts` and in `icons.test.ts::HELD_MODES`). - * - * ⚠ THE SAME SHAPE THE STARRED PROJECTION USES: a row that JOINS the rail without being a - * stored view, resolved by id here rather than in the rail. One precedent, not two. - * ⚠ `hostWorkspace` only. A Query surface draws one artefact and has no database of its own - * to scope scripts to; an embedded picker is not a place to run anything. - */ - const [scriptRows, setScriptRows] = useState([]); - const [scriptBump, setScriptBump] = useState(0); - useEffect(() => { - if (!hostWorkspace || !isUserTable) return; - let live = true; - void listScriptViews(scope).then((answer) => { - if (live) setScriptRows(answer.views); - }); - return () => { - live = false; - }; - }, [hostWorkspace, isUserTable, scope, scriptBump]); - // The mode is what makes the rail draw the right icon, label and tone for it, AND what makes - // the grid stand down when one is open. Nothing ever sends this config to `view_upsert`, which - // is why the host mirror is not needed. - const scriptProjections = useMemo( - () => scriptRows.map(scriptProjectionView), - [scriptRows] - ); - /** - * ⛔ OPENING A SCRIPT VIEW IS A SELECTION, NOT A STATE POKE. `setActiveViewId` alone mounts the - * panel and leaves `config` on the previous view, so `displayMode` stays `"grid"` and the GRID - * PAINTS UNDERNEATH IT. This is the same work `selectView` does, reachable from the two doors - * that hold the row already (the panel's list, and the create that follows a request) and - * therefore cannot wait for `scriptProjections` to re-render. - */ - const openScriptView = useCallback((row: { id: string; name: string }) => { - const view = scriptProjectionView(row); - setActiveViewId(view.id); - setConfig(view.config); - setCollapsed(new Set()); - setSearch(""); - setDetailPid(null); - // ⚠ AND THE DISPLAY CAP, which `selectView` also resets. The script panel renders no rows, so - // nothing here reads it — but leaving it out would make this opener a FIVE-of-six copy of a - // six-step reset, and the next person could not tell the omission from an oversight. Symmetry - // is cheaper than the comment explaining why it is missing. - setDisplayCap(DISPLAY_PAGE); - }, []); - const railViews = useMemo(() => { - // ⛔ THE STARRED PROJECTION KEEPS ITS OWN STATEMENT, VERBATIM, AND IS NOT FOLDED INTO THE - // EXPRESSION BELOW. `verify_grid_ux.py`'s record-star scan pins this exact line as the proof - // of D-229's shape ("the row is not drawn at all until something is starred"), and that file - // is in no lane's fence to repair. Rewriting it as a ternary turned the gate red on a claim - // that was still true. Two projections, two statements, one list. - const starredRail = (): SavedView[] => { - const projection = recordStars.stars.view; - if (!projection || views.some((view) => view.id === projection.id)) return views; - return [...views, projection]; - }; - const base = starredRail(); - return scriptProjections.length === 0 ? base : [...base, ...scriptProjections]; - }, [views, recordStars.stars.view, scriptProjections]); - /** The one currently open, if any: its SOURCE is fetched on demand, never with the list. */ - const activeScriptId = scriptRows.some((row) => row.id === activeViewId) ? activeViewId : null; - const [scriptDoc, setScriptDoc] = useState(null); - const [scriptRun, setScriptRun] = useState(null); - const [scriptBusy, setScriptBusy] = useState<"" | "run" | "save">(""); - useEffect(() => { - if (activeScriptId === null) { - setScriptDoc(null); - setScriptRun(null); - return; - } - let live = true; - setScriptRun(null); - void readScriptView(activeScriptId).then((answer) => { - if (live) setScriptDoc(answer.view); - }); - return () => { - live = false; - }; - }, [activeScriptId]); - const onScriptRun = useCallback((draft: string) => { - if (activeScriptId === null) return; - setScriptBusy("run"); - void runScriptView(activeScriptId, draft).then((answer) => { - setScriptRun(answer); - setScriptBusy(""); - }); - }, [activeScriptId]); - /** - * ⭐⭐ W36-T05 (owner item 6) — THE VIEW AGENT PANEL'S OPEN STATE AND ITS ONE WRITE. - * - * ⛔ CLOSED MEANS ABSENT FROM THE TREE, not hidden. F's fold rule is - * `.shell-root:has(.cg-agent-panel) .shell-side` and `:has()` matches an element that EXISTS, - * so a `display: none` panel would hold the main rail folded to 48px over nothing at all - * (wiring W8). The ternary below is therefore load-bearing, not a style choice. - */ - const [agentOpen, setAgentOpen] = useState(false); - const [agentError, setAgentError] = useState(null); - const [agentBusy, setAgentBusy] = useState(false); - const onAgentCreate = useCallback((request: string) => { - setAgentBusy(true); - setAgentError(null); - void createScriptView(scope, seedScript(request), seedName(request)).then((answer) => { - setAgentBusy(false); - if (!answer.view) { - // ⚠ SHOWN IN THE PANEL, not toasted. The reader is looking at the box they just typed - // into; a toast that fades takes the answer away from the question. - setAgentError(answer.error ?? "That script view could not be created."); - return; - } - // R3 — unlimited per database, so there is no cap state to reach. The rail simply grows, - // which is what lets an agent leave three attempts side by side for the owner to compare. - setScriptRows((rows) => [...rows, answer.view as ScriptViewRow]); - openScriptView(answer.view); - setScriptDoc(answer.view); - setScriptRun(null); - }); - }, [scope, openScriptView]); - const onScriptSave = useCallback((source: string) => { - if (activeScriptId === null) return; - setScriptBusy("save"); - void saveScriptView(activeScriptId, source).then((answer) => { - if (answer.view) { - setScriptDoc(answer.view); - // The list carries the version number the rail shows, so a save refreshes it. - setScriptBump((b) => b + 1); - } else if (answer.error) { - signal(TOAST_EVENT, answer.error); - } - setScriptBusy(""); - }); - }, [activeScriptId]); - const lockedNote = useMemo( - // ⚠ The label is the ARTEFACT's source name when there is one, and otherwise nothing: the - // payload carries no database name, and "This database" is unambiguous on a surface whose - // header already says which one it is. Guessing a name from the scope key would print - // `ut_odoo_customers` at somebody. - () => (embedded ? null - : lockedRecordsNote(recordsMutable, isUserTable, queryBinding?.source.label)), - [embedded, recordsMutable, isUserTable, queryBinding] - ); - const displayRows = useMemo(() => { - const shown = capped ? sliceForDisplay(visibleRows, displayCap) : visibleRows; - // ⚠ APPENDED AFTER THE SLICE, so the cap can never eat the totals row itself — and it is the - // LAST row, which is what `freezeTrailingRows={1}` pins. - return showTotals - ? [...shown, - { kind: "group-footer" as const, groupKey: TOTAL_GROUP_KEY, aggs: totalsAggs }] - : shown; - }, [capped, visibleRows, displayCap, showTotals, totalsAggs]); - const displayPidToIndex = useMemo(() => { - if (!capped) return pidToIndex; - const m = new Map(); - displayRows.forEach((vr, i) => { - if (vr.kind === "data" && !m.has(vr.record.pid)) m.set(vr.record.pid, i); - }); - return m; - }, [capped, displayRows, pidToIndex]); - - // C-AVATAR (wave-14 item 11) — profile photos, absent until HOST serves them. The tick is the - // async half: `cells.setAvatarRepaint` fires it when an Image finishes decoding, and it is a - // dep of `getCellContent`, which is the only thing glide watches. See useGetCellContent. - const [avatarTick, setAvatarTick] = useState(0); - useEffect(() => { - setAvatarRepaint(() => setAvatarTick((t) => t + 1)); - return () => setAvatarRepaint(undefined); - }, []); - const userAvatars = payload?.workspace?.userAvatars; - /** - * Item 15 — the frozen strip's usable width, which is what a GROUP BAR's label is clipped to - * now that the first column is pinned in every mode. `null` when nothing is grouped (no bar to - * clip) — see `fitGroupLabel` for why the clip exists and why the count outranks the name. - */ - const groupLabelSpace = useMemo(() => { - if (!config.groupBy) return null; - const n = Math.min(frozenCountOf(config), visibleCols.length); - let px = 0; - // `GridColumn` is a union — only its SIZED member declares `width`, and `useGridColumns` - // always builds that one. Narrowed rather than asserted so an auto-sized column (which we - // do not create) would read 0 and simply not be counted, instead of throwing in a memo. - for (let i = 0; i < n; i += 1) - px += (visibleCols[i] as { width?: number } | undefined)?.width ?? 0; - return Math.max(0, px - GROUP_LABEL_PAD); - }, [config, visibleCols]); - /** - * ⭐ owner item 2 (2026-08-03) — WHICH MEASURE COLUMNS ARE STILL BEING CALCULATED. - * - * A measure column's numbers are resolved by the server (one aggregate over the whole book) - * and reach the browser on the workspace re-read that follows the create — seconds later. - * In between, the column is on screen with nothing in it, which the owner correctly read as - * an error rather than as a wait. - * - * ⛔ `editRequestId` IS THE TEST, and it is the honest one. It is set optimistically at the - * moment the create is emitted and cleared by `reconcileFields` when the host's own copy of - * that field comes back — and the host's copy travels in the SAME `/workspace` response as - * `derived`, which is where the values are. So the flag is true across exactly the window - * where the column exists and its numbers do not, and false the instant they land. - * - * The alternative — "no value anywhere in the column" — cannot tell a pending column from - * one that permanently failed to resolve (a BU-scoped caller on a company-level measure, - * say), and would spin forever on the second. This one resolves either way: when the echo - * arrives with no values, the flag clears and the cells go honestly blank. - */ - const pendingMeasureKeys = useMemo(() => { - const out = new Set(); - for (const f of fields) - if (f.editRequestId && f.key.startsWith("measure_")) out.add(f.key); - return out; - }, [fields]); - /** - * The skeleton's pulse. A canvas cell cannot hold a CSS animation, so the motion is repaints: - * this counter is a dependency of `getCellContent`, which is the only thing glide watches. - * - * ⚠ IT RUNS ONLY WHILE SOMETHING IS PENDING, and the effect's own guard is what stops it — - * an interval left running would repaint the whole canvas ~7×/s forever, on every grid, to - * animate nothing. 140ms × the 4-step ramp is a ~0.6s cycle: a wait, not a strobe. - */ - const [pulse, setPulse] = useState(0); - /** - * ⛔ AND IT GIVES UP. A skeleton that never resolves is worse than the blank it replaced: a - * blank cell is at least honest about having no number, while a permanent shimmer promises - * one that is never coming. - * - * The window it guards is narrow but real. `editRequestId` clears when `reconcileFields` takes - * the host's copy — and that function has a branch (`hostAuthoritative: false`, i.e. a payload - * with no workspace) whose `{...local, ...host}` spread would PRESERVE the flag forever. Today - * that branch cannot be reached with a measure column on screen (measures are offered through - * the workspace, so a payload without one cannot have produced this field), which is an - * argument about the current call graph and not a property of the code. This bound holds - * whether or not the argument stays true, and it costs one boolean. - */ - const [pendingGaveUp, setPendingGaveUp] = useState(false); - useEffect(() => { - setPendingGaveUp(false); - if (pendingMeasureKeys.size === 0) return; - let n = 0; - const id = window.setInterval(() => { - n += 1; - if (n > PULSE_MAX_TICKS) { - window.clearInterval(id); - setPendingGaveUp(true); // fall back to ordinary blank cells - return; - } - setPulse((p) => p + 1); - }, PULSE_MS); - return () => window.clearInterval(id); - }, [pendingMeasureKeys]); - const activePendingKeys = pendingGaveUp ? NO_PENDING_KEYS : pendingMeasureKeys; - const getCellContent = useGetCellContent( - displayRows, - visibleCols, - fieldByKey, - overlayEdits, - canEditField, - userAvatars, - avatarTick, - groupLabelSpace, - measureGroupText, - activePendingKeys, - pulse - ); - const { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid, - setActiveCell, clearSelection } = - useGridSelection( - displayRows, - displayPidToIndex, - visibleCols.length, - embeddedSelectable ? embeddedSelectedIds : [] - ); - const embeddedSelectedKey = embeddedSelectedIds.join(","); - const selectedPidKey = [...selectedPids].join(","); - useEffect(() => { - if (!embeddedSelectable) return; - if (selectedPidKey !== embeddedSelectedKey) - selectPids(embeddedSelectedIds as number[], "replace"); - }, [embeddedSelectable, embeddedSelectedKey, embeddedSelectedIds, selectedPidKey, selectPids]); - useEffect(() => { - if (!embeddedSelectable) return; - onEmbeddedSelectionChange?.([...selectedPids]); - }, [embeddedSelectable, onEmbeddedSelectionChange, selectedPids]); - /* ════════════════════════ owner item 16 / R4 / C-UNDO ════════════════════════ - THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one - thing it cannot: reading the value a cell held BEFORE the write, which only exists at the - call site. Everything below funnels through `patchAndRecord` / `patchManyAndRecord`, so a - write path that forgets to record is a write path that does not reach the store either. - - ⚠ A REF, NOT STATE. Nothing on screen depends on the stack in v1 (no undo button), so - keeping it in state would repaint the grid on every keystroke of a paste for no pixels. - Per SCOPE (a Ctrl+Z on the Customer grid must never rewrite a user table's cell) and per - tab (nothing persists it — a stack restored into a session that did not make those edits - would undo somebody else's work). */ - const undoBook = useRef({}); - const applyingUndo = useRef(false); - - /** The value a cell holds RIGHT NOW: the overlay stratum wins over the payload row, exactly - * as `useGetCellContent` renders it — so what undo restores is what was on screen. */ - const rowByPid = useMemo(() => { - const map = new Map(); - for (const r of rawRows) map.set(r.pid, r); - return map; - }, [rawRows]); - const currentValue = useCallback( - (pid: number, key: string): UndoValue => { - const edited = overlayEdits[pid]?.[key]; - const raw = edited !== undefined ? edited : rowByPid.get(pid)?.[key]; - return raw == null ? null : (raw as UndoValue); - }, - [overlayEdits, rowByPid] - ); - - const recordCells = useCallback( - (changes: CellChange[], label: string) => { - if (applyingUndo.current || changes.length === 0) return; - undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), { - kind: "cells", label, changes, - }); - }, - [scope] - ); - - /** ONE cell write, recorded. Every editor, picker and drag goes through this. */ - const patchAndRecord = useCallback( - (pid: number, updates: Partial, label = "an edit") => { - const changes: CellChange[] = Object.entries(updates).map(([key, value]) => ({ - pid, key, - before: currentValue(pid, key), - after: (value ?? null) as UndoValue, - })); - patchOverlay(pid, updates); - recordCells(changes, label); - }, - [currentValue, patchOverlay, recordCells] - ); - - /** MANY cells, ONE stack entry — a paste and a bulk clear are each one user action (R4). */ - const patchManyAndRecord = useCallback( - (writes: { pid: number; updates: Partial }[], label: string) => { - const changes: CellChange[] = []; - for (const w of writes) - for (const [key, value] of Object.entries(w.updates)) - changes.push({ - pid: w.pid, key, - before: currentValue(w.pid, key), - after: (value ?? null) as UndoValue, - }); - for (const w of writes) patchOverlay(w.pid, w.updates); - recordCells(changes, label); - }, - [currentValue, patchOverlay, recordCells] - ); - - /** - * Apply one stack entry in one direction. The INVERSE lives in `undoStack.directed` — this - * only knows how to WRITE each op, and it writes through the same doors the user does - * (`patchOverlay`, the rows endpoint), so an undone edit is persisted exactly like the edit - * was. Nothing here is optimistic-only: a Ctrl+Z that reverted the screen and not the store - * would come back on the next reload. - */ - const applyEntry = useCallback( - async (entry: UndoEntry, dir: "back" | "forward") => { - const op = directed(entry, dir); - if (op.kind === "cells") { - const byPid = new Map>(); - for (const c of op.changes) { - const at = byPid.get(c.pid) ?? {}; - at[c.key] = (c.after ?? "") as Row[string]; - byPid.set(c.pid, at); - } - applyingUndo.current = true; - try { - for (const [pid, updates] of byPid) patchOverlay(pid, updates); - } finally { - applyingUndo.current = false; - } - } else if (op.kind === "rowAdd") { - // Restore under the OLD id where the store still has it free; the server answers with - // what it actually wrote and the re-read is what puts the rows back on screen. - // - // ⚠ SEQUENTIAL, not `Promise.all`. `add_row` picks `max(id)+1` when the requested id is - // taken, and it reads the store to do it — firing ten restores concurrently is ten - // readers racing one counter, which is how two rows end up sharing an id. - let any = false; - let moved = 0; - for (const r of op.rows) { - const made = await addTableRow(op.table, r.values as Record, r.rid); - any = any || !!made; // a refusal already said why, in the server's words - // ⚠ THE ID IS PART OF WHAT IS BEING UNDONE, and `add_row` falls back to `max(id)+1` - // when the one it was asked for is no longer free — which happens if anything created - // a row in the gap. The restore is still the right thing to do, but it is no longer - // the SAME record to anything that named the old id (a cohort, a comment, a filter), - // and a redo would then look for an id that is not there. Said out loud rather than - // discovered later. - if (made && String(made.rid) !== String(r.rid)) moved += 1; - } - if (!any) return; - if (moved) - signal( - TOAST_EVENT, - `${moved} restored record${moved === 1 ? "" : "s"} came back under a new id. ` + - `Something had taken the original while it was gone.` - ); - signal(ROWS_STALE_EVENT); - } else if (op.kind === "rowDelete") { - let any = false; - for (const r of op.rows) any = (await deleteTableRow(op.table, r.rid)) || any; - if (!any) return; - // ⭐ ITEM 2 — the row may still be one of THIS browser's pending copies (add, then - // Ctrl+Z). A deleted row never reappears in a payload, so the prune effect can never - // absorb it and it would sit on screen looking undeleted. See `withdrawPending`. - withdrawPending(op.rows.map((r) => r.rid)); - signal(ROWS_STALE_EVENT); - } else if (op.kind === "choiceRename") { - // Item 15 — the inverse mapping. The host rewrites the values and the saved views that - // name them, exactly as it did on the way out; `directed` already turned the pairs - // around, so this emits what it is given. - emitHostEvent({ - id: eventId("choicerename"), - type: "choice_rename", - key: op.fieldKey, - renames: op.renames, - }); - } - signal(TOAST_EVENT, describe(entry, dir)); - }, - [patchOverlay, withdrawPending] - ); - - /** - * Ctrl+Z / Ctrl+Shift+Z (and Ctrl+Y, which is the same request on Windows). - * - * ⚠ ON `window`, IN CAPTURE, and it steps aside for real text fields. The grid is a canvas — - * glide's key handling only fires while the canvas has focus, so a Ctrl+Z after clicking the - * toolbar would do nothing, which is exactly the "sometimes it works" the owner would report - * next. But an with a cursor in it has its OWN undo that belongs to the browser, and - * stealing that would be worse than not having ours: the search box, the rename field and - * glide's own cell editor are all inputs, so `activeElement` decides. - * - * Nothing to undo says so out loud rather than silently ignoring the key — an undo that - * appears to do nothing is indistinguishable from one that is broken. - */ - useEffect(() => { - const onKey = (event: KeyboardEvent) => { - if (!(event.ctrlKey || event.metaKey) || event.altKey) return; - const key = event.key.toLowerCase(); - if (key !== "z" && key !== "y") return; - const el = document.activeElement as HTMLElement | null; - if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) - return; - const forward = key === "y" || event.shiftKey; - event.preventDefault(); - const book = undoBook.current; - const state = stackFor(book, scope); - const { state: next, entry } = forward ? popRedo(state) : popUndo(state); - if (!entry) { - signal(TOAST_EVENT, forward ? "Nothing to redo." : "Nothing to undo."); - return; - } - book[scope] = next; - void applyEntry(entry, forward ? "forward" : "back"); - }; - window.addEventListener("keydown", onKey, true); - return () => window.removeEventListener("keydown", onKey, true); - }, [scope, applyEntry]); - - - /** - * R4 — **BULK BACKSPACE/DELETE IS ONE ACTION.** glide's own delete walks the selection and - * calls `onCellEdited` per cell, which would put forty entries on the stack for one keypress; - * returning `false` takes the whole operation over so it lands as one. - * - * ⚠ PRESETS ARE NEVER ATTEMPTED (the contract says so, and it is also the only honest - * behaviour): `canEditField` is the same verdict the editor and the paste path use, so a - * selection spanning read-only columns clears the editable ones and leaves the rest exactly - * as they were — rather than firing writes the server will refuse one by one. - */ - /** - * ⭐ 2026-08-06 (owner) — DELETE RECORDS, with Ctrl+Z. - * - * Owner, verbatim: *"I should always be able to delete records that I created myself manually - * (not from automation), because right now it only shows 'Add to cohort'… make sure I can use - * 'Delete/Backspace' to delete the record and use Ctrl Z if i want to undo."* - * - * Everything under this was already built and had no door: `DELETE /tables/{key}/rows/{rid}`, - * `deleteTableRow`, and `undoStack`'s `rowDelete` — whose inverse (`rowAdd` under the SAME rid) - * is exactly "put it back". The only missing piece was something that calls them. - * - * ⛔ THE VALUES ARE CAPTURED BEFORE THE DELETE, and this is the line the whole feature rests on. - * `deleteTableRow` answers a boolean; it does not hand the row back. Push the undo entry after - * the round trip and there is nothing left to read, so Ctrl+Z would faithfully restore an EMPTY - * row under the right id — a silent data loss wearing the costume of a working undo. - * - * ⚠ ONE ENTRY FOR THE WHOLE GESTURE (R4's "bulk Backspace = ONE grouped stack entry"), which is - * why `UndoEntry` carries a row LIST now. Ten rows deleted must be one Ctrl+Z, not ten. - * - * ⚠ NO CONFIRM DIALOG, deliberately. The owner named Ctrl+Z as the safety net in the same - * sentence as the delete; a modal on top of a working undo is friction that teaches people to - * dismiss modals. - */ - const deleteRecords = useCallback( - async (pids: number[]): Promise => { - if (!canMutateRecords || !pids.length) return false; - // The row as it stands NOW, straight off the rendered records — the same values the - // reader can see, so a restore puts back what they watched disappear. - const byPid = new Map(); - for (const vr of displayRows) - if (vr.kind === "data") byPid.set(vr.record.pid, vr.record); - const captured: UndoRow[] = []; - /* - * ⛔ A SKIPPED ROW IS COUNTED AND NAMED, never dropped quietly ([[no-unverifiable-aggregates]]). - * `displayRows` is FILTERED and CAPPED, and the selection is not: Select-from-file matches - * on a value and can tick pids the current view does not render — `fileSelect` has a whole - * bucket for exactly that ("not in this view"). Capturing only what is on screen and - * `continue`ing past the rest would delete two rows and report three, which is the silent-cap - * defect in the one place it is least forgivable. - * - * REFUSED WHOLE rather than partially applied: a delete that half-happened leaves the reader - * reconciling a count against a table, and the fix (scroll or clear the filter, then select - * again) is one sentence away. - */ - const offscreen: number[] = []; - for (const pid of pids) { - const rec = byPid.get(pid); - if (!rec) { - offscreen.push(pid); - continue; - } - const values: Record = {}; - for (const f of fields) { - const v = rec[f.key]; - if (v !== undefined && v !== null && v !== "") - values[f.key] = v as UndoValue; - } - captured.push({ rid: pid, values }); - } - if (offscreen.length) { - signal( - TOAST_EVENT, - `Nothing was deleted. ${offscreen.length} of the ${pids.length} selected ` + - `record${pids.length === 1 ? " is" : "s are"} not shown in this view. Clear the ` + - `filter (or scroll them into view) and select again.` - ); - return false; - } - if (!captured.length) return false; - - /* - * ⭐ WARN THEN ALLOW (owner ruling, 2026-08-06) — the arm fires ONLY where something is - * actually at stake, so deleting your own scratch rows stays one keypress. - * - * ⛔ THE PREDICATE IS "WHAT WOULD BE LOST", NOT "WHO CREATED THIS". There is no stored - * creator on a row, and deriving one from a stage column would flip meaning as the - * automation runs. But the question the warning answers is a different and answerable one: - * *has an automation written to this row* — because that is exactly what a re-find cannot - * give back. MEASURED in `run_discover_instagram`: the seen-before test reads the rows - * CURRENTLY IN THE TABLE, so a deleted candidate returns as NEW — `found_count` back to 1, - * `first_found` re-stamped, and its stage reset to Review. Deleting a candidate somebody - * had already judged throws that judgement away. - * - * ⚠ This paragraph used to name a "Declined" stage. WAVE 26 / R6 DELETED THE BOARD'S - * BUILT-IN TERMINALS — the lanes are whatever the user defined now — so the warning names - * the mechanism (the stage resets to Review) and no longer a stage that does not ship. - */ - /** - * ⭐⭐ WAVE-29 T23 (owner item 2b) — **THE QUESTION IS AUTHORSHIP, AND IT HAS AN ANSWER.** - * - * The note above this used to argue there is no stored creator on a row, so the warning - * asked "has a machine written here" instead. There IS one: `automation_engine` stamps - * `created_by` ("Found by") on every candidate row it discovers, and `add_row` stamps - * nothing — so the two authors are distinguishable, and `machineFoundRows` (types.ts) is - * that distinction. Everything the old predicate got wrong followed from asking the - * answerable-but-different question: every preset column is machine-tagged, a hand-added - * row is born holding materialised rollups, and the STAGE cell a human drives was - * force-included — so pressing "+" and then Delete produced a loud warning about losing - * an automation's work on a row the person had just made themselves. - * - * ⚠ The wave-27 note that lived here (`stageField` survives the stage deletion, so a - * legacy stage column stays read-only) is about the READ-ONLY readers — `isMachineWritten` - * / `mayEditField` in types.ts, which are untouched. It never described this predicate; the - * risk test is the one place where including the stage column is the defect rather than the - * protection. - * - * ⛔ AND THE SENTENCE IS BUILT FROM THE COLUMNS THIS TABLE HAS. It used to name first-found, - * times-found and a stage reset unconditionally while all three live in `CANDIDATE_FIELDS` - * — so on a user-named IG Profile database it named the loss of three columns that are not - * there. `reFindConsequences` reads the field list. - */ - const riskyIds = new Set(machineFoundRows(fields, captured)); - const risky = captured.filter((r) => riskyIds.has(r.rid)); - const sig = captured.map((r) => r.rid).join(","); - if (risky.length && delArmed !== sig) { - setDelArmed(sig); - const losses = reFindConsequences(fields); - const one = risky.length === 1; - signal( - TOAST_EVENT, - `${risky.length} of these ${one ? "records was" : "records were"} FOUND by an ` + - `automation. Deleting ${one ? "it" : "them"} is not undone by a re-find` + - (losses.length - ? `: ${losses.join(", ")}, so a card somebody had already moved on comes back ` + - `undecided` - : `, so ${one ? "it" : "they"} would come back as a new record with no history`) + - `. Press Delete again (or click again) to confirm.` - ); - return false; - } - setDelArmed(""); - // SEQUENTIAL, matching the restore path: the store is one document and ten concurrent - // read-modify-writes against it is how a delete silently misses a row. - const gone: UndoRow[] = []; - for (const r of captured) - if (await deleteTableRow(scope, r.rid)) gone.push(r); - if (!gone.length) { - signal(TOAST_EVENT, "Nothing was deleted: the server refused."); - return false; - } - undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), { - kind: "rowDelete", table: scope, rows: gone, - }); - // ⭐ ITEM 2 — same reason as the undo path: a row added and then deleted in one session - // is still a PENDING copy here, and no future payload will ever prune it. - withdrawPending(gone.map((r) => r.rid)); - clearSelection(); - signal(ROWS_STALE_EVENT); - // ⚠ THE CONSEQUENCE IS NAMED IN FULL, and the first version of this sentence undersold it. - // It said only that a matching record "will return", which is true and reassuring and - // leaves out the part that actually costs something. - // - // MEASURED in `run_discover_instagram`: the seen-before test is built from the rows - // CURRENTLY IN THE TABLE (`seen` comes from `existing2`). A deleted profile is therefore - // not "seen again" on the next run — it is NEW. `found_count` resets to 1, `first_found` - // is re-stamped, and the stage goes back to Review, because a new candidate's card starts - // at the human gate. So deleting a candidate somebody had already moved out of Review puts - // it back there undecided — the human judgement is the thing the re-find cannot give back. - // (W26/R6 deleted the board's built-in terminals, so there is no named stage to cite here.) - // - // (The follower SNAPSHOT history is safe either way — it lives in the platform master keyed - // by handle, a different store, which only `purge_handle` touches.) - // Same survival, same reason as `machineKeys` above (item 12 / R3, B's ASK ->C): the flag - // is no longer WRITTEN, and a table that has not been migrated yet still carries it. - const fedByAutomation = fields.some((f) => f.automation?.stageField); - signal( - TOAST_EVENT, - `Deleted ${gone.length} record${gone.length === 1 ? "" : "s"}. Ctrl+Z to undo.` + - (fedByAutomation - ? " If this automation finds one again it comes back as a NEW candidate: first-found" + - " and times-found reset, and its stage back to Review. Undo keeps all of that." - : "") - ); - return true; - }, - [canMutateRecords, displayRows, fields, scope, clearSelection, delArmed, - withdrawPending] - ); - - const onGridDelete = useCallback( - (sel: GridSelection): boolean => { - /* - * ⭐ 2026-08-06 (owner) — A ROW SELECTION + Delete/Backspace DELETES THE RECORDS. - * - * Checking rows and pressing Delete used to CLEAR every editable cell in them, which on the - * owner's Instagram table now clears nothing at all: every column but one is machine-written - * and refuses the write. So the key appeared to do nothing, which is what they reported. - * - * ⚠ ROWS ONLY, AND ONLY WHEN NO **MULTI-CELL** RANGE IS DRAWN. Checking rows is an explicit - * gesture about RECORDS; DRAGGING a range is a gesture about CELLS, and a Delete that - * destroyed records because some rows happened to be checked would feel unrecoverable even - * with an undo behind it. When both are present, the narrower reading (clear the cells) wins. - * - * ⛔ A SINGLE ACTIVE CELL IS NOT A RANGE, and the first version of this got it wrong. - * `sel.current.range` is ALWAYS set when a cell is merely focused — a 1×1 rect — so - * `!sel.current?.range` meant the feature worked only if you had never clicked a cell. - * Caught by driving it live rather than by the gate: click a cell, then check a row, then - * press Delete, and the key silently went back to clearing cells. The gesture the rule is - * actually about is a DRAG, which is width or height greater than one. - */ - const dragged = !!sel.current - && (sel.current.range.width > 1 || sel.current.range.height > 1); - if (canMutateRecords && sel.rows.length > 0 && !dragged) { - const pids: number[] = []; - for (const rowIndex of sel.rows) { - const vr = displayRows[rowIndex]; - if (vr && vr.kind === "data") pids.push(vr.record.pid); - } - // ⛔ `true`, NOT `false`, WHEN THIS BRANCH DOES NOT DO THE WORK — the scar the comment at - // the bottom of this handler already records, pointed at a second case. `false` cancels - // glide's own delete, so bailing with it would leave Delete doing nothing at all on a - // selection this branch declined. `deleteRecords` is async and the keypress cannot wait - // for it, so the branch commits here and reports the outcome through its own toast. - if (!pids.length) return true; - void deleteRecords(pids); - return false; - } - const byPid = new Map>(); - const clear = (rowIndex: number, colIndex: number) => { - const vr = displayRows[rowIndex]; - if (!vr || vr.kind !== "data") return; - const column = visibleCols[colIndex]; - const field = column ? fieldByKey.get(column.id!) : undefined; - if (!field || !canEditField(field)) return; - const at = byPid.get(vr.record.pid) ?? {}; - at[field.key] = ""; - byPid.set(vr.record.pid, at); - }; - for (const rowIndex of sel.rows) - for (let c = 0; c < visibleCols.length; c++) clear(rowIndex, c); - const range = sel.current?.range; - if (range) - for (let y = range.y; y < range.y + range.height; y++) - for (let x = range.x; x < range.x + range.width; x++) clear(y, x); - const writes = [...byPid.entries()].map(([pid, updates]) => ({ pid, updates })); - if (!writes.length) return true; - patchManyAndRecord(writes, "clearing cells"); - // ⚠ `false` ONLY WHEN THIS ACTUALLY DID THE WORK. Returning it unconditionally cancels - // glide's own delete for cases this handler does not cover — a COLUMN selection, which - // glide deletes from `toDelete.columns` and the loops above never look at — so Delete - // would silently clear nothing. Handing the keypress back when there is nothing to - // group is strictly safer than swallowing it: the per-cell path still refuses read-only - // fields (`onCellEdited`'s own `canEditField`), it just does not arrive as one entry. - return false; - }, - [canMutateRecords, deleteRecords, displayRows, visibleCols, fieldByKey, canEditField, - patchManyAndRecord] - ); - - /** - * Owner item 4 / C-ADDROW — the cursor FOLLOWS the appended row, once it exists. - * - * The append is a server round trip, so at click time there is nothing to focus; this waits - * for the re-read to bring the pid back and then lands the active cell on its first column, - * scrolled into view. `newRowPid` is cleared either way — a row the re-read never produced - * (a refused write, a filter that excludes it) must not leave a cursor waiting forever. - */ - useEffect(() => { - if (newRowPid === null) return; - const index = displayPidToIndex.get(newRowPid); - if (index === undefined) return; - setActiveCell(0, index); - gridRef.current?.scrollTo(0, index, "vertical", 0, 0, { vAlign: "center" }); - setNewRowPid(null); - }, [newRowPid, displayPidToIndex, setActiveCell]); - - /** Owner item 23 — the fields in the view's column order, for the Hide-fields panel. Built - * from `order` (already reconciled by useGridColumns) rather than from `config.order` so the - * panel and the grid can never disagree about which fields exist or where they sit. */ - const orderedFields = useMemo( - () => order.map((key) => fieldByKey.get(key)).filter((f): f is Field => !!f), - [order, fieldByKey] - ); - /** Owner item 17 — the row of the ACTIVE cell, i.e. what the last click highlighted. Read off - * `gridSelection` rather than tracked separately so keyboard navigation moves the wash too; - * `useGridSelection` already guards this index against a grid that shrank this render. */ - const activeRow = gridSelection.current?.cell[1]; - - const statusValues = useMemo(() => { - const map: Record = {}; - for (const field of fields) { - // A `user`'s people come from the HOST, never from the rows — an assignee nobody has been - // given yet is still assignable. - if (field.type === "user") { - map[field.key] = payload?.userOptions ?? []; - continue; - } - // ⭐ Owner item 24 — every other choice column goes through ONE rule (`choiceVocabulary`): - // the DECLARED list when the field has one, the values seen in the data when it does not. - // This used to branch on the type, which sent `stock_bucket` — a `select` whose vocabulary - // is computed server-side and declares no `options` — down the declared path to an empty - // list, and an empty supplied list WINS in the filter panel. See the function's own note; - // it is pure so the gate can run it, which nothing inside this file can be. - if (field.type === "select" || field.type === "multiselect" || field.type === "status") - map[field.key] = choiceVocabulary(field, rawRows); - } - return map; - }, [fields, rawRows, payload?.userOptions]); - - const rowPx = ROW_PX[config.rowHeightMode]; - // DISTINCT customers, not painted data rows. Grouping a MULTI field (Cohorts) puts the same - // customer under every group it belongs to, so counting rows would report more records than - // there are customers — a number nobody could reconcile against the book. `pidToIndex` is - // first-wins per pid, so its size IS the distinct count, and it equals the row count for every - // non-multi grouping and for no grouping at all. - // - // ⚠ The FULL pipeline's map, never the display slice's: the toolbar count must state what the - // view MATCHES. What is painted is the "Showing first N" bar's job (owner item 8). - const recordCount = pidToIndex.size; - const shownRecords = displayPidToIndex.size; - /* wave17 GRID — item 2 / owner R5. The whole-table row band is GONE, and with it the - `bandTone` / `bandMask` pair that used to sit here: the winning control's tone, and the - alternating-row mask built from the DATA-ROW ORDINAL so group headers could not flip the - stripe at a boundary. What follows is now three row states, not four. - ⛔ wave-29 R8 (2026-08-11) — R5 also kept an involved-COLUMN cell wash; that is now gone too, - so `COLUMN_TONE_THEME` carries HEADER keys only. This callback and `cells.AUTOMATION_TINT` - are therefore the ONLY writers of a body `bgCell` on this canvas: a sorted or filtered column - can no longer layer anything over a status wash, which is exactly what R8 asked for. */ - const getRowThemeOverride = useCallback( - (row: number): Partial | undefined => { - const visibleRow = displayRows[row]; - if (!visibleRow || visibleRow.kind !== "data") return undefined; - // Owner item 17 — the row holding the ACTIVE cell stays washed after the pointer moves - // on. Ordered AFTER hover on purpose: the pointer is the more immediate signal, and a - // hovered-and-active row reading as merely active would make hover look broken. - if (row === hoverRow) { - if (!config.colorBy) return HOVER_NEUTRAL; - return ( - HOVER_ROW_THEME[String(visibleRow.record[config.colorBy] ?? "").toLowerCase()] ?? - HOVER_NEUTRAL - ); - } - // A colour-by wash already marks this row with meaning the user chose; overlaying the - // active tint on top would blend two hues into a third that means neither. There, glide's - // accent ring is the active marker and this stays out of the way. - if (!config.colorBy) { - if (row === activeRow) return ACTIVE_ROW_NEUTRAL; - return undefined; - } - return STATUS_ROW_THEME[ - String(visibleRow.record[config.colorBy] ?? "").toLowerCase() - ]; - }, - [displayRows, config.colorBy, hoverRow, activeRow] - ); - const onItemHovered = useCallback( - (args: GridMouseEventArgs) => { - const row = args.kind === "cell" ? args.location[1] : undefined; - setHoverRow((previous) => (previous === row ? previous : row)); - // Owner item 19 — the hover-only Expand. Placed from glide's OWN bounds for the primary - // cell of this row, so freeze, horizontal scroll and row-height mode are handled by the - // component that owns them rather than re-derived here. - // - // ⚠ Only ever CLEARED from here for a different row, never for "the pointer left the - // canvas". Moving the pointer ONTO the button leaves the canvas, so clearing on - // out-of-bounds would unmount the control between the mouse arriving and the click - // landing — an affordance that vanishes exactly when you reach for it, and one that - // every assertion still sees because it exists in every state except the one that - // matters. `.cg-grid-box`'s own onMouseLeave is what dismisses it. - const vrow = row !== undefined ? displayRows[row] : undefined; - if (args.kind === "cell" && vrow?.kind === "data" && row !== undefined) { - const pid = vrow.record.pid; - const b = gridRef.current?.getBounds(0, row); - // Clamped to the grid's own box. ⚠ RETARGETED wave-14 item 15: this used to say "with a - // grouping active `freezeColumns` is 0, so the primary column scrolls away". It no - // longer does — the first column is now frozen in EVERY mode. The clamp stays because - // its other half is still live: glide's bounds are VIEWPORT coordinates, so a row - // scrolled under the header or past the bottom still reports a rect outside the grid, - // and a `position: fixed` button with no clamp would paint over the chrome on a row - // nobody can see. The horizontal legs are now defence in depth (a column drag, a box - // narrower than the frozen strip) rather than the everyday case. - const boxRect = gridBoxRef.current?.getBoundingClientRect(); - const at = b ? expandButtonRect(b, boxRect) : null; - // W35-T29 — the star book-ends the same cell the Expand does, from the SAME bounds. It - // has its own minima (smaller mark, left end), so a compact row that cannot carry the - // Expand can still carry this one. - const starAt = b ? starButtonRect(b, boxRect) : null; - setStarHover((prev) => - !starAt - ? null - : prev && prev.pid === pid && prev.x === starAt.x && prev.y === starAt.y - ? prev - : { pid, x: starAt.x, y: starAt.y, size: starAt.size } - ); - // Recomputed EVERY move, not memoised on the pid: a scroll can leave the pointer over - // the same record at a new y, and a button that keeps the pid but not the position - // floats over the wrong row. Referential stability is preserved by comparing values, - // which is the cheap half — `getBounds` is arithmetic over glide's own layout. - setExpandAt((prev) => - !at - ? null - : prev && prev.pid === pid && prev.x === at.x && prev.y === at.y - ? prev - : { pid, x: at.x, y: at.y, size: at.size } - ); - } - // Wave-5 item 6 — hovering a header whose field carries a description floats the text - // under the header. The tip element is pointer-events:none and aria-hidden: it can - // NEVER become the click target, which is the tooltip trap this page has already paid - // for once ([[ui-invisible-to-assertions]]). - if (args.kind === "header") { - const colDef = visibleCols[args.location[0]]; - const field = colDef ? fieldByKey.get(colDef.id!) : undefined; - const note = field ? field.note || field.description : undefined; - // Owner item 16 — a title the fit SHORTENED must still be readable somewhere, and the - // tip is where. `colDef.title !== field.label` IS the truncation test: `fitHeaderTitle` - // returns the label by identity when it fits, so this asks the renderer what it did - // rather than re-running the measurement and hoping the two agree. - const cut = !!field && !!colDef && colDef.title !== field.label; - const text = cut && field ? (note ? `${field.label}: ${note}` : field.label) : note; - const bounds = args.bounds; - if (text && bounds) { - setHeaderTip((prev) => - prev && prev.text === text && prev.x === bounds.x - ? prev - : { text, x: bounds.x, y: bounds.y + bounds.height + 4 } - ); - return; - } - } - setHeaderTip((prev) => (prev === null ? prev : null)); - - // I2 — the cell half. Only text that is genuinely CUT OFF gets a tip: a tooltip on - // every cell just repeats what is already legible and covers the row under it. - // Truncation is MEASURED with glide's own font rather than guessed from a character - // count, which is what makes "e.g. a Note" work and a short currency cell stay quiet. - if (args.kind === "cell" && row !== undefined) { - const vr = displayRows[row]; - const cellCol = visibleCols[args.location[0]]; - // Group headers and footers carry no cell text of their own — a tip over the group - // bar would repeat the label glide has already drawn across it. - if (vr?.kind === "data" && cellCol) { - const field = fieldByKey.get(cellCol.id!); - const text = field ? formatDisplay(field, vr.record[field.key]) : ""; - const bounds = args.bounds; - const tip = bounds ? cellTipText(text, measureCellText(text), bounds.width) : null; - if (tip && bounds) { - const x = tipLeft(bounds.x, window.innerWidth, measureCellText(tip) + 20); - const y = bounds.y + bounds.height + 4; - setCellTip((prev) => - prev && prev.text === tip && prev.x === x && prev.y === y - ? prev - : { text: tip, x, y } - ); - return; - } - } - } - setCellTip((prev) => (prev === null ? prev : null)); - }, - [visibleCols, fieldByKey, displayRows] - ); - const rowHeight = useMemo( - () => - config.groupBy - ? (row: number) => (displayRows[row]?.kind === "group-header" ? 32 : rowPx) - : rowPx, - [config.groupBy, rowPx, displayRows] - ); - - /* ═══ W18-B VOID ═══ (wave 18, owner item 1b) — WHERE THE TABLE ENDS, in canvas pixels. - Consumed by the two `.cg-grid-void` rectangles at the DataEditor mount; see the CSS region - of the same name for why the void is painted in the DOM rather than in the glide theme. - - ⭐ THE PROPERTY THAT MAKES THIS ARITHMETIC AND NOT SCROLL-TRACKING, and the reason there is - no `onVisibleRegionChanged` handler anywhere near it: **a void can only exist in an axis the - content does not overflow.** glide's scroll extent is EXACTLY the content — `scrollWidth = - nonGrowWidth (+ overscrollX)`, `scrollHeight = header + rows (+ overscrollY)`, and we pass - neither overscroll prop (scrolling-data-grid.js:12-24) — so at maximum scroll the last - row/column lands flush on the client edge. Wherever there is something to paint, the scroll - offset in that axis is 0 and cannot become anything else. The rectangles therefore depend on - the columns, the rows and the box, and on nothing that moves while the user drags. - - Every column carries an explicit `width` (`useGridColumns` sets `config.widths[key] ?? - DEFAULT_WIDTH`) and none carries `grow`, so glide's column sizer passes them through - untouched and this sum is the width it actually lays out. */ - const gridVoid = useMemo(() => { - const gutter = scrollbarGutter(); - let colsPx = rowMarkerPx(displayRows.length); - for (const c of visibleCols) colsPx += (c as { width?: number }).width ?? 0; - let rowsPx = HEADER_PX; - if (typeof rowHeight === "number") rowsPx += displayRows.length * rowHeight; - else for (let i = 0; i < displayRows.length; i++) rowsPx += rowHeight(i); - // ⚠ Owner item 4 — THE GHOST ROW IS PART OF THE TABLE, and this line is what makes it - // visible. glide draws its trailing row AFTER the last data row, but `displayRows` (our - // rows) does not contain it, so the void started exactly where the ghost row does and - // painted flat #F6F8FC straight over it. The "+" was still clickable the whole time - // (`.cg-grid-void` is `pointer-events: none`), which is the worst version of this bug: - // the gate went green on an affordance nobody could see. Found by READING THE SCREENSHOT - // ([[ui-invisible-to-assertions]], [[finalize-visual-review-sop]]). - if (canMutateRecords) - rowsPx += typeof rowHeight === "number" ? rowHeight : rowHeight(displayRows.length); - - /* Fit is tested against the client box the OTHER axis's scrollbar leaves behind — the same - `clientWidth`/`clientHeight` glide's own scroll handler reads (infinite-scroller.js: - 110-116), so this branch and glide's cannot disagree about whether a bar is there. The - two `if`s resolve the circularity in the only direction it can run: a bar in one axis can - CREATE one in the other, but two bars can never un-create each other. */ - let vBar = rowsPx > gridSize.height; - let hBar = colsPx > gridSize.width; - if (vBar && !hBar) hBar = colsPx > gridSize.width - gutter; - if (hBar && !vBar) vBar = rowsPx > gridSize.height - gutter; - const clientW = gridSize.width - (vBar ? gutter : 0); - const clientH = gridSize.height - (hBar ? gutter : 0); - - /* `null` = the content reaches that edge, so there is no void and no rectangle. The - comparison is strict: a table ending exactly on the edge has nothing past it. */ - return { - clientW, - clientH, - below: rowsPx < clientH ? rowsPx : null, - right: colsPx < clientW ? colsPx : null, - }; - }, [visibleCols, displayRows, rowHeight, gridSize, canMutateRecords]); - /* ═══ end W18-B VOID (geometry) ═══ */ - - // The record drawer resolves positions against what the MODE paints: the display slice for - // grid/list (the cap is real there), the FULL pipeline for calendar/kanban/map (a month, a - // stack or a pin reaches past the cap by design — a drawer that refused those pids would - // close itself on a card the user can plainly see). - // ⭐ WAVE-27 item 8 (C3) — `swipe` belongs here for calendar/kanban/map's own reason: the deck - // is derived from `modeDataRows` (the FULL pipeline), so a card past the display cap is one - // the user can plainly see, and a drawer that refused its pid would close itself on open. - const fullSetMode = - displayMode === "calendar" || displayMode === "kanban" || displayMode === "map" || - displayMode === "swipe"; - const detailRows = fullSetMode ? visibleRows : displayRows; - const detailIdxMap = fullSetMode ? pidToIndex : displayPidToIndex; - const detailIndex = detailPid !== null ? detailIdxMap.get(detailPid) : undefined; - useEffect(() => { - if (detailPid !== null && detailIndex === undefined) setDetailPid(null); - }, [detailPid, detailIndex]); - const detailRecord = useMemo(() => { - if (detailIndex === undefined) return null; - const row = detailRows[detailIndex]; - if (!row || row.kind !== "data") return null; - return overlayEdits[row.record.pid] - ? { ...row.record, ...overlayEdits[row.record.pid] } - : row.record; - }, [detailIndex, detailRows, overlayEdits]); - const dataPosition = useMemo(() => { - if (detailIndex === undefined) return 0; - return detailRows - .slice(0, detailIndex + 1) - .filter((row) => row.kind === "data").length; - }, [detailIndex, detailRows]); - const neighborExists = useCallback( - (delta: -1 | 1) => { - if (detailIndex === undefined) return false; - for ( - let index = detailIndex + delta; - index >= 0 && index < detailRows.length; - index += delta - ) - if (detailRows[index]?.kind === "data") return true; - return false; - }, - [detailIndex, detailRows] - ); - const go = useCallback( - (delta: -1 | 1) => { - if (detailIndex === undefined) return; - for ( - let index = detailIndex + delta; - index >= 0 && index < detailRows.length; - index += delta - ) { - const row = detailRows[index]; - if (row?.kind !== "data") continue; - setDetailPid(row.record.pid); - if (displayMode === "grid") - gridRef.current?.scrollTo(0, index, "vertical", 0, 0, { vAlign: "center" }); - return; - } - }, - [detailIndex, detailRows, displayMode] - ); - - /** - * Wave-9 I3 — the description "(i)", drawn RIGHT-ALIGNED and vertically centred with the - * field name. - * - * ⚠ This is the header-draw alternative, taken because glide's `overlayIcon` PROVABLY - * cannot do it: `drawHeaderInner` paints an overlay at a hard-coded `drawX + 9` / - * `(height - 18) / 2 + 6` — a badge on the bottom-right corner of the TYPE mark at the far - * LEFT of the header — and no prop moves it. `drawHeader` is glide's supported escape - * hatch: it hands over the ctx, the rect, the menu bounds and the sprite manager, plus a - * `drawContent()` that runs its own rendering first. So glide still draws the header it - * always drew (type mark, title, menu); only the (i) is ours, and `overlayIcon` is no - * longer set on any column. - * - * The geometry is `infoMarkRect()` in overlayPlacement.ts, so "right-aligned and centred" - * is asserted numerically by a gate rather than judged from one screenshot at one width. - */ - const drawGridHeader = useCallback( - (args, drawContent) => { - const field = args.column.id ? fieldByKey.get(args.column.id) : undefined; - // ⚠⚠ WAVE-14 ITEM 2 — THE ONE LINE THAT MAKES AN INVOLVED HEADER BOLD, and it is not a - // font setting anywhere near where you would look for one. - // - // glide's `drawGridHeaders` does, verbatim: - // if (theme !== outerTheme) ctx.font = theme.baseFontFull; // :38-40 - // i.e. the moment a column carries ANY `themeOverride` its header title is painted in the - // CELL font ("13px") instead of the header font ("600 13px"). That is why the owner's - // filtered/sorted/grouped headers had already lost their weight — nothing in our code - // asked for it, and setting `headerFontStyle` on the involved theme alone would have - // changed precisely nothing, because glide never reads it on that path. Re-asserting the - // MERGED theme's header font on the ctx here is what puts it back, for every column, - // uniformly: on a column with no override this is a no-op (glide set the same value at - // :21), and glide's own save/restore around each column stops it leaking to the next. - // - // Built from `headerFontStyle` + `fontFamily` rather than reading `headerFontFull`, - // which is real at runtime but is declared on `FullTheme`, not the public `Theme` the - // callback is typed with — same string, no cast, and it is exactly how - // `mergeAndRealizeTheme` composes it. - // - // Bonus fix, worth knowing: `drawHeaderInner` passes `theme.headerFontFull` to - // `getMiddleCenterBias`, which measures with the CURRENT ctx font but CACHES under the - // string it was handed. Before this line, an overridden column measured its baseline bias - // in the 13px font and filed it under the "600 13px" key, so whichever column drew first - // decided the vertical centring for all of them. - args.ctx.font = `${args.theme.headerFontStyle} ${args.theme.fontFamily}`; - drawContent(); - if (!field) return; - const hasInfo = !!(field.note || field.description); - // ⭐ OWNER ITEM 4 (2026-08-06): *"why is the Field still have the Dot at the header to - // mark its a custom editable field?"* Because the predicate was `source === "overlay"` - // alone — true of columns a person added BESIDE the Odoo ones, and true of literally - // every column in a `ut_*` database, including the ones an automation spawns and fills. - // So on the owner's Instagram table the dot marked all 23 columns as "yours", which is - // both meaningless (it never varies) and wrong (they are not yours to edit). - const isCustom = field.source === "overlay" && !isMachineOwned(field); - // ONE layout for both marks, from the same `headerMarkSizes` order `useGridColumns` used - // to reserve the label's room — so the number of marks drawn and the number reserved for - // cannot disagree. null = the column is too narrow to carry them without covering its own - // label, and then NOTHING is drawn (all-or-nothing; see headerMarkLayout). - const marks = headerMarkLayout( - args.rect, - args.menuBounds.width, - headerMarkSizes(hasInfo, isCustom) - ); - if (!marks) return; - let slot = 0; - if (hasInfo) { - const at = marks[slot++]; - args.spriteManager.drawSprite( - "aiosInfo", - "normal", - args.ctx, - at.x, - at.y, - at.size, - args.theme - ); - } - if (isCustom) { - // Wave-14 item 1 / R11 — the user-created-field marker that REPLACES the yellow header - // wash. A muted dot: quiet enough to ignore while reading, present enough to answer - // "which of these columns are mine?" at a glance. Never a background — the owner killed - // the wash, and a paler wash would have been the same answer in a lower voice. - const at = marks[slot++]; - const ctx = args.ctx; - ctx.save(); - ctx.beginPath(); - ctx.arc(at.x + at.size / 2, at.y + at.size / 2, at.size / 2, 0, Math.PI * 2); - ctx.fillStyle = CUSTOM_FIELD_MARK; - ctx.fill(); - ctx.restore(); - } - }, - [fieldByKey] - ); - - const onCellClicked = useCallback( - (cell: Item, event: CellClickedEventArgs) => { - const row = displayRows[cell[1]]; - if (!row) return; - // Owner item 10: clicking into the cells is "I am working now" — the frame folds its - // navigation rail to the slim strip (a no-op in the embed; the shell listens). - signal(NAV_MINIMIZE_EVENT); - if (row.kind === "group-header") { - event.preventDefault(); - setCollapsed((current) => { - const next = new Set(current); - if (next.has(row.groupKey)) next.delete(row.groupKey); - else next.add(row.groupKey); - return next; - }); - return; - } - const column = visibleCols[cell[0]]; - const field = column ? fieldByKey.get(column.id!) : undefined; - // A picked field (select / assignee) opens its choices where the cell is. It cannot use - // glide's text overlay — a free-text editor on a constrained column is how you end up - // with "Done", "done" and "DONE" as three different values — and glide's own dropdown - // cell lives in a package we have deliberately not added, so this reuses the same - // AnchoredOverlay the column and view menus already use. Wave-5: a RATING cell rides - // the same picker surface (its choices are 1..max stars); both doors respect the - // permissions verdict. - if (row.kind === "data" && field && canEditField(field) - && (isPickType(field.type) || field.type === "rating")) { - event.preventDefault(); - // Item 6 (2026-07-31) — the click also LANDS the active cell here (preventDefault - // stops glide from committing it), so pick → Enter walks on down the column exactly - // like a typed edit does. - setActiveCell(cell[0], cell[1]); - const b = event.bounds; - setPicker({ - pid: row.record.pid, - fieldKey: field.key, - anchor: { - left: b.x, top: b.y, right: b.x + b.width, bottom: b.y + b.height, - width: b.width, height: b.height, - }, - }); - return; - } - // A linked-record cell is a doorway to the target database, not an opaque id list. - // The large modal mounts the same Grid surface over exactly these pids, so its standard - // search, Filters, Sort, Fields, and column menus keep working for every database kind. - if (row.kind === "data" && field?.type === "link" && field.link?.table) { - if (field.link.table.startsWith("ut_")) { - event.preventDefault(); - setActiveCell(cell[0], cell[1]); - setLinkAt({ pid: row.record.pid, fieldKey: field.key }); - return; - } - } - // ⭐ Wave-23 C7 — a JSON cell opens the big viewer. It is the ONLY door: the cell carries - // `allowOverlay:false`, because glide's overlay is a one-line box and one keystroke in the - // wrong place inside a 32 KB document turns a well-formed payload into an unparseable one, - // saved. Opened for EVERY reader (a document you may not edit is still one you must be - // able to read) — the viewer takes `onSave` only when the permission verdict allows it, - // and the host's write wall is the real one either way. - if (row.kind === "data" && field?.type === "json") { - event.preventDefault(); - setActiveCell(cell[0], cell[1]); - setJsonAt({ pid: row.record.pid, fieldKey: field.key }); - return; - } - // Wave-5 item 11 — a URL cell opens its link on click (scheme-guarded: http/https only, - // a bare domain gets https://). Editing stays with glide's overlay (Enter/double-click). - if (row.kind === "data" && field?.type === "url") { - const raw = String( - overlayEdits[row.record.pid]?.[field.key] ?? row.record[field.key] ?? "" - ).trim(); - if (raw) { - const href = /^https?:\/\//i.test(raw) - ? raw - : /^[\w-]+(\.[\w-]+)+/.test(raw) - ? `https://${raw}` - : null; - if (href) { - event.preventDefault(); - window.open(href, "_blank", "noopener"); - return; - } - } - } - // Owner item 6 (2026-07-31) — CLICKING THE CUSTOMER TICKS THE CHECKBOX. The row marker - // is a ~32px strip; the identity cell is the widest, most natural target on the row, so - // a click there toggles the same pid-anchored set the markers write ("more surface area - // to select individual customers into a Cohort"). No preventDefault: glide still commits - // the cell highlight below, so reading across the row keeps working. - if (row.kind === "data" && field && field.key === lockedKey) { - togglePid(row.record.pid); - } - // Owner item 17 — A SINGLE CLICK HIGHLIGHTS. It used to open the record panel from here, - // which meant a user could not select a cell, read across a row, or copy a value without - // a drawer landing over the table. The highlight is glide's own doing: this handler - // returns without `preventDefault`, so the click commits `gridSelection.current`, the - // accent ring lands on the cell and `getRowThemeOverride` washes the row - // (ACTIVE_ROW_NEUTRAL). Nothing is drawn here. - // - // ⚠ SHIPPED WITH ITEM 19, never alone. Deleting this line is what removes the ONLY way to - // open a record; the hover Expand button below is its replacement, and half of this - // change is a table whose records cannot be opened at all. - }, - [displayRows, visibleCols, fieldByKey, canEditField, overlayEdits, lockedKey, togglePid, - setActiveCell] - ); - /** - * Double-click / Enter. KEPT as a door to the record on purpose (owner item 17 names the - * SINGLE click, and it is the single click that was in the way). - * - * Removing it too would leave the record reachable only by pointer — the hover affordance - * cannot be reached from the keyboard at all — so a keyboard user would lose the panel - * outright. glide routes Enter on an EDITABLE cell to its overlay editor before this fires, - * so the two doors do not collide: this is the activation path for the read-only columns, - * which is most of the table. - */ - const onCellActivated = useCallback( - (cell: Item) => { - const row = displayRows[cell[1]]; - const column = visibleCols[cell[0]]; - const field = column ? fieldByKey.get(column.id!) : undefined; - if (row?.kind === "data" && field?.source !== "overlay") - setDetailPid(row.record.pid); - }, - [displayRows, visibleCols, fieldByKey] - ); - const onCellEdited = useCallback( - (cell: Item, value: EditableGridCell) => { - const row = displayRows[cell[1]]; - const column = visibleCols[cell[0]]; - const field = column ? fieldByKey.get(column.id!) : undefined; - if (!row || row.kind !== "data" || !field || !canEditField(field)) return; - // Wave-5 item 11 — a checkbox toggles straight through glide's BooleanCell (no overlay - // editor); the overlay store keeps its '1'-or-empty contract. - if (value.kind === GridCellKind.Boolean && field.type === "checkbox") { - patchAndRecord(row.record.pid, { [field.key]: value.data ? "1" : "" }, "a tick"); - return; - } - if (value.kind === GridCellKind.Uri) { - patchAndRecord(row.record.pid, { [field.key]: value.data ?? "" }, "an edit"); - return; - } - if (value.kind === GridCellKind.Text || value.kind === GridCellKind.Number) { - patchAndRecord(row.record.pid, { [field.key]: value.data }, "an edit"); - } - }, - [displayRows, visibleCols, fieldByKey, patchAndRecord, canEditField] - ); - const validateCell = useCallback( - (cell: Item): boolean => { - const row = displayRows[cell[1]]; - const column = visibleCols[cell[0]]; - const field = column ? fieldByKey.get(column.id!) : undefined; - // ONE verdict (types.mayEditField): stratum + read-only-by-nature + permissions. The - // picked types and rating never take glide's text overlay — their pickers are the door. - return ( - !!row && - row.kind === "data" && - !!field && - canEditField(field) && - !isPickType(field.type) && - field.type !== "rating" && - field.type !== "status" - ); - }, - [displayRows, visibleCols, fieldByKey, canEditField] - ); - const onGridPaste = useCallback( - (target: Item, values: readonly (readonly string[])[]): boolean => { - const column = visibleCols[target[0]]; - const field = column ? fieldByKey.get(column.id!) : undefined; - if (!field) return false; - // ⭐ Wave-15 item 3 (R8) — THE SELECTION DECIDES HOW FAR THE PASTE REACHES. Until now the - // targets were walked down from the anchor for exactly `values.length` rows, so a - // fifty-row selection and a one-cell clipboard wrote ONE row: the selection was painted - // and obeyed by nothing. `pasteRowCount` is the whole rule and it is pure, so the gate - // can drive it — this handler cannot be reached by any node test in the repo. - const rowCount = pasteRowCount(values.length, gridSelection.current?.range, - { col: target[0], row: target[1] }); - const targetPids = Array.from({ length: rowCount }, (_, offset) => { - const row = displayRows[target[1] + offset]; - return row?.kind === "data" ? row.record.pid : null; - }); - const patches = planFieldPaste({ - field, - sourceFieldKey: copyProvenanceRef.current.fieldKey, - editable: canEditField(field), - values, - targetPids, - allowedChoices: statusValues[field.key] ?? choiceOptions(field), - }); - if (!patches) return false; - // R4 — ONE stack entry for the whole paste. Cell by cell would put forty entries on the - // stack for one Ctrl+V, and undoing a paste one cell at a time is not undoing a paste. - patchManyAndRecord( - patches.map((patch) => ({ pid: patch.pid, updates: { [field.key]: patch.value } })), - "a paste" - ); - // We handled the write ourselves. Returning false tells glide not to run its cell - // renderers' generic paste path (Bubble/rating renderers cannot enforce this contract). - return false; - }, - [visibleCols, fieldByKey, canEditField, statusValues, displayRows, patchManyAndRecord, - gridSelection.current] - ); - - const openHeaderMenu = useCallback( - (column: number, bounds: Rectangle) => { - if (embedded) return; - const definition = visibleCols[column]; - if (!definition?.id) return; - setColumnMenu({ - fieldKey: definition.id, - anchor: { - left: bounds.x, - top: bounds.y, - right: bounds.x + bounds.width, - bottom: bounds.y + bounds.height, - width: bounds.width, - height: bounds.height, - }, - }); - }, - [embedded, visibleCols] - ); - const onHeaderClicked = useCallback( - (column: number, event: HeaderClickedEventArgs) => { - if (event.isEdge) return; - event.preventDefault(); - openHeaderMenu(column, event.bounds); - }, - [openHeaderMenu] - ); - const onGridKeyDown = useCallback( - (event: GridKeyEventArgs) => { - if ( - event.key.toLowerCase() === "c" && - (event.ctrlKey || event.metaKey) && - !event.altKey - ) { - const range = gridSelection.current?.range; - const source = range?.width === 1 ? visibleCols[range.x] : undefined; - copyProvenanceRef.current = markGridCopy(source?.id ?? null, Date.now()); - // Do not prevent default: glide still owns serialization and the OS clipboard write. - return; - } - // Owner item 5 (2026-07-31) — EXCEL-GRADE ENTER. When no editor is open (an open overlay - // editor swallows its own keys before the canvas sees them), Enter moves the active cell - // DOWN one record and Shift+Enter moves UP — never opening the record drawer, which is - // what made keyboard runs down a column "really clunky". Glide's own overlay editor - // already commits-and-moves-down on Enter, so typing → Enter → typing flows like Excel; - // this handles the BETWEEN-edits half. Group headers are skipped in the direction of - // travel. The drawer stays reachable by double-click and the hover Expand button. - if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey) { - const cur = gridSelection.current?.cell; - if (cur) { - event.preventDefault(); - event.stopPropagation(); - event.cancel(); - // Owner item 6 (2026-07-31) — Enter on a PICKED cell (select / multi select / - // assignee / rating) opens its picker: the keyboard door the mouse click already - // had. glide's text overlay cannot serve these types (validateCell refuses them), - // so without this the keyboard run down a column dead-ends at every picked field. - // Shift+Enter stays pure navigation, so walking UP past picked cells still works. - const curRow = displayRows[cur[1]]; - const curColumn = visibleCols[cur[0]]; - const curField = curColumn ? fieldByKey.get(curColumn.id!) : undefined; - if ( - !event.shiftKey && - curRow?.kind === "data" && - curField && - canEditField(curField) && - (isPickType(curField.type) || curField.type === "rating") - ) { - const b = gridRef.current?.getBounds(cur[0], cur[1]); - if (b) { - setPicker({ - pid: curRow.record.pid, - fieldKey: curField.key, - anchor: { - left: b.x, top: b.y, right: b.x + b.width, bottom: b.y + b.height, - width: b.width, height: b.height, - }, - }); - return; - } - } - const dir = event.shiftKey ? -1 : 1; - let row = cur[1] + dir; - while (row >= 0 && row < displayRows.length && displayRows[row]?.kind !== "data") - row += dir; - if (row >= 0 && row < displayRows.length) { - setActiveCell(cur[0], row); - gridRef.current?.scrollTo(cur[0], row, "vertical", 0, 0); - } - return; - } - } - const contextMenu = - event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey); - // DataEditor exposes public controlled selection without the row marker, - // while GridKeyEventArgs.location comes from its internal grid and still - // includes that marker at column zero. - const eventColumn = - event.location && event.location[0] > 0 - ? event.location[0] - 1 - : undefined; - const column = gridSelection.current?.cell[0] ?? eventColumn; - if (!contextMenu || column === undefined || !event.bounds) return; - const gridBounds = gridBoxRef.current?.getBoundingClientRect(); - const headerTop = gridBounds?.top ?? event.bounds.y; - event.preventDefault(); - event.stopPropagation(); - event.cancel(); - openHeaderMenu(column, { - x: event.bounds.x, - y: headerTop, - width: event.bounds.width, - height: 36, - }); - }, - [gridSelection.current, openHeaderMenu, displayRows, setActiveCell, - visibleCols, fieldByKey, canEditField] - ); - - /** - * ⭐⭐ W35-T23 (R2) — ONE BODY FOR BOTH SURFACES, and the branch that used to sit here is why. - * - * It had a `if (queryBinding) { ...refuse; return; }` prologue that never touched `views`, so a - * Query surface could not change its own spec at all. R2 makes it live, and the ONLY difference - * left is which transport the router picks — which is `routeQueryViewMutation`'s whole job, so - * asking the question twice (once here, once inside it) was the duplication that made the two - * paths drift. - * - * ⚠ THE STATE UPDATE MUST HAPPEN FOR BOTH. Without it the autosave effect compares `config` - * against a `views` entry that never moved, `sameConfig` stays false, and the effect re-fires - * every render — a POST per frame for the life of the surface. - * - * ⚠ A view whose id is NOT the artefact's is still refused by the router (a Query workspace - * holds exactly one view), so Duplicate cannot smuggle a second one in. - */ - const persistView = useCallback((view: SavedView) => { - setViews((current) => { - const found = current.some((item) => item.id === view.id); - return found - ? current.map((item) => (item.id === view.id ? view : item)) - : [...current, view]; - }); - // ⛔⛔ WAVE 35 QA — THE SAME RE-IDENTIFICATION THE AUTOSAVE NEEDS, AND THIS IS THE CALL SITE - // THE TOOLBAR ACTUALLY USES. `persistView` is what Rows / Sort / Group / Hide fields go - // through, so fixing only the autosave left every one of them still refused: measured on the - // DEPLOYED build after the first fix — row height still raised *"…cannot take a second view"* - // and `edited` stayed `false`. There are FOUR callers of the router in this file and the - // identity belongs to the WRITE, not to one of them. - // ⚠ Only the emitted subject moves; `setViews` above keeps the source view's own id. - const subject = queryBinding ? { ...view, id: queryBinding.artifactId } : view; - const routed = routeQueryViewMutation(queryBinding, scope, { - id: eventId(queryBinding ? "query-view-update" : "view"), - type: "view_upsert", - view: subject as unknown as Record, - }, { query: mutateQueryWorkspace, native: emitHostEvent }); - if (routed.channel === "refused") - signal(TOAST_EVENT, routed.refusal?.message ?? "That view change could not be saved."); - else if (routed.channel === "query") - void routed.result.then((result) => { - if (!result.ok) signal(TOAST_EVENT, result.message); - }); - }, [queryBinding, scope]); - - /** - * Item 12 (C-LOCK) — set or clear a view's cohort lock, from the rail's menu. - * - * Works on ANY view, not only the active one, which is why it goes through `persistView` - * rather than `updateConfig`: the rail's menu opens on whichever row you clicked. When the - * target IS the active view, the live `config` must move too, or the table keeps showing the - * old row set until the next select and the lock reads as ignored. - * - * Clearing DELETES the key rather than storing null — the no-churn rule every other optional - * config member follows, and the shape `_clean_display`'s sibling validator expects. - */ - const onViewCohortLock = useCallback( - (viewId: string, cohortId: string | null) => { - const target = views.find((v) => v.id === viewId); - if (!target) return; - const nextConfig = { ...target.config }; - if (cohortId) nextConfig.cohortLock = cohortId; - else delete (nextConfig as Record).cohortLock; - persistView({ ...target, config: nextConfig }); - if (viewId === activeViewId) { - setConfig((live) => { - const next = { ...live }; - if (cohortId) next.cohortLock = cohortId; - else delete (next as Record).cohortLock; - return next; - }); - } - }, - [views, activeViewId, persistView] - ); - - const selectView = useCallback( - (id: string) => { - if (isQueryPreview) return; - const current = views.find((view) => view.id === activeViewId); - if (current && !sameConfig(current.config, config)) - persistView({ ...current, config }); - /** - * ⭐⭐ W35-T30 (C5) — "Starred records" IS SELECTABLE AND IS NOT IN `views`, DELIBERATELY. - * - * ⛔ IT IS THE SERVER'S PROJECTION, NOT A WORKSPACE VIEW. Half this component keys off - * `views`: the autosave, the echo reconcile, `writeLocal`, `uniqueDisplayName`. Merging a - * server-owned object into that state would put it into the autosave's comparison and this - * browser would start writing a view it does not own — the shape D-170 refuses on a - * read-through grid, arriving from the client side instead. So it joins the RAIL's list - * (see `railViews`) and is resolved here by name, which is the same split the comment on - * `applyViewOrder` makes for view ORDER: a rendering fact about one surface. - */ - const next = views.find((view) => view.id === id) - ?? (id === recordStars.stars.view?.id ? recordStars.stars.view : undefined) - /** - * ⭐⭐ W36-T04/T05 — THE THIRD PROJECTION, and without it a script View could be SEEN in - * the rail and never OPENED. Script views join `railViews` and are deliberately absent - * from `views` (they are not workspace views), so the first clause misses them and the - * guard below swallowed the click. Found by review, not by any of this ticket's own - * assertions: every one of them is pure-function, static-render or a source scan, and - * not one drives THIS callback [[reachable-is-not-the-same-as-built]]. - * ⛔ APPENDED AS A THIRD `??`, never folded into the chain: `verify_grid_ux.py`'s - * record-star scan pins the clause above VERBATIM, and rewriting it reds a claim that is - * still true (the sixth time this wave a gate pinned a spelling in nobody's fence). - * ⚠ Setting `config` is the half that matters as much as the selection: `displayMode` - * reads `config.display.mode`, so without this the grid would keep painting UNDERNEATH - * the script panel — both surfaces at once, which is what `setActiveViewId` alone did. - */ - ?? scriptProjections.find((view) => view.id === id); - if (!next) return; - setActiveViewId(id); - setConfig(normalizeConfig(next.config, fields)); - setCollapsed(new Set()); - setSearch(""); - setDetailPid(null); - setDisplayCap(DISPLAY_PAGE); - // Owner item 3 (2026-07-31): tell the host WHERE THE USER IS, so a fresh browser (no - // localStorage copy) resumes on this view instead of the system default. Presentation - // state — the host stores the id and the read side re-validates it. - emitHostEvent({ id: eventId("view"), type: "view_select", viewId: id }); - // Owner item 10: opening a view is "I am working now" — the frame folds its nav rail. - signal(NAV_MINIMIZE_EVENT); - }, - [views, activeViewId, config, persistView, fields, isQueryPreview, recordStars.stars.view, - scriptProjections] - ); - - /** - * A-S4-3 (item 25) — a notification's click-through. The SHELL routes to the table and fires - * this; the GRID owns view selection, so neither learns the other's state. - * - * ⛔ IGNORE, NEVER THROW, when the view is not ours: an alert can outlive the view it watches - * (deleted, or a share revoked), and a reader who can no longer see it must simply land on the - * table. `selectView` already returns early on an unknown id; the topic check stops one grid - * reacting to another's alert when both are mounted. - */ - useEffect(() => { - const onOpen = (e: Event) => { - const detail = (e as CustomEvent).detail; - if (!detail || detail.topic !== scope) return; - if (!views.some((v) => v.id === detail.viewId)) return; - selectView(detail.viewId); - }; - window.addEventListener(VIEW_OPEN_EVENT, onOpen); - return () => window.removeEventListener(VIEW_OPEN_EVENT, onOpen); - }, [scope, views, selectView]); - const createView = useCallback( - (name: string, mode: DisplayMode, permissions: ViewPermissions) => { - if (queryBinding) { - const routed = routeQueryViewMutation(queryBinding, scope, { - id: eventId("query-view-create"), type: "view_create", - }, { query: mutateQueryWorkspace, native: emitHostEvent }); - if (routed.channel === "refused") - signal(TOAST_EVENT, routed.refusal?.message ?? "This Query binding cannot create a source view."); - return; - } - const acceptedName = uniqueDisplayName(name, views.map((view) => view.name)); - /** - * ⭐⭐ WAVE 27 · OWNER ITEM 9 / RULING R4 — **A NEW VIEW IS BLANK. ALL OF IT.** - * - * ⛔ THIS REVERSES THE WAVE-26 COMMENT THAT STOOD HERE, so the reversal is stated rather - * than quietly applied. That comment dropped `cohortLock` from the spread and defended - * keeping the rest: *"their inheritance is a FEATURE (build a view, branch off it) - * precisely because it is visible"*. The owner disagrees, in as many words — R4: - * **"New views ALWAYS start blank — no filters/sorts inherited from anything."** The - * branch-off use it defended gets its own door later (Duplicate view); it is not what the - * "+" button means, and the wave-26 fix was the right diagnosis of the wrong scope — one - * member of the spread was invisible, but ALL of them arrived unasked. - * - * ⛔ AND `defaultViewConfig` IS THE BLANK, not a literal assembled here. It is the same - * function the grid's own initial state and the cohort door already use, so "blank" has - * ONE definition in this file ([[one-evaluator-per-question]]); a second literal beside it - * would be a place for the two to disagree the day a `ViewConfig` member is added — and - * the one that got added last wave is exactly what caused this bug. - * - * WHAT STOPS CARRYING, enumerated because a reader deserves the list and not just the - * ruling: `filters` + `filterConj`, `sorts`, `groupBy`, `colorBy`, `rowHeightMode`, - * `order`/`visible` (hidden columns), `widths`, `memberPids`, `frozenCount` and - * `cohortLock`. `order`/`visible` come back from the FIELD list's own default visibility, - * which is why `fields` replaces `config` in the dependency list below. - * - * ⚠ THE DISPLAY REFS GO TOO, and that is the one deliberate loss. W13's carry rule kept - * the calendar's date field / kanban's stack field / map's lat-lon across a mode switch so - * Grid→Map→Grid→Map did not re-ask; a NEW view is not a mode switch, and inheriting the - * previous view's stack field is inheritance of exactly the kind R4 names. A ref-less - * kanban asks which field to stack by, which is the honest state for a view born empty. - * `cleanDisplay` still collapses a ref-less GRID to absent, so picking "Grid" produces the - * byte-identical shape every pre-wave-9 view has (no churn). - */ - const nextConfig: ViewConfig = { - ...defaultViewConfig(fields), - display: cleanDisplay({ mode }), - }; - const view: SavedView = { - id: nextViewId(), - name: acceptedName, - kind: "custom", - config: nextConfig, - // I17 (C4) — sent EXPLICITLY. Absent on create means 'personal' host-side, so a user - // who chose "Collaborative" would silently get the opposite. `createdBy` is NOT sent: - // the host stamps it and ignores whatever the browser claims. - permissions, - }; - persistView(view); - setActiveViewId(view.id); - // The LIVE config has to move too, not just the stored one. Without this the view is - // created as a Calendar and the user keeps staring at the Grid until they switch views - // and back — the mode would be real in the store and invisible on screen. - setConfig(nextConfig); - setSaveState("saved"); - }, - // `fields`, not `config` (R4): the blank is derived from the COLUMNS, and reading the live - // config here at all is what item 9 deletes. - [fields, persistView, views, queryBinding, scope] - ); - const renameView = useCallback( - (id: string, name: string) => { - const view = views.find((item) => item.id === id); - if (view) { - const acceptedName = uniqueDisplayName( - name, - views.filter((item) => item.id !== id).map((item) => item.name) - ); - persistView({ ...view, name: acceptedName }); - } - }, - [views, persistView] - ); - /** - * I12 (contract C3) — freeze/unfreeze a view's DISPLAY MODE. - * - * The client re-checks the actor before emitting even though the menu entry is already - * gated: an event can be replayed, and "hidden in the client" has never been a permission. - * The host checks it again and that check is the wall — this one only keeps the client from - * showing a change that will not survive the round trip. - */ - /** - * ⭐⭐ WAVE 32 · T24 (owner item 17, ruling R5, contract C4) — MARK / UNMARK IMPORTANT. - * - * ⛔ NO PERMISSION TEST OF ITS OWN, unlike `toggleViewLock` above, and that is the ruling - * rather than an omission: R5's mark is a legibility flag ("keep this number in front of me"), - * not a lock over anybody's rows. `persistView` already refuses a view this caller may not - * write, which is the wall that matters. - * - * ⚠ `important` is written EXPLICITLY as a boolean, never by deleting the key. The server's - * allowlist reads `cfg.get('important') is True`, so an unmark has to ARRIVE as `false`; a - * client that dropped the key on unmark would leave the stored `true` untouched and produce a - * mark that can be set and never cleared. - */ - /** - * ⭐⭐ WAVE 33 · T13 (owner item 3) — THE MARK MUST MOVE THE LIVE CONFIG TOO, and without the - * last two lines it UNDOES ITSELF within a tick. - * - * ⛔ THE MECHANISM, reproduced before it was fixed. `persistView` updates `views`; the LIVE - * `config` state is a separate copy of the active view's config. Mark the ACTIVE view and the - * two disagree by exactly one key — at which point the autosave effect above - * (`sameConfig(active.config, config)` is a whole-object `JSON.stringify` compare) sees a - * difference, fires, and writes `{ ...active, config }` — THE LIVE CONFIG, which never learned - * about `important`. The server's allowlist then stores `'important': cfg.get('important') is - * True` = **False**, and the mark is gone. The badge paints for one render and vanishes. - * - * ⛔ WHY IT LOOKED LIKE AN "IG PRESET DATABASE" PROBLEM, which is the guess in item 3 and the - * thing four separate theories were checked against: this only bites the ACTIVE view, and an IG - * database's Overview is PINNED to the top of the rail (`aios_grid.IG_OVERVIEW_ID`, re-pinned - * above the system view) and is therefore the view you are standing on when you mark it. Mark - * any OTHER view from the rail menu and it sticks, because the live config is not its config. - * Nothing about presets, locks, windowing or system views is involved. - * - * ⭐ THE FIX IS ITS OWN SIBLING'S, VERBATIM: `onViewCohortLock` below already moves both halves - * and its comment already states this rule ("the live `config` must move too"). This callback - * was written without that line, which is the whole defect. - */ - const toggleViewImportant = useCallback( - (id: string, important: boolean) => { - const view = views.find((item) => item.id === id); - if (!view) return; - persistView({ ...view, config: { ...view.config, important } }); - if (id === activeViewId) setConfig((live) => ({ ...live, important })); - }, - [views, persistView, activeViewId] - ); - const toggleViewLock = useCallback( - (id: string, locked: boolean) => { - const view = views.find((item) => item.id === id); - if (!view || !mayToggleViewLock(view, viewer)) return; - persistView({ ...view, locked }); - }, - [views, persistView, viewer] - ); - // The list description. Template lists ship with prose seeded by the host, but it is - // the USER's text once they touch it — including clearing it. An empty string is - // persisted as an empty string (never coerced back to the seed), because the host - // resolves a saved view OVER its template, so "" is how you delete a description. - const setViewNote = useCallback( - (id: string, note: string) => { - const view = views.find((item) => item.id === id); - if (view) persistView({ ...view, note: note.slice(0, 2000) }); - }, - [views, persistView] - ); - const duplicateView = useCallback( - (id: string) => { - const source = views.find((view) => view.id === id); - if (!source) return; - const copy: SavedView = { - ...source, - id: nextViewId(), - name: uniqueDisplayName( - `${source.name} copy`, - views.map((view) => view.name) - ), - kind: "custom", - locked: false, - // I17 (C4) — a COPY IS THE DUPLICATOR'S OWN, PERSONAL view. Ruled, not inherited: - // - inheriting a 'users' grant would silently re-share the copy with a list the - // person making it never chose; - // - Duplicate is also the escape hatch for someone who may NOT edit the original, - // and 'personal' + the host's fresh createdBy stamp is exactly "mine to work in". - permissions: { edit: "personal" }, - // Never echo the SOURCE's creator: the host stamps and ignores what the browser - // sends, but sending someone else's name is a laundering attempt on its face. - createdBy: undefined, - config: { ...source.config }, - }; - persistView(copy); - setActiveViewId(copy.id); - setConfig(copy.config); - }, - [views, persistView] - ); - const deleteView = useCallback( - (id: string) => { - if (queryBinding) { - const routed = routeQueryViewMutation(queryBinding, scope, { - id: eventId("query-view-delete"), type: "view_delete", viewId: id, - }, { query: mutateQueryWorkspace, native: emitHostEvent }); - if (routed.channel === "refused") { - signal(TOAST_EVENT, routed.refusal?.message ?? "This Query binding cannot delete a source view."); - return; - } - if (routed.channel !== "query") return; - void routed.result.then((result) => { - if (!result.ok) { - signal(TOAST_EVENT, result.message); - return; - } - setViews([]); - setActiveViewId(""); - }); - return; - } - const view = views.find((item) => item.id === id); - // ⚠ This gate used to read `view.locked`, and the menu entry above it did too. C3 - // redefines `locked` as "the DISPLAY MODE is frozen" on ANY view, so leaving the gate - // here would make a user-frozen Kanban undeletable in the client while the host would - // delete it happily (app.py refuses only `all-customers`, by id). The MENU is just the - // door — this is the gate, and both had to move. - // C4 rides alongside: you may not delete a view you may not edit. - if (!view || isUndeletableView(view) || !mayEditView(view, viewer)) return; - // 2026-08-04 — STAMP BEFORE THE EMIT, the writeLocal-before-emit order every other - // optimistic path here uses. The live adopt below re-reads `payload.workspace.views` - // on every echo, and the queue sends ONE batch at a time — so an echo answering an - // EARLIER batch still lists this view, and without the tombstone the row would come - // back and (since that merge never removes) stay back. See liveWorkspace.ts. - viewTombstonesRef.current = stampTombstone(viewTombstonesRef.current, id, Date.now()); - setViews((current) => current.filter((item) => item.id !== id)); - routeQueryViewMutation(undefined, scope, { - id: eventId("view-delete"), type: "view_delete", viewId: id, - }, { query: mutateQueryWorkspace, native: emitHostEvent }); - if (activeViewId === id) { - const all = views.find((item) => item.id === ALL_VIEW_ID) ?? allRecordsView(fields, scope); - setActiveViewId(all.id); - setConfig(all.config); - } - }, - [views, activeViewId, fields, viewer, scope, queryBinding] - ); - - /** - * Wave-6 item 7 — PER-COHORT view state. Each cohort keeps its own view under the stable id - * `cohort:`; a cohort with no saved view opens CLEAN (defaultViewConfig — no sort, - * no filters), which is exactly what "first created" means. The view is added LOCALLY on - * first open and emitted only when the user actually edits it (the autosave effect already - * compares configs), so "absent = clean" stays true in the store — opening every cohort - * once must not write a store full of empty views. - */ - useEffect(() => { - if (!cohortMode || !workspaceReady || !activeCohortId) return; - const vid = `cohort:${activeCohortId}`; - if (activeViewId === vid) return; - const outgoing = views.find((view) => view.id === activeViewId); - if (outgoing && !sameConfig(outgoing.config, config)) - persistView({ ...outgoing, config }); - const existing = views.find((view) => view.id === vid); - const view: SavedView = existing ?? { - id: vid, - name: lists.find((l) => l.id === activeCohortId)?.name ?? "Cohort", - kind: "custom", - config: defaultViewConfig(fields), - }; - if (!existing) setViews((current) => [...current, view]); - setActiveViewId(vid); - setConfig(normalizeConfig(view.config, fields)); - setCollapsed(new Set()); - setSearch(""); - }, [cohortMode, workspaceReady, activeCohortId, activeViewId, views, config, - fields, lists, persistView]); - - /** - * "Add to list" (owner item 6). Runs the SAME engine over the named view's config to learn - * which customers it matches, then hands those pids to the host. - * - * ⚠ Deliberately re-runs the pipeline for THAT view rather than using what is on screen: the - * menu is available on every view, not only the active one, and "add the rows I can see" would - * quietly mean something different depending on which view happened to be open. - * - * ⚠ Refuses on a windowed table. There the client holds ONE PAGE, so "the customers this view - * matches" is a question it cannot answer — it would add the 200 rows it happens to hold and - * report success. Silence would be worse than the refusal (CG-3, no-unverifiable-aggregates). - * - * ⚠ Refuses for the SAME reason when this view has a measure condition with no answer yet - * (CG-8). A pending condition matches nothing, so the cohort would be built from a filter that - * is currently narrower than the one the user is reading — and a cohort is a FIXED set, so - * that wrong membership would persist long after the answer arrived. - */ - /** - * ⭐ WAVE 27 · OWNER ITEM 21 / RULING R14 — HOW MANY RECORDS EACH ALERTED VIEW MATCHES. - * - * ⛔ ONE EVALUATOR, AND IT IS `runPipeline` RATHER THAN `matchFilterTree`. The question is - * "how many records does this VIEW show", and a view is more than its condition list: a - * `cohortLock` narrows before anything else, `memberPids` are pinned in regardless, and a - * RANK leaf ("top 10") is only answerable against the domain the other conditions leave. - * `matchFilterTree` answers a different question — "does this ROW match this tree" — and - * using it here would report the whole book for a locked view and nonsense for a ranked one. - * `addToList` below already reaches for the pipeline for the same reason - * ([[one-evaluator-per-question]]); this is the same call with the same inputs. - * - * ⛔ THREE CASES WHERE THERE IS NO HONEST NUMBER, and each is ABSENT rather than zero: - * · a WINDOWED table — the client engine is skipped by design (CG-3), so a count here - * would be the size of one PAGE wearing the label of a scope; - * · an unresolved MEASURE condition — the server has not answered it yet, so the view - * matches nothing *yet*, and rendering `0` would state a fact nobody has established; - * · a view the rail no longer holds — an alert outlives its view. - * A missing key means no badge (the prop's own contract), which is the correct rendering of - * "not known" and is distinguishable from `0`, which is a real and interesting answer. - * - * ⚠ MEMOISED ON THE ALERTED SET, not on `views`: this walks the whole book once per alerted - * view, and on a table with no alerts it does nothing at all. - */ - /** - * ⭐⭐ WAVE 32 · T24 (R5/C4) — THE BADGE'S SOURCE SET GAINS THE MARKED VIEWS. - * - * ⛔ THE SAME MACHINE, NOT A SECOND ONE. Wave 27 already computes a live matching count for - * every ALERTED view and paints it as a red pill; R5 asks for exactly that number on a view - * the user marked. Building a parallel counter would give one rail two ideas of "how many - * records match this view", and they would drift the first time the pipeline changed. - * ⛔ AND IT ADDS NO QUERY — R8's whole subject is that `/nav` is too slow. This folds over - * `computedRows`, which the grid already holds; the memo below still walks the book once per - * counted view, and on a table with neither an alert nor a mark it does nothing at all. - */ - const importantIds = useMemo( - () => views.filter((v) => v.config?.important === true).map((v) => v.id), - [views] - ); - // ⚠ DEDUPED: a view that is BOTH alerted and marked must be counted once, or the memo walks - // the whole book twice for one number. - const countedIds = useMemo( - () => Array.from(new Set([...alerted, ...importantIds])), - [alerted, importantIds] - ); - const alertedKey = countedIds.join(","); - const alertCounts = useMemo(() => { - const out: Record = {}; - if (serverWindowed || !countedIds.length) return out; - for (const id of countedIds) { - const view = views.find((v) => v.id === id); - if (!view) continue; - if (pendingMeasures(view.config.filters, measureSets) > 0) continue; - const { pidToIndex } = runPipeline({ - rawRows: computedRows, - fields, - filters: view.config.filters, - search: "", - sorts: [], - groupBy: null, - collapsed: new Set(), - memberPids: view.config.memberPids, - filterConj: view.config.filterConj ?? "and", - measureSets, - cohortSets, - cohortLock: view.config.cohortLock, - today, - }); - // DISTINCT records, which is what `pidToIndex` is — a grouped pipeline can list one - // record under several headings and `visibleRows.length` would then count it twice. - out[id] = pidToIndex.size; - } - return out; - // `alertedKey` is the scalar identity of the list; `alerted` itself is a fresh array on - // every fetch even when the answer has not changed. - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [alertedKey, views, computedRows, fields, measureSets, cohortSets, today, serverWindowed]); - - /** - * ⭐⭐ WAVE 33 · T14 (D-205) — WHY THERE IS NO NUMBER, said out loud. - * - * ⛔ THE COMPLAINT THIS ANSWERS. `alertCounts` above returns `{}` for a server-windowed grid - * and skips any view with an unresolved measure — both correct, because the client holds ONE - * WINDOW and a count computed from it would be the size of a page wearing the label of a - * scope. But it returned them SILENTLY, so the badge simply was not there, on exactly the - * big Odoo grids the owner has been chasing. An absent badge and "nothing matches" are - * indistinguishable to a reader. - * - * ⭐ THIS IS R6'S SECOND SENTENCE, WHICH IS THE HALF THAT GETS DROPPED: *"if there is lag or - * it can't be done, you need to explicitly tell me why and recommend a fix."* A limit that - * genuinely cannot be removed must be REPORTED with its cause — a silent truncation is the - * violation, not the limit. So this memo produces the SENTENCE for every counted view that - * did not get a number, and the rail renders it. - * - * ⛔ IT DOES NOT INVENT A COUNT, and must not. Counting a windowed grid honestly means a - * server-side `count(*)` per view — a new query per view on the read path, which is exactly - * what R8 refused for this badge ("it adds no query"). The recommended fix rides in the - * sentence instead of being silently attempted. - * - * ⚠ Keyed on the SAME `countedIds` and the SAME conditions as the memo above, in the same - * order — two lists that decide "is there a number here" by different rules would drift, and - * the drift would show as a row wearing both a count and an excuse. - */ - /* - * ⛔⛔ `importantTotal` IS DELETED (W35-T27, owner item 9 / R4). - * - * W33-T16 built it to answer *"the database should have the sum number of all the views with - * mark important numbers"*, and W34-T20 stripped its label. Owner item 9 removes the count - * from every surface EXCEPT the view itself, so the rail badge it fed is gone and this memo - * had no reader. Deleted rather than left computing a value nobody renders - * [[artifact-with-no-importer]]. - * - * ⚠ `importantIds` SURVIVES — it also feeds the `alerted ∪ important` union just above, which - * is what decides which views get counted at all. This deletion is the SUM, not the set. - */ - - const countNotes = useMemo(() => { - const out: Record = {}; - if (!countedIds.length) return out; - for (const id of countedIds) { - const view = views.find((v) => v.id === id); - if (!view) continue; - if (serverWindowed) { - out[id] = - "No count: this database is read through its source in windows, so the browser holds " + - "one page rather than the whole table. A number from it would be the size of that " + - "page, not of this view. Counting it honestly needs a server-side count per view."; - } else if (pendingMeasures(view.config.filters, measureSets) > 0) { - out[id] = - "No count yet: this view filters on a measure the server has not answered for these " + - "records. The number appears once that answer arrives."; - } - } - return out; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [alertedKey, views, measureSets, serverWindowed]); - - const addToList = useCallback( - (viewId: string, cohortId: string, name: string) => { - if (serverWindowed) return; - const view = views.find((v) => v.id === viewId); - if (!view) return; - if (pendingMeasures(view.config.filters, measureSets) > 0) return; - const { visibleRows: matched } = runPipeline({ - rawRows: computedRows, - fields, - filters: view.config.filters, - search: "", - sorts: view.config.sorts, - groupBy: null, - collapsed: new Set(), - memberPids: view.config.memberPids, - filterConj: view.config.filterConj ?? "and", - measureSets, - cohortSets, - today, - }); - const pids = matched - .filter((r): r is { kind: "data"; record: Row } => r.kind === "data") - .map((r) => r.record.pid); - emitHostEvent({ - id: eventId("addlist"), - type: "add_to_list", - viewId, - cohortId, - name, - pids, - }); - }, - [views, computedRows, fields, serverWindowed, measureSets, cohortSets, today] - ); - - // ---------------------------------------------------------------- I11c (C4) - // Folders for the Views rail. The MODEL (echo reconcile, tombstones, dangling - // refs) lives in folders.ts and is gated by verify_folders.py; this is only the - // wiring: stamps for what this browser just did, and the five events. - const [folderStamps, setFolderStamps] = useState({}); - /** - * Folders THIS browser just created, held until the host echo returns them. - * - * ⚠ Without these, `reconcileFolders` was being called with the host list as - * BOTH arguments, which makes its optimistic-create branch unreachable: a - * folder absent from `host` is equally absent from `local`. The function was - * right and the wiring was wrong, so "+ New folder" painted nothing until the - * round trip landed — seconds, on a cold container, with the user clicking - * again. The gate passed because it called reconcileFolders with two DIFFERENT - * arrays; it now also calls it the way this component does. - */ - const [localFolders, setLocalFolders] = useState([]); - const workspaceFolders = payload?.workspace?.folders; - const folders = useMemo( - () => reconcileFolders(workspaceFolders, localFolders, folderStamps, Date.now()), - [workspaceFolders, localFolders, folderStamps] - ); - const folderIdOf = useCallback( - (viewId: string) => - resolveFolderId( - viewId, - views.find((v) => v.id === viewId)?.folderId ?? null, - folders, - folderStamps, - Date.now() - ), - [views, folders, folderStamps] - ); - const stampFolder = useCallback((patch: (prev: FolderStamps) => FolderStamps) => { - setFolderStamps((prev) => pruneFolderStamps(patch(prev), Date.now())); - }, []); - - /* wave17 R1 / C-LOCKV — the COHORT FOLDER surface is gone. `folders['cohorts']` merged - into `folders['views']` in the host's one-shot migration and `cohortFolders` is no longer - emitted at all, so `reconcileFolders` here would have been reconciling an absent list - against nothing forever. A locked view files into an ORDINARY folder now, which is what R1 - means by "native folders". */ - - /** - * C4 as AMENDED — the folder-level bulk add's PREVIEW. Deliberately re-runs the - * engine per contained view, exactly as `addToList` does, and carries the SAME - * two refusals as named skips rather than dropping them silently: a union that - * quietly omits two views is a wrong cohort that looks right, and a cohort is a - * FIXED set, so it stays wrong ([[no-unverifiable-aggregates]]). - */ - const folderAddPreview = useCallback( - (folderId: string) => { - const pids = new Set(); - const skipped: { name: string; why: string }[] = []; - let counted = 0; - for (const view of views) { - if (folderIdOf(view.id) !== folderId) continue; - if (serverWindowed) { - skipped.push({ name: view.name, why: "this table loads one page at a time" }); - continue; - } - if (pendingMeasures(view.config.filters, measureSets) > 0) { - // C-NAME (item 9) — user-facing, so it says "metric" like every other surface. - skipped.push({ name: view.name, why: "a metric condition has no answer yet" }); - continue; - } - counted += 1; - const { visibleRows: matched } = runPipeline({ - rawRows: computedRows, - fields, - filters: view.config.filters, - search: "", - sorts: view.config.sorts, - groupBy: null, - collapsed: new Set(), - memberPids: view.config.memberPids, - filterConj: view.config.filterConj ?? "and", - measureSets, - cohortSets, - today, - }); - for (const r of matched) if (r.kind === "data") pids.add(r.record.pid); - } - return { pids: [...pids], counted, skipped }; - }, - [views, folderIdOf, serverWindowed, computedRows, fields, measureSets, cohortSets, today] - ); - - const folderAddToList = useCallback( - (folderId: string, cohortId: string, name: string) => { - const { pids } = folderAddPreview(folderId); - if (!pids.length) return; - // ONE emit with the deduped union, through the SAME guarded event a single - // view's "Add to cohort" uses — so the host validates both identically. - emitHostEvent({ - id: eventId("addlist"), - type: "add_to_list", - viewId: "", - cohortId, - name, - pids, - }); - }, - [folderAddPreview] - ); - - /** - * Wave-7 item W2 (contract C2) — export "the current view", CLIENT-side. The same - * engine run addToList uses (that is the point: the file says exactly what the count - * says), over the same pool the named surface scopes to. - * - * ⚠ Same two refusals as addToList, for the same reasons: a server-windowed table - * holds ONE PAGE (the client cannot answer "the view's matches"), and a view with a - * PENDING measure condition currently matches nothing — an export taken then would be - * an empty/narrower file wearing the view's name. Both windows self-resolve; the - * refusal is silent no-op for one round trip. - */ - const exportRows = useCallback( - (name: string, cfg: ViewConfig, poolRows: Row[], format: ExportFormat) => { - if (serverWindowed) return; - if (pendingMeasures(cfg.filters, measureSets) > 0) return; - const { visibleRows: matched } = runPipeline({ - rawRows: poolRows, - fields, - filters: cfg.filters, - search: "", - sorts: cfg.sorts, - groupBy: null, - collapsed: new Set(), - memberPids: cfg.memberPids, - filterConj: cfg.filterConj ?? "and", - measureSets, - cohortSets, - today, - }); - const records = matched - .filter((r): r is { kind: "data"; record: Row } => r.kind === "data") - .map((r) => - overlayEdits[r.record.pid] - ? { ...r.record, ...overlayEdits[r.record.pid] } - : r.record - ); - // Columns = the view's VISIBLE fields in display order (the reconciled order: - // every key present, config order first — the same shape useGridColumns paints). - const inOrder = new Set(cfg.order); - const keys = [ - ...cfg.order, - ...fields.map((f) => f.key).filter((k) => !inOrder.has(k)), - ]; - const vis = new Set(cfg.visible); - const cols = keys - .filter((k) => vis.has(k)) - .map((k) => fieldByKey.get(k)) - .filter((f): f is Field => !!f); - runExport(format, name, today, cols, records); - }, - [serverWindowed, fields, fieldByKey, measureSets, cohortSets, today, overlayEdits] - ); - - /** - * ⭐ owner item 3 (2026-08-03) — THE SHEET THE TIME-SERIES VIEW IS SHOWING, published by the - * panel. A ref rather than state on purpose: nothing renders from it, and setting state on - * every sheet rebuild would re-render the whole grid to feed a menu nobody has opened yet. - */ - const tsSheetRef = useRef(null); - const onTsSheet = useCallback((s: TsSheet) => { tsSheetRef.current = s; }, []); - /** The same channel for a SUMMARY calendar — the other mode whose content is not its rows. - * `null` is CalendarView saying "records mode, export the rows" (see its note). */ - const calSheetRef = useRef<{ fields: Field[]; rows: Row[] } | null>(null); - const onCalSheet = useCallback( - (s: { fields: Field[]; rows: Row[] } | null) => { calSheetRef.current = s; }, - [] - ); - - /** - * W2 — the view menu's Export (Customer page): the named view over the whole pool. - * - * ⭐ owner item 3 (2026-08-03) — AND IT EXPORTS WHAT THE VIEW SHOWS. Every mode used to - * export the same thing: the matched customer rows. For grid / list / kanban / calendar / - * map / chart that is right — those modes ARRANGE rows, so the rows are what they show, and - * a calendar's dates and a kanban's lanes are columns already in the file. - * - * TWO modes are not arrangements of rows, and both were wrong in the direction that matters: - * - * · `timeseries` — metric ROWS over period COLUMNS. Exporting it handed you a customer list - * that shares none of its numbers. It exports the SHEET. - * · `calendar` IN SUMMARY MODE — metric values per DAY (C-DISP item 4). Same problem, and - * the owner named this one by hand. A calendar in RECORDS mode is genuinely an - * arrangement of rows, so it keeps the row export; `CalendarView` says which it is by - * publishing a sheet or publishing null. - * - * ⚠ ONLY FOR THE ACTIVE VIEW, and this is a real limit, not an oversight. Both sheets are - * built by the mounted view — the time series from a server round trip, the calendar from the - * month on screen. A view sitting unopened in the rail has neither, and this component cannot - * conjure one without fetching it. So that case SAYS SO and downloads nothing: the - * alternative is silently handing over the customer rows under that view's name, which is the - * exact substitution this branch exists to stop. - */ - const exportViewData = useCallback( - (viewId: string, format: ExportFormat) => { - const view = views.find((v) => v.id === viewId); - if (!view) return; - const cfg = normalizeConfig(view.config, fields); - const spec = cleanDisplay(cfg.display); - const isActive = viewId === activeViewId; - const refuse = (what: string) => - signal( - TOAST_EVENT, - `Open "${view.name}" first: ${what} is exported from what it draws on screen.` - ); - - if (spec?.mode === "timeseries") { - const sheet = isActive ? tsSheetRef.current : null; - if (!sheet) return refuse("a time series"); - if (sheet.empty) { - signal(TOAST_EVENT, - `"${view.name}" has no metrics on its sheet yet. Add one, then export.`); - return; - } - if (format === "csv") { - // The gated builder (verify_timeseries), kept as THE csv path so the file the owner - // downloads is the one the gate proves — footnotes, grouped thousands and all. - triggerDownload( - exportFilename(view.name, today, format), - new Blob(["" + buildTsCsv(sheet)], { type: "text/csv;charset=utf-8" }) - ); - } else { - const { fields: tf, rows: tr } = tsSheetToTable(sheet); - runExport(format, view.name, today, tf, tr); - } - return; - } - - /* ═══ W18-C CATALOG ═══ A catalog is the THIRD mode that is not an arrangement of rows, - and it is the furthest from one: its pages are authored content, and its product codes - are a fraction of the pool in an order the user chose. Handing over the matched rows - under a catalogue's name would be the same substitution the two branches around this - one exist to stop. It exports as a PDF, through the browser's own print dialog (R10), - so this branch refuses the file and names the door that works. */ - if (spec?.mode === "catalog") { - signal( - TOAST_EVENT, - `"${view.name}" is a catalog. Open it and use Print to save it as a PDF. ` + - `A spreadsheet of its products is not what it draws.` - ); - return; - } - /* ═══ end W18-C CATALOG ═══ */ - - if (spec?.mode === "calendar" && spec.calendarMode === "summary") { - const sheet = isActive ? calSheetRef.current : null; - if (!sheet) return refuse("a calendar summary"); - runExport(format, view.name, today, sheet.fields, sheet.rows); - return; - } - - exportRows(view.name, cfg, computedRows, format); - }, - [views, fields, computedRows, exportRows, activeViewId, today] - ); - - /** - * The selection bar's "Add to locked view": exactly the CHECKED customers, not a view's - * matches. - * Same guarded `add_to_list` event — the host intersects the pids with the caller's pool - * either way, so a hand-picked set and a view's match set ride one validation path. - */ - const addSelectionToList = useCallback( - (cohortId: string, name: string) => { - const pids = [...selectedPids]; - if (!pids.length || serverWindowed) return; - emitHostEvent({ - id: eventId("addlist"), - type: "add_to_list", - viewId: activeViewId, - cohortId, - name, - pids, - }); - setSelAddOpen(false); - setSelListName(""); - clearSelection(); - }, - [selectedPids, serverWindowed, activeViewId, clearSelection] - ); - - // Item 3c: the local write is the RENDERED truth (the def half of the no-blip contract) — - // stamped so a lagged echo cannot claw it back at the next remount. Item 9c: `scope` rides - // the EVENT at create time only; the def itself carries `scope: 'cohort'` so the echo is - // byte-stable ('global' stays absent — the host normalizes them to one shape). - const scopeChoice = payload?.workspace?.scopeChoice === true; - /** - * Item 12 (C-LOCK) — the Filter banner's copy for a cohort-locked view. - * - * `undefined` when the view is not locked. When it IS locked but the set is not in `lists`, - * this returns an EMPTY object: the banner must still appear (the reader is looking at a - * narrowed table and deserves to know why), but with no name and no count, because a set we - * were given no membership for is a set we must not describe. RECORD's banner reads that - * absence and says "locked to a set you cannot see". - * - * The count is the cohort's OWN size, not the number of rows on screen — the conditions - * narrow within the lock, so the two differ and the banner is stating the lock. - */ - /** - * ⭐ wave17 R1 / C-LOCKV — the lock in force, which is now simply the ACTIVE VIEW's. - * - * Wave 15 had two sources: a saved `config.cohortLock` and an ephemeral pick from the Cohorts - * rail that outranked it while held. The second existed only because a projected cohort was - * not a saved view, so opening one could not be allowed to write anything. Under R1 it IS a - * saved view, so selecting it applies its own stored lock through the ordinary view path — - * one source, and it survives a reload, which the transient one never did. - */ - const effectiveCohortLock = config.cohortLock; - /* wave17 R1 / C-LOCKV — `cohortRows` and the whole `cohortRail` projection are GONE. They - turned `lists` into rail rows for a section that no longer exists; the host projects each - cohort into `views` instead, so the rail renders them through the same path as every other - view and there is nothing left to project. `lists` still arrives and is still load-bearing - — it is the MEMBERSHIP channel that feeds `cohortSets`, which is what the lock resolves - against. Its absence would blank every locked view, so it is not "unused". */ - const cohortLockChip = useMemo(() => { - const id = effectiveCohortLock; - if (!id) return undefined; - const set = lists.find((l) => l.id === id); - if (!set) return {}; - return { name: set.name, count: set.pids?.length ?? 0 }; - }, [effectiveCohortLock, lists]); - // (Setting/clearing the lock lives in `onViewCohortLock` above — the rail's menu operates on - // whichever view you clicked, not necessarily the active one, so it goes through - // `persistView` and only touches the live `config` when the two are the same view.) - /** - * Item 5 / contract C-LAYOUT — this user's record-detail field order, for RECORD's - * `RecordDetail` (they asked for exactly these two props in the wave mailbox). - * - * Read straight off the workspace stratum the host echoes, and emitted back as the - * `record_layout` event HOST landed. Deliberately NOT reconciled or optimistically merged - * here: the event returns False host-side (an autosave hot path like `view_upsert`), so - * there is no rerun to race, and `RecordDetail` holds the live order while a drag is in - * flight. Nothing else in the grid reads it — the order is the MODAL's, never the grid's - * `config.order`. - */ - const recordLayout = payload?.workspace?.recordLayout?.order; - const onRecordLayout = useCallback((order: string[]) => { - emitHostEvent({ id: eventId("rlayout"), type: "record_layout", order }); - }, []); - const saveField = useCallback( - (field: Field, scope?: FieldScope) => { - const requestId = eventId("field"); - const accepted: Field = { - ...field, - label: uniqueDisplayName( - field.label, - fields.filter((item) => item.key !== field.key).map((item) => item.label) - ), - editRequestId: requestId, - }; - delete accepted.labelCorrectedFrom; - delete accepted.labelCorrectionId; - stampFieldEdit(accepted.key); - setFields((current) => - current.some((item) => item.key === accepted.key) - ? current.map((item) => (item.key === accepted.key ? accepted : item)) - : [...current, accepted] - ); - emitHostEvent({ - id: requestId, - type: "field_upsert", - field: accepted, - ...(scope ? { scope } : {}), - }); - }, - [stampFieldEdit, fields] - ); - const createField = useCallback( - ( - label: string, - type: FieldType, - anchorKey: string | null, - side: "left" | "right" | "end", - options?: string[], - measureSpec?: { key: string; window: WindowSpec }, - extra?: FieldBuildExtra - ) => { - /** - * ⚠ RENAMED FROM `scope` (wave 21, item 7). It used to SHADOW the component's own - * `scope` prop — the surface this grid is drawing — with a completely different - * thing: a FIELD's storage stratum (`'cohort'` or nothing). Two unrelated meanings - * under one word, in a function that now has to speak to a per-table endpoint. - * Caught by `tsc` only because `FieldScope` and `SurfaceScope` happen to be - * incompatible unions; had either been a bare `string`, the C2 call below would have - * POSTed to `/tables/undefined/fields` and read as a server bug. - */ - const fieldScope = scopeChoice ? extra?.scope : undefined; - const scoped = fieldScope === "cohort" ? ({ scope: "cohort" } as const) : {}; - // Owner item 7 — a FORMULA-MEASURE column. `measure_` prefixed, source:'odoo' (read-only - // at both ends), derived (values arrive from the host per render), filterable:false (the - // replacement is the measure CONDITION with the same measure+window — the governed path). - // The first render after creation shows blank cells for exactly one round trip: the host - // persists the field, then computes the values into the next payload. - if (measureSpec) { - const field: Field = { - key: `measure_${slugify(label)}_${Math.random().toString(36).slice(2, 7)}`, - label, - type, - source: "odoo", - default: true, - custom: true, - derived: true, - filterable: false, - agg: ["currency", "int"].includes(type) ? "sum" : undefined, - measure: measureSpec, - ...scoped, - }; - saveField(field, fieldScope); - insertColumn(field.key, anchorKey, side); - return; - } - const field = { ...buildOverlayField(label, type, options, extra), ...scoped }; - /** - * ⭐ WAVE 21 item 7 (contract C2) — AN AUTOMATION COLUMN ON A USER DATABASE GOES TO - * THE DEFINITION, not to this user's overlay stratum. - * - * The automation editor's column picker reads `user_tables` (the definition); every - * grid-created column went to `_table_workspace` (the per-user overlay). So the - * picker was empty by construction: the user made the column, then could not find it - * in the automation they made it for. C2 routes exactly this one kind through - * `POST /tables/{key}/fields`, and the ROUTE is the wall — `_field_or_refuse` allows - * only the creator or an admin, which is the same answer the grid would have got. - * - * ⚠ NARROW ON PURPOSE, twice over: `type === "automation"` AND a `ut_` scope. The - * connector surfaces have no such endpoint (their schema is their source's), and - * every other kind keeps the overlay path this wave — moving all of them changes who - * can SEE a column, which is a behaviour change three surfaces would feel and is - * booked as debt rather than smuggled in here. - * - * ⚠ AND IT IS ASYNC, where every other create is optimistic. There is no local - * insert first: the server re-slugs the key and can refuse outright (it 400s until - * `UT_FIELD_TYPES` learns `automation` — C2's other half, A's), and a column shown - * under a key the store never took is one that vanishes on the next read with no - * explanation. The wait is one round trip; the alternative is a lie. - */ - /** - * ⭐⭐ 2026-08-09 — THE RELATIONAL PAIR JOINS THAT ROUTE, and for a stronger reason than - * `automation` had. The note above says every other kind "keeps the overlay path this - * wave", weighing it as a question of who can SEE a column. For `link` and `rollup` it is - * not a visibility trade-off at all: their cells are computed SERVER-side by - * `compute_relation_cells`, which walks `user_tables` definitions. A rollup parked in a - * per-user overlay is invisible to the only code that could ever fill it, so it is not a - * narrower feature — it is a column that cannot work, in any account, ever. - * - * ⛔ AND THE BAG TRAVELS. `_clean_field` returns None for a bagless `link`/`rollup`, so - * sending `{key,label,type}` here would trade a silent blank column for a loud 400. The - * bag is on `extra`; `field` already carries it (see `FieldBuildExtra`). - */ - /** - * ⭐⭐ 2026-08-10 — `formula` JOINS THE SAME ROUTE, on the visibility argument the note - * above weighs and the relational note declines to use. - * - * For `link`/`rollup` visibility was not the point (their cells are computed by something - * that reads the definition). For a formula it IS the whole point, and the trade-off falls - * the other way from wave 21's read of it: a `ut_*` database is a SHARED database — the - * owner builds a column on "IG profile" and expects the four people looking at that - * database to see it. In the overlay stratum only its creator ever does. Measured on - * nurilab: `Trimmed reel views`, the trimmed-average column the whole rollup chain exists - * to produce, was visible to exactly one account. - * - * ⛔ THE VALUES STILL DO NOT MOVE, and this must not be sold as more than it is. A formula - * is computed in this browser (`computedRows`) from the row's other cells and is stored - * nowhere; what became shared is the EXPRESSION. An automation, a rollup and an export - * still cannot read this column. Sharing the definition is not materialising the number. - * - * ⚠ `agg: "sum"` rides along so the totals row behaves identically to the overlay path - * (`buildOverlayField` stamps it) — a column that stops totalling because of which door - * created it is [[gate-answers-the-wrong-question]] wearing a create route. - */ - /** - * ⭐⭐ WAVE-29 T22 (owner item 2a) — **EVERY grid-created column on a `ut_*` database goes - * to the DEFINITION.** The three notes above widened this set one kind at a time - * (`automation`, then `link`/`rollup`, then `formula`), each ending by asking whoever needed - * a fifth to come back here and decide. This is that decision, and it goes to ALL of them - * rather than to `select` alone, because the defect the owner reported is not about select: - * - * ⛔ **ON A `ut_` SCOPE THERE IS NO PER-USER CELL STRATUM LEFT TO WRITE TO.** Every accepted - * cell edit is routed to `user_tables.patch_cells` — the shared DEFINITION rows - * (`grid_events.py:1979-1984`) — while both `ut_` row doors project rows through the - * definition's own field keys (`routes_tables.scoped_pool:176`, `user_tables.add_row:678`). - * So a column living only in `_table_workspace` accepts a value, stores it under a - * key the projection does not know, and reads back BLANK. **The write is not lost; the read - * cannot see it** — which is why the server reports the write as accepted and the cell is - * empty after any refetch. That is item 2a, and it applied to `text`, `int`, `date`, `user`, - * `image` and `checkbox` exactly as much as to `select`. The owner reported the one they - * happened to build. - * - * ⛔ **`created_time` is the ONE exclusion, and it is the SERVER's, not a preference:** - * `UT_FIELD_TYPES` does not contain it, so this POST would 400 (measured — CREATABLE_TYPES - * minus UT_FIELD_TYPES is exactly `{created_time}`). It keeps the overlay path, where it is - * harmless: it is `derived`, so it has no cell of its own to lose. - * - * ⚠ **The body is a PROJECTION of the field the constructor already built**, not a - * per-kind list of keys. The old spelling enumerated what each kind needed, which is how - * `options` came to be missing for the kind that needed it most — a select POSTed without - * its options is degraded to `text` on read (`aios_grid.py:664-668`). `buildOverlayField` - * decides the shape; this sends what it decided, so a new kind cannot arrive half-described. - * (`_clean_field` ignores what it does not know, so `custom`/`derived`/`multi` staying - * client-side costs nothing; `max` on a `rating` is the one key it drops — booked, not lost.) - */ - if (isUserTable && type !== "created_time") { - void addTableField(scope, { - key: field.key, - label: field.label, - type, - ...(field.options?.length ? { options: field.options } : {}), - ...(field.colorCodeOptions !== undefined - ? { colorCodeOptions: field.colorCodeOptions } - : {}), - ...(field.optionColors ? { optionColors: field.optionColors } : {}), - ...(extra?.link ? { link: extra.link } : {}), - ...(extra?.rollup ? { rollup: extra.rollup } : {}), - ...(type === "formula" && extra?.formula ? { formula: extra.formula } : {}), - ...(field.agg ? { agg: field.agg } : {}), - }).then((made) => { - if (!made) return; // the bridge has already said why - setFields((current) => - current.some((f) => f.key === made.key) ? current : [...current, made] - ); - insertColumn(made.key, anchorKey, side); - /** - * ⛔⛔ 2026-08-10 — THE WORKSPACE RE-READ USED TO FIRE HERE AND IT ATE THE LINE ABOVE. - * - * `insertColumn` adds the new key to the view's `visible` set and autosaves that config; - * `signal(WORKSPACE_STALE_EVENT)` refetches `/workspace`, whose views carry the SERVER's - * config — the one written a moment before the insert. The refetch won, every time, so - * a column created into the shared definition was **stored correctly and rendered - * nowhere**: present in the payload, present in `order` (which is re-derived from the - * field list), and absent from `visible`. MEASURED on live nurilab across all four - * views after migrating `Trimmed reel views`; it has been true for `link`, `rollup` and - * `automation` since that route was widened on 2026-08-09. - * - * ⛔ AND THE COMMENT IT REPLACES WAS THE REASON NOBODY LOOKED. It claimed the signal is - * "what makes the column survive a reload rather than living only in this browser's - * state" — but the SERVER already stored the field; the 201 is what makes it survive. - * The signal only refreshed a payload whose field list `setFields` had just updated by - * hand, and `addTableField` already drops the rows cache. It bought nothing and cost - * the one piece of state the create had just written. - * - * ⚠ The delete and reconfigure paths keep their signals: those change VALUES other - * columns fold (a dropped link takes its reciprocal and every rollup over it), and - * neither writes view config in the same breath, so neither has this race. - */ - }); - return; - } - saveField(field, fieldScope); - insertColumn(field.key, anchorKey, side); - }, - [insertColumn, saveField, scopeChoice, isUserTable, scope] - ); - - /** - * ⭐⭐ 2026-08-07 (D-79's last half) — turn a text column into THE profile column. - * - * The write goes to the DEFINITION (`PATCH /tables/{key}/fields/{fkey}`), never to this - * user's overlay stratum, for the reason wave 25's amendment C3-A1 records: the automation - * engine reads `t['rows']`/`t['fields']`, so a flag parked in one person's overlay is a flag - * the enrich step can never see. Same door, same reasoning, as the automation column above. - * - * ⚠ `profile: null` CLEARS it — `_clean_field` treats an explicit null as "take the flag off" - * rather than "leave it alone", which is what lets a column be un-marked without deleting it. - */ - const setProfileFlag = useCallback( - (key: string, on: boolean) => { - if (!isUserTable) return; - void patchTableField(scope, key, { - profile: on ? { source: "instagram" } : null, - }).then((made) => { - if (!made) return; // the bridge has already said why - setFields((current) => current.map((f) => (f.key === made.key ? made : f))); - signal(WORKSPACE_STALE_EVENT); - }); - }, - [isUserTable, scope] - ); - - /** - * ⭐⭐ 2026-08-09 (owner) — RECONFIGURE a `link`/`rollup` column, in the SHARED definition. - * - * Owner: *"even when i click edit field for rollup, it doesn't actually show me the - * configuration"* and *"No rollup field should be uneditable."* Both land here: the Edit pane - * now renders the bag, and this is where its Save goes. - * - * ⛔ NOT `saveField`, and not `retypeField`. Those write the per-user overlay stratum, which is - * precisely the defect this change exists to close — `compute_relation_cells` reads - * `user_tables`, so a bag parked in one person's workspace can never be computed by anything. - * ⚠ ASYNC with no optimistic insert, like the automation create: the server re-cleans the bag - * and can refuse the whole field (`_clean_rollup` returns None for, say, a `limit` with no - * `sortBy`), and showing a configuration the store never took is the lie the wait avoids. - * The cells arrive on the next read — a rollup is recomputed server-side, never in the browser. - */ - const setFieldConfig = useCallback( - (key: string, patch: { - label?: string; link?: unknown; rollup?: unknown; formula?: string; agg?: string; - }) => { - if (!isUserTable) return; - void patchTableField(scope, key, patch as Record).then((made) => { - if (!made) return; // the bridge has already said why - setFields((current) => current.map((f) => (f.key === made.key ? made : f))); - // The definition changed, so the wire's field list did too — and a rollup's VALUES are - // recomputed host-side on this write, so the ROWS are a beat stale as well. The bridge - // drops its rows cache for this table (it owns that map); these two ask for a re-read. - signal(WORKSPACE_STALE_EVENT); - signal(ROWS_STALE_EVENT); - }); - }, - [isUserTable, scope] - ); - - /** - * Owner item 9 — "Change field": the clicked column starts showing another field, in place. - * The new field takes the old one's slot in `order`; the old field is HIDDEN, not lost — it - * stays in Fields and can be swapped back. The locked primary column is the row identity and - * cannot be changed away (ColumnMenu never offers it the control). - */ - const changeField = useCallback( - (oldKey: string, newKey: string) => { - if (oldKey === lockedKey || oldKey === newKey) return; - if (!fieldByKey.has(newKey)) return; - // The RECONCILED order (every key present, locked first), so the splice is well-defined - // even for a fresh view whose config.order is still the default. - const withoutNew = order.filter((k) => k !== newKey); - const at = withoutNew.indexOf(oldKey); - if (at < 0) return; - withoutNew.splice(at, 0, newKey); - const nextVisible = new Set(visible); - nextVisible.delete(oldKey); - nextVisible.add(newKey); - updateConfig({ ...config, order: withoutNew, visible: [...nextVisible] }); - }, - [config, updateConfig, order, visible, fieldByKey, lockedKey] - ); - - /** - * Delete a USER-CREATED column outright (owner gap closed 2026-07-27). Only the created - * strata qualify — `custom_` overlay fields and `measure_` formula columns, both marked - * `custom` — so a base field can never leave the schema from here (Hide is its only exit). - * The definition leaves local state, the ACTIVE view's config is scrubbed (order / visible / - * widths / sorts / group / color), and the host removes it from the workspace store — other - * views self-heal on their next autosave, the rule every stale colId already rides. - * Two emits leave in one burst (field_delete + the config autosave); the event-log value - * slot exists for exactly this. - */ - const deleteField = useCallback( - (key: string) => { - const f = fieldByKey.get(key); - if (!f?.custom || key === lockedKey) return; - setFields((current) => current.filter((item) => item.key !== key)); - const nextWidths = { ...(config.widths ?? {}) }; - delete nextWidths[key]; - updateConfig({ - ...config, - order: order.filter((k) => k !== key), - visible: [...visible].filter((k) => k !== key), - widths: nextWidths, - sorts: (config.sorts ?? []).filter((s) => s.colId !== key), - groupBy: config.groupBy === key ? null : config.groupBy, - colorBy: config.colorBy === key ? null : config.colorBy, - }); - stampFieldDelete(key); - emitHostEvent({ id: eventId("fielddel"), type: "field_delete", key }); - }, - [config, updateConfig, order, visible, fieldByKey, lockedKey, stampFieldDelete] - ); - - /** - * ⭐⭐ 2026-08-10 — DELETE A COLUMN FROM A USER DATABASE'S SHARED DEFINITION. - * - * `deleteField` above writes the per-user overlay: it emits `field_delete`, which - * `grid_events` gates on the `custom_`/`measure_` prefix and applies to - * `
_table_workspace`. A definition column has a `custom_` key too, so that event is - * ACCEPTED and scrubs a bucket the definition never reads — the column disappears for one - * paint and is back on the next fetch. That is why this is a different function rather than a - * branch: two stores, two truths, and the wrong one succeeds quietly. - * - * ⚠ SERVER FIRST, THEN THE SCREEN. `_field_or_refuse` can say no (a pre-set column, a - * non-creator, the last remaining column), and the bridge has already shown its sentence — - * removing the column optimistically would leave the user looking at a grid that disagrees - * with the store until they reload. - * ⚠ The VIEW config is scrubbed on the same terms as the overlay delete; a stale `colId` in - * another view self-heals on its next autosave, the rule every other delete rides. - */ - const deleteDefinitionField = useCallback( - (key: string) => { - if (!isUserTable || key === lockedKey) return; - void deleteTableField(scope, key).then((ok) => { - if (!ok) return; // the bridge has already said why - setFields((current) => current.filter((item) => item.key !== key)); - const nextWidths = { ...(config.widths ?? {}) }; - delete nextWidths[key]; - updateConfig({ - ...config, - order: order.filter((k) => k !== key), - visible: [...visible].filter((k) => k !== key), - widths: nextWidths, - sorts: (config.sorts ?? []).filter((s) => s.colId !== key), - groupBy: config.groupBy === key ? null : config.groupBy, - colorBy: config.colorBy === key ? null : config.colorBy, - }); - signal(WORKSPACE_STALE_EVENT); - signal(ROWS_STALE_EVENT); - }); - }, - [isUserTable, scope, lockedKey, config, updateConfig, order, visible] - ); - - /** - * Change-field's "New field" half (wave-2 item 5): build the overlay field, then swap it into - * the clicked column's slot — one motion, two emits (field_upsert + the config autosave), - * which is exactly the burst the event-log value slot exists for. - */ - const createAndSwapField = useCallback( - ( - oldKey: string, - label: string, - type: FieldType, - options?: string[], - extra?: FieldBuildExtra - ) => { - const scope = scopeChoice ? extra?.scope : undefined; - const field = { - ...buildOverlayField(label, type, options, extra), - ...(scope === "cohort" ? ({ scope: "cohort" } as const) : {}), - }; - saveField(field, scope); - changeField(oldKey, field.key); - }, - [saveField, changeField, scopeChoice] - ); - - /** - * Wave-6 item 2 — Change field on a CREATED (`custom_`) field retypes THAT field in place: - * `field_upsert` with the SAME key and the new type/options; the host rebuilds the def and - * keeps createdBy/scope (this optimistic def carries them too, so the echo is byte-stable). - * Values are never converted — cells re-render per the new type, unreadable values show - * blank (Airtable behavior, disclosed in the pane). A display `format` survives only within - * its own family (number→number); anything else would be junk the host drops anyway. - */ - const retypeField = useCallback( - ( - key: string, - type: FieldType, - options?: string[], - // `label` (owner item 8): rename+retype leave the Edit pane as ONE upsert — two - // sequential emits would each read the stale def and revert the other. - extra?: FieldBuildExtra - ) => { - const old = fieldByKey.get(key); - /** - * ⭐⭐ WAVE-29 T27 — the same widening as the pane above, and it is load-bearing rather - * than cosmetic: supplying `onRetype` while this function still bailed would render a - * Field-type picker that silently does nothing, which is worse than the missing control - * the owner reported. - * - * ⭐ AND IT IS ALSO T24's SECOND HALF. Option colours have a validator now, but the EDIT - * path for them is this function: on a `ut_*` database the field lives in the shared - * DEFINITION, so `saveField` (this user's overlay stratum) would fork the column into a - * private copy of itself and the colours would never reach the door that stores them. - */ - if (!old || !isUserSchemaField(old, isUserTable)) return; - if (type === "formula" || type === "created_time") return; // a different stratum - const numberFam = (t: FieldType) => t === "int" || t === "currency"; - const keepFormat = - old.format && ((numberFam(old.type) && numberFam(type)) || old.type === type); - const next: Field = { - key, - label: extra?.label?.trim() || old.label, - type, - source: "overlay", - default: old.default, - custom: true, - agg: ["currency", "int"].includes(type) ? "sum" : undefined, - ...(old.note ? { note: old.note } : {}), - ...(old.description ? { description: old.description } : {}), - ...(old.scope ? { scope: old.scope } : {}), - ...(old.createdBy ? { createdBy: old.createdBy } : {}), - ...(old.permissions ? { permissions: old.permissions } : {}), - ...(keepFormat ? { format: old.format } : {}), - ...((type === "select" || type === "multiselect") && options?.length - ? { options } - : {}), - ...((type === "select" || type === "multiselect") - ? { - colorCodeOptions: extra?.colorCodeOptions !== false, - ...(extra?.optionColors && Object.keys(extra.optionColors).length - ? { optionColors: extra.optionColors } - : {}), - } - : {}), - ...(type === "multiselect" ? { multi: true } : {}), - ...(type === "rating" ? { max: extra?.max ?? 5 } : {}), - }; - if (isUserTable && !old.custom) { - /* THE DEFINITION DOOR. `patch_field` merges the body through the same `_clean_field` the - create used, so the type, the options, the colours and a rating's max all land in the - one validator — and the SERVER's echo replaces the local field, exactly as the create - path does, because it re-cleans what it was given and may refuse. */ - void patchTableField(scope, key, { - label: next.label, - type, - ...(next.options ? { options: next.options } : {}), - ...(next.optionColors ? { optionColors: next.optionColors } : {}), - ...(next.colorCodeOptions !== undefined - ? { colorCodeOptions: next.colorCodeOptions } - : {}), - ...(next.max !== undefined ? { max: next.max } : {}), - ...(next.agg ? { agg: next.agg } : {}), - ...(next.format ? { format: next.format } : {}), - }).then((made) => { - if (!made) return; // the bridge has already said why - setFields((current) => current.map((f) => (f.key === made.key ? made : f))); - signal(WORKSPACE_STALE_EVENT); - signal(ROWS_STALE_EVENT); - }); - } else { - saveField(next); - } - /* ⭐ Owner item 15 / C-RENAME — the VALUES follow the definition. - BESIDE the upsert, never instead of it, and AFTER it: the def is written optimistically - here, while the rename is a host-side migration over overlay values and saved views. The - order matters if the host processes the batch in order — the list must already offer - "Navy" before any cell is moved onto it. */ - const renames = extra?.renames?.filter((r) => r.from && r.to && r.from !== r.to) ?? []; - if (renames.length) { - emitHostEvent({ id: eventId("choicerename"), type: "choice_rename", key, renames }); - // R4 — and it is undoable: Ctrl+Z emits the mapping turned around. The declared LIST is - // restored by the same inverse (the host rewrites values and views back), so undo does - // not need to re-send the definition. - undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), { - kind: "choiceRename", fieldKey: key, renames, - }); - } - }, - // ⚠ `isUserTable` is in the list because T27 made this function BRANCH on it. A callback that - // closed over a stale `isUserTable` would route a definition retype to the overlay on the - // first render after a scope change — the exact defect this ticket removes, reintroduced by - // a memo rather than by a predicate. - [fieldByKey, saveField, scope, isUserTable] - ); - - /** - * Wave-5 item 1 — Duplicate field. The CLIENT generates the destination key (same stratum - * prefix as the source — the ~20:20 contract amendment), emits `field_duplicate`, and slots - * an optimistic clone right of its source SYNCHRONOUSLY. The host validates the prefix - * pair, stamps createdBy (the clone's creator is the duplicator — the local copy drops the - * source's), and copies overlay VALUES for `custom_` sources; those values arrive with the - * next payload, so the clone's cells are blank for exactly one round trip. - */ - const duplicateField = useCallback( - (source: Field) => { - const prefix = source.key.startsWith("measure_") ? "measure_" : "custom_"; - const label = uniqueDisplayName( - `${source.label} copy`, - fields.map((field) => field.label) - ); - const key = `${prefix}${slugify(label)}_${Math.random().toString(36).slice(2, 7)}`; - const requestId = eventId("fielddup"); - const clone: Field = { ...source, key, label, editRequestId: requestId }; - delete clone.createdBy; - delete clone.permissions; - delete clone.labelCorrectedFrom; - delete clone.labelCorrectionId; - stampFieldEdit(key); - setFields((current) => [...current, clone]); - emitHostEvent({ - id: requestId, - type: "field_duplicate", - sourceKey: source.key, - key, - label, - // Item 9c: a duplicate INHERITS its source's scope (cohort clone stays cohort-only; - // a global source stays global = absent). Only meaningful on the cohort page. - ...(scopeChoice && source.scope === "cohort" ? { scope: "cohort" as const } : {}), - }); - insertColumn(key, source.key, "right"); - }, - [insertColumn, scopeChoice, stampFieldEdit, fields] - ); - - /** - * Wave-2 item 8c — change the PERIOD of a measure-carrying column (user-created `measure_*` - * and the pre-set measure fields alike). The header auto-renames to - * ` · ` ONLY while it still reads as the auto-name for the CURRENT - * window — a user's own title is never overwritten. Values arrive recomputed from the host on - * the next payload; the ordinary `field_upsert` is the whole protocol. - */ - const changeMeasurePeriod = useCallback( - (key: string, window: WindowSpec) => { - const f = fieldByKey.get(key); - if (!f?.measure) return; - const m = measures.find((item) => item.key === f.measure?.key); - const oldAuto = m ? `${m.label} · ${windowLabel(f.measure.window)}` : null; - const label = - m && oldAuto && f.label === oldAuto - ? `${m.label} · ${windowLabel(window)}` - : f.label; - saveField({ ...f, label, measure: { ...f.measure, window } }); - }, - [fieldByKey, measures, saveField] - ); - - /* wave17 R1 — `selectCohort`, `renameCohort`, `deleteCohort` and `exportCohortData` are - gone. Every one of them was called ONLY by `CohortSidebar`, and every one now has a - better-governed twin: the Views rail renames and deletes a locked view through the - ordinary view path, and the HOST routes those to `cohort_mod.rename` / `.delete` on a - projected id (C-LOCKV guards b and c). One name, one lifecycle, one set of events. */ - - /** - * Cohort mode — the selection bar's "Remove from cohort" (contract event `cohort_remove`). - * The pids are intersected with the cohort's membership client-side as a courtesy; the host - * re-validates ownership and pool anyway (a pid from the browser is untrusted input). - */ - const removeSelectionFromCohort = useCallback(() => { - if (!activeCohortId) return; - const pids = [...selectedPids].filter((p) => cohortMemberSet.has(p)); - if (!pids.length) return; - emitHostEvent({ - id: eventId("cohortrm"), - type: "cohort_remove", - cohortId: activeCohortId, - pids, - }); - clearSelection(); - }, [activeCohortId, selectedPids, cohortMemberSet, clearSelection]); - - - /** - * ⭐ Wave-20 owner item 9 — **REMOVE FROM A COHORT WITHOUT BEING INSIDE IT.** - * - * "Remove from cohort" existed only under `cohortMode` (you had to open the locked view - * first), while "Add to cohort" worked from any view. So the two halves of the same idea - * lived on different screens: you could put a customer in a cohort from wherever you found - * them, and then had to go looking for the cohort to take them out again. - * - * SAME EVENT, same host validation as the cohort-mode button — `cohort_remove` with the pids - * intersected client-side as a courtesy. What is new is only WHICH cohort: the picker names - * it, instead of it being implied by the page you are standing on. - */ - const removeSelectionFromList = useCallback( - (cohortId: string) => { - const members = cohortSets[cohortId]; - if (!members) return; - const pids = [...selectedPids].filter((p) => members.has(p)); - if (!pids.length) return; - emitHostEvent({ id: eventId("cohortrm"), type: "cohort_remove", cohortId, pids }); - setSelRemoveOpen(false); - clearSelection(); - }, - [cohortSets, selectedPids, clearSelection] - ); - - /** - * The cohorts the checked rows can actually be removed FROM: those holding at least one of - * them, and only where this viewer may edit the projected view. - * - * ⚠ A cohort holding NONE of the checked rows is not offered. Airtable's rule and the one R8 - * states for the ghost row: an affordance that can only refuse is a fake affordance — and - * here it would be worse than that, because "Remove from Q3 plan" that removes nothing looks - * exactly like a write that failed. - * - * The edit test is the COURTESY half (`mayEditView`, like every other client-side permission - * check on this surface); the host re-validates ownership and pool on the event regardless. - * A cohort with no projected view is still offered — the host owns that verdict, and hiding - * it here would silently drop sets the reader can see in their own rail. - */ - const removableLists = useMemo(() => { - if (cohortMode || selectedPids.size === 0) return []; - const out: { id: string; name: string; hits: number }[] = []; - for (const l of lists) { - const members = cohortSets[l.id]; - if (!members) continue; - let hits = 0; - for (const pid of selectedPids) if (members.has(pid)) hits += 1; - if (!hits) continue; - const projected = views.find((v) => v.id === l.id); - if (projected && !mayEditView(projected, viewer)) continue; - out.push({ id: l.id, name: l.name, hits }); - } - return out; - }, [cohortMode, selectedPids, lists, cohortSets, views, viewer]); - - /** Cohort mode — confirm the "+ Add customers" picker (contract event `cohort_add`). */ - const addCustomersToCohort = useCallback(() => { - if (!activeCohortId || addCustPicked.size === 0) return; - emitHostEvent({ - id: eventId("cohortadd"), - type: "cohort_add", - cohortId: activeCohortId, - pids: [...addCustPicked], - }); - setAddCustPicked(new Set()); - setAddCustQuery(""); - setAddCustOpen(false); - }, [activeCohortId, addCustPicked]); - - /** - * The picker's candidates: the POOL minus the cohort's current members, narrowed by the - * search. The pool's own order is kept (it leads with the biggest customers, which is the - * useful default for "who am I adding"). - */ - const addCandidates = useMemo(() => { - if (!addCustOpen) return []; - const q = addCustQuery.trim().toLowerCase(); - const out: { pid: number; name: string }[] = []; - for (const r of rawRows) { - if (cohortMemberSet.has(r.pid)) continue; - const name = String(r[lockedKey] ?? ""); - if (q && !name.toLowerCase().includes(q)) continue; - out.push({ pid: r.pid, name }); - } - return out; - }, [addCustOpen, addCustQuery, rawRows, cohortMemberSet, lockedKey]); - - /** - * Wave-6 item 11c — PIN. "Pin up to this field" freezes every visible column through the - * clicked one; when the field sits past the clamp (8), it is REORDERED into the frozen - * prefix first (the contract's wording). Pinning the identity column reads as "just the - * identity" = the legacy 1, stored as ABSENT. - * - * ⚠ CORRECTED wave-14 item 15. This used to read "Grouped mode paints frozenCount 0 (spans - * break under freezing)". **Spans do not break under freezing** — glide splits them - * (`getSpanBounds` → `[frozenRect, contentRect]`) and draws the row's contents from the frozen - * half. What IS true, and is the real cost of the pin, is that those contents are then clipped - * to the frozen strip's width; `fitGroupLabel` is what makes that clip honest. The clamp is - * gone: `freezeColumns` is `frozenN` in every mode. - */ - const visibleKeys = useMemo( - () => order.filter((key) => visible.has(key)), - [order, visible] - ); - const frozenN = frozenCountOf(config); - const pinFieldTo = useCallback( - (key: string) => { - const index = visibleKeys.indexOf(key); - if (index < 0) return; - if (index < MAX_FROZEN) { - updateConfig({ ...config, frozenCount: clampFrozenCount(index + 1) }); - return; - } - const moved = visibleKeys.filter((k) => k !== key); - moved.splice(MAX_FROZEN - 1, 0, key); - let cursor = 0; - const nextOrder = order.map((k) => (visible.has(k) ? moved[cursor++] : k)); - updateConfig({ ...config, order: nextOrder, frozenCount: MAX_FROZEN }); - }, - [visibleKeys, order, visible, config, updateConfig] - ); - const unpinFields = useCallback( - () => updateConfig({ ...config, frozenCount: undefined }), - [config, updateConfig] - ); - - // Wave-6 item 10 — display-mode plumbing. W13 (contract C4 as AMENDED 2026-07-28): - // `config.display` is the ONE home for the field picks, so leaving a mode must NOT - // wipe them — returning to grid keeps the refs in the draft (mode 'grid' + refs; - // cleanDisplay's carry), which is what lets the next Kanban/Calendar restore the - // chosen field instead of feeding the select its default. A view that never picked - // stays byte-identical (ref-less grid normalizes to absent, the legacy rule). - const setDisplayMode = useCallback( - (m: DisplayMode) => { - // I12 (C3) — a locked view's MODE is frozen. The switcher is already hidden for one, - // so reaching here means something other than the switcher asked; refuse rather than - // trust the UI. (The host refuses too — this only stops the local state diverging from - // what the host will store, which would look like a working change that never saved.) - // (Resolved from `views` here rather than the `activeView` binding below — this - // callback is declared above it.) - const av = views.find((v) => v.id === activeViewId); - if (av && isModeFrozen(av)) return; - const spec = cleanDisplay(config.display); - if (m === "grid") { - updateConfig({ - ...config, - display: spec ? cleanDisplay({ ...spec, mode: "grid" }) : undefined, - }); - return; - } - updateConfig({ ...config, display: { ...(spec ?? {}), mode: m } }); - }, - [config, updateConfig, views, activeViewId] - ); - // C4 (amended): the pick is written INTO display and the selects are FED from the - // view def (`display.`), never from component state that dies on unmount. - const setDisplayField = useCallback( - (k: "dateField" | "stackField" | "colorField" | "sizeField", key: string) => { - const spec = cleanDisplay(config.display); - if (!spec) return; - // Wave-8 I3/I5: colour/size are OPTIONAL encodings, so "" is a real choice - // (none) and must delete the ref rather than store an empty key that no - // field can ever match. - const next = { ...spec, [k]: key }; - if (!key) delete (next as Record)[k]; - updateConfig({ ...config, display: next }); - }, - [config, updateConfig] - ); - /** - * Item 3 (C-DISP) — the kanban card clamp. - * - * ⚠ `undefined` DELETES the key rather than storing `true`. Absent means clamped, so a - * stored `true` would be the default wearing a second name — and the host's `_clean_display` - * accepts the literal `false` only, so a `true` would be dropped on save and the toggle - * would look like it does not persist. Delete-to-default keeps both ends agreeing. - */ - const setKanbanClamp = useCallback( - (next: false | undefined) => { - const spec = cleanDisplay(config.display); - if (!spec) return; - const display = { ...spec }; - if (next === false) display.kanbanClamp = false; - else delete (display as Record).kanbanClamp; - updateConfig({ ...config, display }); - }, - [config, updateConfig] - ); - /** - * ⭐ WAVE-27 item 8 (owner ruling R2, contract C3) — the swipe BINDING. - * - * ⚠ `undefined` DELETES the key, for `setKanbanClamp`'s reason one step further on. A swipe - * binding is a single three-part thing (field + two options); both engines' `_clean_display` - * DROP a half-binding rather than storing it, so writing one back would look like a save that - * silently did not persist. Absent IS the unconfigured state. - */ - const setSwipeSpec = useCallback( - (next: SwipeSpec | undefined) => { - const spec = cleanDisplay(config.display); - if (!spec) return; - const display = { ...spec }; - if (next) display.swipe = next; - else delete (display as Record).swipe; - updateConfig({ ...config, display }); - }, - [config, updateConfig] - ); - /** - * ⭐⭐ WAVE-29 C4 / T29 (owner R7) — the FORM spec's writer, shaped exactly like `setSwipeSpec` - * above it: `null` DELETES the key rather than storing an empty object, because "this view has - * no form" and "this view has a form with nothing in it" are different states and only one of - * them should survive a reload. `_clean_display` drops an empty bag anyway, so storing one - * would make clearing a form look like it did not save. - */ - const setFormSpec = useCallback( - (next: FormSpec | null) => { - const spec = cleanDisplay(config.display); - if (!spec) return; - const display = { ...spec }; - if (next) display.form = next; - else delete (display as Record).form; - updateConfig({ ...config, display }); - }, - [config, updateConfig] - ); - /** - * Item 4 (C-DISP) — the per-day totals. An EMPTY list deletes the key rather than storing - * `[]`: the always-on Records count is what an absent list means, so `[]` would be that - * state wearing a second name — and `_clean_display` drops an empty array anyway, which - * would make un-picking the last metric look like it did not save. - */ - const setCalendarMetrics = useCallback( - (next: { id: string; field: string; agg: string }[]) => { - const spec = cleanDisplay(config.display); - if (!spec) return; - const display = { ...spec }; - const capped = next.slice(0, MAX_CALENDAR_METRICS); - if (capped.length) display.calendarMetrics = capped; - else delete (display as Record).calendarMetrics; - updateConfig({ ...config, display }); - }, - [config, updateConfig] - ); - /** Item 4 (C-DISP) — records vs summaries. `records` is the default, so it deletes the key. */ - const setCalendarMode = useCallback( - (next: "records" | "summary") => { - const spec = cleanDisplay(config.display); - if (!spec) return; - const display = { ...spec }; - if (next === "summary") display.calendarMode = "summary"; - else delete (display as Record).calendarMode; - updateConfig({ ...config, display }); - }, - [config, updateConfig] - ); - // The calendar's date field: the view's pick when it is still a real date-family field, - // else `last_order`, else the first date-family field. The kanban's stack field: status or - // single-select ONLY (a multiselect card in two columns at once is a count lie — contract). - const dateFieldChoices = useMemo( - () => fields.filter((f) => isDateFamilyType(f.type)), - [fields] - ); - const stackFieldChoices = useMemo( - () => fields.filter((f) => f.type === "status" || f.type === "select"), - [fields] - ); - const pickBy = (want: string | undefined, choices: Field[], preferred?: string): Field | undefined => { - const wanted = want ? choices.find((f) => f.key === want) : undefined; - if (wanted) return wanted; - const pref = preferred ? choices.find((f) => f.key === preferred) : undefined; - return pref ?? choices[0]; - }; - // W13 (C4 as amended): the VIEW DEF's display feeds the pickers — the refs survive - // every mode switch via cleanDisplay's grid carry. pickBy already falls back to the - // default when the stored key no longer names an offerable field (a dropped ref). - // I3/I5 — the map's encodings. Deliberately NOT resolved through `pickBy`: - // that helper falls back to the first choice, which is right for the calendar - // (a calendar must have a date) and wrong here, where "no encoding" is a - // legitimate, and the DEFAULT, state. An unknown stored key resolves to - // undefined = no encoding, which is also the dropped-ref behaviour. - // I19c (C2) — the chart list lives in `config.display.charts`, so it rides the - // existing view autosave + viewEcho reconcile and needs no new event type. - const charts = useMemo( - () => cleanCharts(displaySpec?.charts) ?? [], - [displaySpec?.charts] - ); - const setCharts = useCallback( - (next: ChartSpec[]) => { - const spec = cleanDisplay(config.display); - if (!spec) return; - const display = { ...spec, charts: next }; - if (!next.length) delete (display as Record).charts; - updateConfig({ ...config, display }); - }, - [config, updateConfig] - ); - const colorFieldChoices = useMemo( - () => fields.filter((f) => f.type === "status" || f.type === "select"), - [fields] - ); - const sizeFieldChoices = useMemo( - () => fields.filter((f) => isNumericFieldType(f.type)), - [fields] - ); - const mapColorField = displaySpec?.colorField - ? colorFieldChoices.find((f) => f.key === displaySpec.colorField) - : undefined; - const mapSizeField = displaySpec?.sizeField - ? sizeFieldChoices.find((f) => f.key === displaySpec.sizeField) - : undefined; - const calendarField = pickBy(displaySpec?.dateField, dateFieldChoices, "last_order"); - const kanbanField = pickBy(displaySpec?.stackField, stackFieldChoices); - /* ⭐ WAVE-27 item 8 (C3) — the BOUND field, resolved by key and NOT through `pickBy`. `pickBy` - falls back to the first choice when the stored key is unknown, which is right for a kanban - (a stack field is a preference) and wrong here: SwipeView SHOWS a rotted binding rather than - guessing at one, so it must receive the loss instead of a silent substitute — the wave-7 trap - [[wrong-parent-not-broken-control]] and the component's own header note both name. */ - const swipeField = displaySpec?.swipe - ? fieldByKey.get(displaySpec.swipe.fieldKey) - : undefined; - // Calendar/kanban project the FULL pipeline result (a month/stack is its own bound), as - // DISTINCT data rows — grouping can repeat a pid — with overlay edits layered so a dragged - // card restacks and an edited title repaints without waiting on any echo (item 3c). - const modeDataRows = useMemo(() => { - if ( - displayMode !== "calendar" && - displayMode !== "kanban" && - displayMode !== "map" && - displayMode !== "chart" && - // C-TS close-out stitch: the panel's pids come from these rows; without this line the - // time-series view asked its question about ZERO customers (caught by the mounted - // judge — the node battery is blind here). - displayMode !== "timeseries" && - /* ═══ W18-C CATALOG ═══ The designer's "add the products this view shows" door reads - these rows — the same rows the toolbar counts, so the two can never disagree about - which products the filter kept. The catalog's PAINT does not: it joins its stored - codes against the whole pool, so a filter narrows what you can ADD and never what a - finished page prints. ═══ end W18-C CATALOG ═══ */ - displayMode !== "catalog" && - /* ⭐ WAVE-27 item 8 (C3) — the swipe deck is these rows filtered to the undecided ones. - Without this line the deck is derived from `[]`, so the view paints its "nothing left - to decide" empty state over a table full of undecided records — the C-TS stitch above, - repeated. */ - displayMode !== "swipe" - ) - return []; - const seen = new Set(); - const out: Row[] = []; - for (const vr of visibleRows) { - if (vr.kind !== "data" || seen.has(vr.record.pid)) continue; - seen.add(vr.record.pid); - const edits = overlayEdits[vr.record.pid]; - out.push(edits ? { ...vr.record, ...edits } : vr.record); - } - return out; - }, [displayMode, visibleRows, overlayEdits]); - // Owner item 1 — the CALL-SITE half of the kanban memo. `KanbanView` is `memo`'d now, but a - // memo whose props are rebuilt every render never bails, and these two were: `cardKeys` is a - // fresh array from `.filter().slice()`, `onMove` a fresh closure. With them stable, a - // `detailPid` change (clicking a card) re-renders the modal and skips the board entirely — - // which is the whole of "laggy record-open". Everything else KanbanView takes was already - // stable: `modeDataRows`/`fieldByKey` are useMemos, `kanbanField` is a reference INTO the - // `stackFieldChoices` useMemo, and `onOpen={setDetailPid}` is a setState identity. - const kanbanCardKeys = useMemo( - () => - kanbanField - ? visibleKeys.filter((k) => k !== lockedKey && k !== kanbanField.key).slice(0, 3) - : [], - [visibleKeys, lockedKey, kanbanField] - ); - const onKanbanMove = useCallback( - (pid: number, value: string) => { - if (!kanbanField) return; - patchAndRecord(pid, { [kanbanField.key]: value }, "a card move"); - }, - [kanbanField, patchAndRecord] - ); - /* ⭐ WAVE-27 item 8 (C3) — a swipe writes through the NORMAL cell door, the same - `patchAndRecord` a card move uses. That is the whole reason the deck needs no permission - logic, no undo entry and no echo handling of its own: it inherits all three. */ - const onSwipeDecide = useCallback( - (pid: number, value: string) => { - if (!swipeField) return; - patchAndRecord(pid, { [swipeField.key]: value }, "a swipe"); - }, - [swipeField, patchAndRecord] - ); - /** - * ⭐ WAVE-26 ITEM 12 (owner ruling R8) — the kanban's "Add option" door. - * - * It opens the ORDINARY ColumnMenu on the stack field, straight into its Edit-field pane. So - * the option is written into the SHARED field definition by the same `onRetype` upsert the - * header menu uses — never a per-view list — and every guarantee that editor already carries - * (permission wall, rename-by-row-identity, option colours, one upsert for name+choices) comes - * along without being re-implemented. R8's "do not invent a second options editor", literally. - * - * ⛔ THE PERMISSION QUESTION IS NOT `canEditField`. Adding an option changes the column's - * DEFINITION; `mayEditField` answers who may type a VALUE into it, and conflating the two is - * the confusion [[schema-role-is-not-a-value-wall]] exists to prevent. The right predicate is - * the one that decides whether `onRetype` is supplied at all — a created overlay field — so it - * is written ONCE here and read at both doors. - * - * `null` (not "absent") when the field cannot take one: a status column is computed by the - * source system, and an Odoo select's vocabulary lives in Odoo. - * - * ⭐ THE PREDICATE ADMITS AN AUTOMATION'S `stage_` COLUMN, and that is INTENDED rather than - * incidental — worth stating because it was arrived at by matching `onRetype`'s condition, and - * a coincidence and a decision look identical in code. It is exactly what R6 and R8 compose - * to: R6 deleted the board's built-in terminals, so the lanes a review stage offers are now - * only the ones a user defined — and R8 is the control that lets them define one. Landed - * without this the pair is worse than either alone (SESSION A's A-7 says the same thing from - * the engine side: `humanMoves` had to learn to admit user-added options, or the move door - * would refuse the lane the product just invited you to create). - * ⚠ CARRIED CONSEQUENCE, pre-existing and not introduced here: the pane this opens is the - * whole Edit-field window, so it also offers the TYPE picker — one wrong click retypes a stage - * column. The header menu has offered exactly that on the same column since wave 6; this door - * adds a second way in, not a new hazard. Written down rather than left to be rediscovered. - */ - /* ⭐ WAVE-29 T27: the same stratum predicate as the Edit-field pane, for the same reason — this - door OPENS that pane, so gating the two differently means offering an add-option control that - leads to a pane with no editor in it. On a `ut_*` kanban (every automation board) the old - `custom` test was false for every column, so the lane header offered nothing. */ - const kanbanCanAddOption = - !!kanbanField && kanbanField.type === "select" && - isUserSchemaField(kanbanField, isUserTable); - const onKanbanAddOption = useCallback( - (anchor: AnchorRect) => { - if (!kanbanField) return; - setColumnMenu({ fieldKey: kanbanField.key, anchor, pane: "edit" }); - }, - [kanbanField] - ); - const onListToggleGroup = useCallback((groupKey: string) => { - setCollapsed((current) => { - const next = new Set(current); - if (next.has(groupKey)) next.delete(groupKey); - else next.add(groupKey); - return next; - }); - }, []); - /** - * Item 7 (C-TS) — the pid set the time series asks about: exactly the rows the current - * filter kept, so the panel and the toolbar count can never disagree about "who". - * - * `modeDataRows` is already the de-duplicated data rows for a non-grid mode, so this is a - * projection of it and NOT a second pipeline. The server intersects with the caller's book - * anyway — this narrows the question, it can never widen the answer. - */ - const timeseriesPids = useMemo( - () => (displayMode === "timeseries" ? modeDataRows.map((r) => r.pid) : []), - [displayMode, modeDataRows] - ); - /* ═══ W18-C CATALOG ═══ (owner item 4, contract C6) - The catalogs this view holds, and the door that persists an edit to them. - - `displaySpec.catalogs` is ALREADY validated — `cleanDisplay` ran `cleanCatalogs` over it on - read — so there is no second normalisation here and no chance of the two disagreeing. - - ⚠ The write goes through `updateConfig` exactly like `onTimeseriesDisplay` below, which - means a catalog edit is a VIEW-CONFIG save. That is the point: a catalogue is authored - content and has to survive a reload, and the display spec is the only per-view store the - grid has. It also means the 500-code / 40-page / 12-catalog caps are enforced twice on the - round trip (here on read, and by the host's `_clean_catalogs` on write) — the designer - states its budget in the toolbar so the cap is never the user's first news of it. */ - const catalogs = useMemo( - () => (displayMode === "catalog" ? (displaySpec?.catalogs ?? []) : []), - [displayMode, displaySpec] - ); - /** The codes the current filter kept — the designer's "add what the view shows" shortcut. - * Derived from `modeDataRows`, so it counts what the toolbar counts. */ - const catalogFilteredCodes = useMemo( - () => - displayMode === "catalog" - ? modeDataRows - .map((r) => r[CATALOG_CODE_FIELD]) - .filter((c): c is string => typeof c === "string" && !!c) - : [], - [displayMode, modeDataRows] - ); - const onCatalogs = useCallback( - (next: CatalogSpec[]) => { - const spec = cleanDisplay(config.display) ?? { mode: "catalog" as const }; - updateConfig({ ...config, display: { ...spec, catalogs: next } }); - }, - [config, updateConfig] - ); - /* ═══ end W18-C CATALOG ═══ */ - /** Persist a bucket/span/metric pick onto the view, the way every other display key rides. */ - const onTimeseriesDisplay = useCallback( - (next: Partial) => { - const spec = cleanDisplay(config.display) ?? { mode: "timeseries" as const }; - updateConfig({ ...config, display: { ...spec, ...next } }); - }, - [config, updateConfig] - ); - const kanbanCanMove = - !!kanbanField && kanbanField.type === "select" && canEditField(kanbanField); - const kanbanReason = !kanbanField - ? null - : kanbanField.type === "status" - ? `Stacked by ${kanbanField.label}. That field is read-only, computed from the source system.` - : !canEditField(kanbanField) - ? "You do not have permission to edit this field, so cards cannot be moved." - : null; - /* ⭐ WAVE-27 item 8 (C3) — the kanban pair, said again for the deck. Same wall, same words - shaped for the gesture: a swipe writes a VALUE, so `canEditField` is the right predicate. */ - const swipeCanWrite = !!swipeField && swipeField.type === "select" && canEditField(swipeField); - const swipeReason = !swipeField - ? null - : swipeField.type === "status" - ? `Bound to ${swipeField.label}. That field is read-only, computed from the source system.` - : !canEditField(swipeField) - ? "You do not have permission to edit this field, so records cannot be decided." - : null; - - if (loading || !workspaceReady) { - /* wave17 GRID — item 3 / owner R6: one small bare rotating icon, NO WORDS. `aria-label` - is not a word on screen and stays: `.lp-spin` is an empty span, so without it the wait - is announced to a screen reader as nothing at all. (C-SPIN, SHELL defines the class.) - `--lg` because this is the case C-SPIN sizes it for — the whole surface is this mark and - nothing else, where the 14px version "reads as dust". */ - return ( -
- -
- ); - } - - const activeView = views.find((view) => view.id === activeViewId); - // Cohort mode's toolbar control (rendered by Toolbar via the `cohortAction` slot; the popover - // it opens is with the other overlays at the bottom). Disabled without an active cohort — - // zero cohorts is the host page's near-empty state, not this button's error to explain. - const cohortAction = cohortMode ? ( - - ) : undefined; - // Wave-7 item W9 — the Fields panel's permanent delete rides the SAME wall as the - // column menu's Delete: created strata only (`custom`), never the locked identity - // column. One predicate, two doors. - // - // ⚠ WAVE-29 T22 NARROWED WHAT THIS SET CONTAINS, and the narrowing is CORRECT rather than an - // oversight — said out loud because it looks like a regression. A grid-created column on a - // `ut_*` database now lives in the shared DEFINITION and carries no `custom` flag, so it drops - // out of this set. It has to: this door emits `field_delete`, which scrubs the per-user - // workspace overlay — a bucket the definition never reads — so the column would vanish for one - // paint and be back on the next fetch (`deleteDefinitionField`'s note says the same thing from - // the other side). ⛔ The consequence is real and is NOT silently widened here: a definition - // column can only be deleted through the column menu, which offers it for `link`/`rollup`/ - // `formula` alone (D-114's deliberate narrowness — every other kind holds real values). Booked - // as pending work rather than fixed by handing a type-blind delete to a panel. - const deletableKeys = new Set( - fields.filter((f) => f.custom && f.key !== lockedKey).map((f) => f.key) - ); - const menuField = columnMenu ? fieldByKey.get(columnMenu.fieldKey) : undefined; - // Wave-5 item 3 — what the CURRENT VIEW does with the menu's field, so the conditional - // "Don't sort/filter/group" entries render exactly when they apply. - const menuSortedDir = menuField - ? config.sorts.find((s) => s.colId === menuField.key)?.dir ?? null - : null; - const menuIsFiltered = menuField - ? treeNamesField(config.filters, menuField.key, measureCols) - : false; - // Item 11c — is the menu's field the current frozen boundary (its Pin would be a no-op)? - const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1; - const menuPinnedTo = menuVisIndex >= 0 && frozenN > 1 && menuVisIndex + 1 === frozenN; - // Item 10 — the toolbar's mode switcher (grid-only on windowed tables, see displayMode). - const modeControl = serverWindowed || embedded || isQueryPreview ? undefined : ( - - ); - // ⭐ Wave-23 C7 — the open document, resolved exactly like the picker's value below it: - // the OPTIMISTIC edit wins over the raw record, so a save the server has not echoed yet is - // what re-opening the cell shows (the echo-suppression law — reading the raw row here would - // make a just-saved document appear to revert). - const jsonField = jsonAt ? fieldByKey.get(jsonAt.fieldKey) : undefined; - const jsonRow = jsonAt ? rawRows.find((r) => r.pid === jsonAt.pid) : undefined; - const jsonValue = String( - (jsonAt && overlayEdits[jsonAt.pid]?.[jsonAt.fieldKey]) - ?? (jsonField && jsonRow ? jsonRow[jsonField.key] : "") - ?? "" - ); - const linkField = linkAt ? fieldByKey.get(linkAt.fieldKey) : undefined; - const linkRow = linkAt ? rawRows.find((row) => row.pid === linkAt.pid) : undefined; - const linkValue = String( - (linkAt && overlayEdits[linkAt.pid]?.[linkAt.fieldKey]) - ?? (linkField && linkRow ? linkRow[linkField.key] : "") - ?? "" - ); - const linkedRecordIds = [...new Set( - linkValue.split(",").map((part) => Number(part.trim())).filter( - (pid) => Number.isInteger(pid) && pid > 0 - ) - )]; - const pickerField = picker ? fieldByKey.get(picker.fieldKey) : undefined; - // A `user` field's choices come from the HOST's real user list, a `select`'s from its own - // definition — so an assignee is always someone who can log in, and a status is always one of - // the choices the column was created with. - const pickerChoices = !pickerField - ? [] - : pickerField.type === "user" - ? payload?.userOptions ?? [] - : choiceOptions(pickerField); - const pickerRow = picker ? rawRows.find((r) => r.pid === picker.pid) : undefined; - const pickerValue = String( - (picker && overlayEdits[picker.pid]?.[picker.fieldKey]) - ?? (pickerField && pickerRow ? pickerRow[pickerField.key] : "") - ?? "" - ); - // A multiselect cell is a SET: choices TOGGLE and the picker stays open for the next pick - // (item 5). Single select/user keep pick-and-close. - const pickerMulti = pickerField?.type === "multiselect"; - const pickerParts = pickerMulti ? splitMulti(pickerValue) : []; - /** Item 6 — which choice receives focus when the picker opens: the current value where it - * still exists in the list, else the first choice. Keyboard-only editing starts HERE. */ - const pickerFocusChoice = !pickerChoices.length - ? null - : pickerMulti - ? pickerParts.find((p) => pickerChoices.includes(p)) ?? pickerChoices[0] - : pickerChoices.includes(pickerValue) - ? pickerValue - : pickerChoices[0]; - - return ( - // `data-today` is the TENANT'S day, exactly as the payload delivered it. It is here so the - // date every relative condition resolves against is OBSERVABLE rather than inferred: a live - // QA that reads its own clock instead compares two engines on "now" and fails whenever a run - // straddles midnight — which is the flake harness/windows.py takes `today` as a parameter to - // avoid, reintroduced one layer up. It cost a false failure (57 vs 55, both correct, one - // computed either side of a date change) to notice. - // `data-measure-rules` / `data-measure-sets` are BUG-1's handshake, published the same way - // and for the same reason as `data-today`: a measure condition stuck on "Calculating…" - // renders identically whether the rule carries no id, the host resolved nothing, or the two - // sides name the rule differently — and only the first of those is ours. A console.log - // cannot serve this: the grid runs in an iframe that is CROSS-ORIGIN on the Space, and every - // Streamlit rerun replaces it. An attribute survives the remount and is one `get_attribute` - // away from any harness, local or deployed. -
- {/* ⭐ wave17 R1 / C-LOCKV — the COHORT SIDEBAR is gone, and with it the last surface that - treated a cohort as its own kind of object. `CohortSidebar` was the retired `#/cohort` - page's left panel: a second rail, with its own rename, delete, export and folder - machinery, over `workspace.lists`. Under R1 a cohort IS a saved view, so the Views rail - below renders every one of them and the second rail has nothing to switch between. - ⚠ `lists` is NOT gone with it — it stays the membership channel that feeds `cohortSets` - (C-LOCKV point 2), which is what the lock resolves against. */} - {/* ⛔ THE QUERY PROVENANCE BLOCK WAS HERE AND MOVED TO `QueryPage` (owner item 3, 2026-08-15). - It was rendered as the FIRST CHILD of `.cg-shell`, which is a horizontal flex row - (index.css:233) holding the views rail and `.cg-main` — and it had no stylesheet rule of - its own anywhere in the tree. So R9's citations painted as an unstyled column of raw - `JSON.stringify` beside the grid, shoving the table sideways: the provenance requirement - met, and the surface it was on made unreadable. Chrome belongs to the page that owns the - artefact; the grid draws the artefact. `queryCitationLabel` still formats it, from - `QueryPage`. */} - {!hideViews && ( -
- ); -} - -export default memo(CustomerGrid); +import { + memo, + useCallback, + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, +} from "react"; +import { DataEditor, GridCellKind } from "@glideapps/glide-data-grid"; +import type { + CellClickedEventArgs, + DataEditorRef, + EditableGridCell, + GridKeyEventArgs, + GridMouseEventArgs, + GridSelection, + DrawHeaderCallback, + HeaderClickedEventArgs, + Item, + Rectangle, + Theme, +} from "@glideapps/glide-data-grid"; +import "@glideapps/glide-data-grid/dist/index.css"; + +import { useCustomerData } from "./useCustomerData"; +import type { SurfaceScope } from "./apiBridge"; +import { mutateQueryWorkspace, QUERY_BINDING_EVENT, refuseQueryMutation } from "../query/queryApi"; +import type { QueryVirtualBinding } from "../query/queryApi"; +import { acceptsQueryPreview, routeQueryViewMutation } from "./queryPreview"; +// ⭐ WAVE 30 · W30-T42 (contract C2) — the WINDOWED grid's arithmetic, pure and node-run in +// `verify_grid_ux.py`, because every one of these decisions is taken inside a callback where +// only its own source text could otherwise be checked. +import { WINDOW_ROWS } from "./apiBridge"; +import { + EMPTY_WINDOW_PREDICATE, limitSummary, lockedRecordsNote, nextWindowOffset, + windowedCapabilityNote, windowedFoldNote, windowPredicateKey, +} from "./counts"; +// ⭐ W35-T29/T30 (C5) — the record star's client, and its own stylesheet imported HERE, by the +// module that renders its classes. W35-T22 in this same wave was the cost of doing otherwise. +import { useRecordStars } from "./recordStars"; +// ⚠ ALIASED, and the collision is worth naming: `./Stars`'s `StarIcon` is the RATING atom (a +// 5-point star for a 1..5 field) and this one is the record MARK. Two different meanings, one +// noun, already both in this file — importing the second under its own name would have shadowed +// the first and silently repainted every rating picker. +import { StarIcon as RecordStarMark } from "../ui/icons"; +import "./rowStar.css"; +// ⭐ W36-T04 (owner item 6) — the code-script View's sheet, carried by the MOUNT rather +// than by `viewModes.tsx` where the component lives: that module is loaded under node by +// `verify_grid_ux.py`, and a side-effect CSS import survives into the emitted CommonJS. +import "./scriptView.css"; +import { ScriptView } from "./ScriptViewPanel"; +/* ⭐⭐ W37-T29 / CONTRACT C6 — E's chat panel. ⛔ THE DEFAULT EXPORT, NEVER `GridChatView`: + E-6 says the named one is the presentational half, split out so a gate can render its in-flight + and error states, and mounting it directly gives a panel that holds no conversation. */ +/* ⛔⛔ THE EXPLICIT `.tsx` IS LOAD-BEARING, NOT STYLE. `GridChat.tsx` (the component) and + `gridChat.ts` (its React-free state module) differ ONLY IN CASE, so on a case-insensitive + filesystem — Windows and macOS, i.e. every machine this repo is developed on — a bare + `./GridChat` resolves to `gridChat.ts` and `tsc` reports TS1261 plus "has no default export". + `allowImportingTsExtensions` is on in `tsconfig.app.json`, so naming the extension is what makes + the resolution unambiguous. Reported to E as `NOTE C-17`. */ +import "./chatDock.css"; +import GridChat from "./GridChat.tsx"; +import type { GridChatAnswer, GridChatEdit, GridChatField, GridChatRunResult, + GridChatViewSpec } from "./GridChat.tsx"; +import { ViewAgentPanel, seedName, seedScript } from "./ViewAgentPanel"; +import { + createScriptView, listScriptViews, readScriptView, runScriptView, saveScriptView, +} from "./scriptViews"; +import type { ScriptRun, ScriptView as ScriptViewRecord, ScriptViewRow } from "./scriptViews"; +import { defaultViewConfig, useGridColumns } from "./useGridColumns"; +import { activeMeasureRuleIds, pendingMeasures, runPipeline, sliceForDisplay, + unresolvedConditions, useVisibleRows } from "./useVisibleRows"; +import { computeAggs } from "./aggregations"; +import type { CohortSets } from "./useVisibleRows"; +import type { MeasureSets } from "./useVisibleRows"; +import { useGetCellContent } from "./useGetCellContent"; +import { useGridSelection } from "./useGridSelection"; +import Toolbar from "./Toolbar"; +import JsonViewer from "./JsonViewer"; +import RecordDetail from "./RecordDetail"; +import ViewSidebar from "./ViewSidebar"; +import ColumnMenu from "./ColumnMenu"; +import type { ColumnMenuState } from "./ColumnMenu"; +import { HEADER_ICONS } from "./iconShapes"; +import { emitHostEvent, eventId } from "./hostBridge"; +// ⭐ WAVE 27 item 21 (R14) — which of this table's views carry an alert. The SERVER's +// list is the only answer; the rail's alert door is a one-way create. +import { fetchAlerts } from "../alerts/alertsApi"; +import { NAV_MINIMIZE_EVENT, ROWS_STALE_EVENT, TOAST_EVENT, VIEW_OPEN_EVENT, + WORKSPACE_STALE_EVENT, signal } + from "../apiContract"; +import type { ViewOpenDetail } from "../apiContract"; +import { addTableField, addTableRow, deleteTableField, deleteTableRow, fetchLinkTargets, + fetchRollupSources, patchTableField, enrichField } from "./apiBridge"; +import type { LinkTarget, RollupSourceOffer } from "./apiBridge"; +import SelectFromFile from "./SelectFromFile"; +import ImportDialog from "./ImportDialog"; +import { FormInterface } from "./FormInterface"; +import type { FormSpec } from "./FormInterface"; +// Owner item 16 / R4 / C-UNDO — the stack and every inverse. Pure, so a node gate can run it. +import { describe, directed, popRedo, popUndo, pushUndo, stackFor } from "./undoStack"; +import type { CellChange, UndoBook, UndoEntry, UndoRow, UndoValue } from "./undoStack"; +import { exportFilename, runExport, triggerDownload } from "./export"; +import type { ExportFormat } from "./export"; +// owner item 3 (2026-08-03) — a time-series view exports its SHEET, not the rows under it. +import { buildTsCsv, tsSheetToTable } from "./timeSeriesData"; +import type { TsSheet } from "./timeSeriesData"; +import { echoReemit, reconcileEchoView, resolveActiveView } from "./viewEcho"; +import { pruneStamps, reconcileFields } from "./optimism"; +import { applyViewOrder, isSyntheticFolderId, newFolderId, pruneFolderStamps, reconcileFolders, + resolveFolderId } from "./folders"; +import type { FolderStamps } from "./folders"; +// ⭐ WAVE 27 item 22 — the cells the active view's filter forces on a record added under it. +import { filterSeedValues } from "./filterSeed"; +import { adoptNewFields, adoptNewViews, pruneTombstones, reconcileHeldFields, + reconcileHeldViews, seedLocalViews, stampTombstone } + from "./liveWorkspace"; +import type { Tombstones } from "./liveWorkspace"; +import type { GridFolder, ViewPermissions } from "./types"; +import type { FieldStamps } from "./optimism"; +import { evalFormula, orderFormulas, parseFormula } from "./formulaEngine"; +import type { FormulaAst } from "./formulaEngine"; +import { CalendarView, KanbanView, ListView, ModeSwitch } from "./viewModes"; +import { SwipeView } from "./SwipeView"; +import type { SwipeSpec } from "./SwipeView"; +// The chart engine lives in `viz/` since EXIT wave 2 (W2-5/Y3) — the grid is now +// one of its two callers, the Y1 page envelope being the other. +import { DashboardView } from "../viz/DashboardView"; +import { cleanCharts } from "../viz/chartData"; +import type { ChartSpec } from "../viz/chartData"; +import { MapView } from "./MapView"; +/* ═══ W18-C CATALOG ═══ (owner item 4, contract C6) */ +import { CatalogView } from "./CatalogView"; +import { CATALOG_CODE_FIELD } from "./catalogData"; +import type { CatalogSpec } from "./types"; +/* ═══ end W18-C CATALOG ═══ */ +import { lightTheme, STATUS_ROW_THEME, HOVER_ROW_THEME, HOVER_NEUTRAL, + ACTIVE_ROW_NEUTRAL, CUSTOM_FIELD_MARK } from "./theme"; +import { avatarInitials, formatDisplay, imageCellRenderer, ratingCellRenderer, setAvatarRepaint, + userCellRenderer } from "./cells"; +import { optionTint, pickTint } from "./choiceColors"; +import { + EMPTY_GRID_COPY_PROVENANCE, + markGridCopy, + observeCopyEvent, + planFieldPaste, + pasteRowCount, +} from "./clipboard"; +import { cellTipText, expandButtonRect, starButtonRect, + GROUP_HEADER_FONT, GROUP_LABEL_PAD, headerMarkLayout, + headerMarkSizes, tipLeft } from "./overlayPlacement"; +import { AnchoredOverlay, BodyPortal, useOverlayLayer } from "./OverlaySurface"; +import type { AnchorRect } from "./OverlaySurface"; +import { StarIcon } from "./Stars"; +import { ALL_VIEW_ID, MAX_CALENDAR_METRICS, allViewName, + MAX_FROZEN, choiceOptions, choiceVocabulary, clampFrozenCount, cleanDisplay, formulaOf, + topicForScope, + isDateFamilyType, isFilterGroup, isGroupableField, isMachineOwned, + isDerivedLink, isUserSchemaField, TOTAL_GROUP_KEY, + isNumericFieldType, + machineFoundRows, reFindConsequences, + isPickType, mayEditField, + isModeFrozen, isUndeletableView, mayEditView, mayToggleViewLock, + measureColumnIndex, ratingMax, ruleColumnKeys, + tableMode, uniqueDisplayName, + /* ⭐ W37-T26 (C4) — the view-membership leaf's two pure helpers. `viewRefsOf` is the ONE walk + the resolver, the cycle detector and the gate share; `viewFilterCycle` is the refusal. */ + viewFilterCycle, viewRefsOf } from "./types"; +import type { + DisplayMode, + DisplaySpec, + Field, + HostEvent, + FieldScope, + FieldType, + FilterNode, + FilterRule, + Row, + RowHeightMode, + SavedView, + ViewConfig, +} from "./types"; +// Item 7 (C-TS). Default-exported because it is a leaf VIEW like MapView, and because RECORD +// imports the same default for the record-detail Insights tab (C-EMBED). +import TimeSeriesPanel from "./TimeSeriesPanel"; +import type { WindowSpec } from "./windows"; +import { windowLabel } from "./windows"; + +const ROW_PX: Record = { short: 28, medium: 34, tall: 48 }; + +/** + * Wave-14 R7 — **a button label is ONE LINE.** Every button row in this file is a flex row with + * no width reservation, so on a narrow grid box the items shrink and their labels wrap: + * "Remove from cohort" becomes two lines, the row grows, and the bar stops being a bar. It only + * happens below a width no fixed-size screenshot is taken at, which is why it survived. + * + * ⚠ Applied INLINE rather than on `.cg-btn`, where it belongs: `index.css` is PANEL's fence this + * wave. A mailbox line asks them to hoist it onto `.cg-btn` globally — after which this is + * redundant, and harmless, because it says exactly the same thing. + */ +const ONE_LINE = { whiteSpace: "nowrap" } as const; + +/** + * How many VISIBLE columns glide freezes. Module-level and used twice on purpose (item 15): the + * `freezeColumns` prop and the group bar's label fit must read ONE answer, because the label is + * clipped to exactly this strip and a second copy of the clamp would let the two disagree by a + * column — which shows up as a label cut early for no visible reason. + */ +function frozenCountOf(config: ViewConfig): number { + return Math.min(MAX_FROZEN, Math.max(1, config.frozenCount ?? 1)); +} +/* WAVE 21 item 3 (R6): the id and the NAME both moved to `types.ts` — the id because a second + copy of a pinned literal is the drift class this repo gates against, the name because it is + now topic-derived and the host mints the same string. */ + +/** + * owner item 2 (2026-08-03) — the measure-cell skeleton's pulse, and its ceiling. + * + * 140ms against the 4-step colour ramp is a ~0.6s cycle: a wait, not a strobe. The ceiling is + * ~60s, after which the cells fall back to ordinary blanks — a measure that has not resolved in + * a minute is not "still loading", and a shimmer that never ends promises a number that is not + * coming. Shared constant so the two are read in one place rather than tuned apart. + */ +const PULSE_MS = 140; +const PULSE_MAX_TICKS = Math.round(60_000 / PULSE_MS); +/** Referentially stable, so the fallback does not change `getCellContent`'s identity per render + * and repaint the canvas forever. */ +const NO_PENDING_KEYS: ReadonlySet = new Set(); + +/** + * I2 — measure a cell's text in GLIDE'S OWN font, so "is this cut off?" is a fact rather than + * a character-count guess (a guess is wrong in both directions: "IIIII" is narrow, "WWWWW" is + * wide, and being wrong means either a missing tip or a tip over text you can already read). + * One lazily-built offscreen context for the whole module — `measureText` is cheap, but + * creating a canvas per mouse-move would not be. + */ +let _tipCtx: CanvasRenderingContext2D | null | undefined; +function measureCellText(text: string): number { + if (_tipCtx === undefined) { + _tipCtx = document.createElement("canvas").getContext("2d"); + // glide's default `baseFontStyle` is 13px; the family comes from our own theme. + if (_tipCtx) _tipCtx.font = `13px ${lightTheme.fontFamily ?? "Inter, sans-serif"}`; + } + return _tipCtx ? _tipCtx.measureText(text).width : 0; +} +/** + * Item 15 — the GROUP BAR's measurer, and it is a SECOND context on purpose. + * + * `measureCellText` above is set to plain "13px", which is right for ordinary cells and WRONG for + * a group bar: that row carries `baseFontStyle: GROUP_HEADER_FONT` (semibold), and glide paints a + * cell with the merged theme's font. Measuring the fit with the lighter weight under-truncates + * and the label overflows into the frozen strip's hard clip — the exact failure `fitGroupLabel` + * exists to prevent. Font string built from the SAME constant the cell's themeOverride uses, so + * the two cannot drift. + */ +let _groupCtx: CanvasRenderingContext2D | null | undefined; +function measureGroupText(text: string): number { + if (_groupCtx === undefined) { + _groupCtx = document.createElement("canvas").getContext("2d"); + if (_groupCtx) + _groupCtx.font = `${GROUP_HEADER_FONT} ${lightTheme.fontFamily ?? "Inter, sans-serif"}`; + } + return _groupCtx ? _groupCtx.measureText(text).width : 0; +} +/** + * Owner item 8 (2026-07-27): the first render shows this many rows; "See more" reveals the rest. + * A DISPLAY cap only — the pipeline still runs over the whole book, the toolbar count is still + * the full matched count, and the cap is stated beside the control that lifts it, which is what + * keeps [[no-unverifiable-aggregates]] satisfied: nothing is silently truncated. + */ +const DISPLAY_PAGE = 50; +const DISPLAY_STEP = 250; + +/* ═══════════════════════════════════════════════════════════════════════════════════════════ + ═══ W18-B VOID ═══ (wave 18, owner item 1b) — the two constants the void geometry needs + that belong to the BROWSER and to GLIDE rather than to us. Both are mirrors, and both say + here what they are mirroring, because a mirror that does not name its original is how the + two copies stop agreeing. + ═══════════════════════════════════════════════════════════════════════════════════════════ */ + +/** The header band. `headerHeight={36}` at the DataEditor mount is the original. + * Not `+ groupHeaderHeight`: glide adds that only when `enableGroups` is on, which it turns on + * when a COLUMN carries a `group` — `useGridColumns` never sets one (scrolling-data-grid.js:13). */ +const HEADER_PX = 36; + +/** glide's own row-marker width ladder, `data-editor.js:103`, verbatim. + * + * ⚠ It is passed BACK to glide as `rowMarkerWidth` rather than merely predicted here. The + * marker column is part of the content width the void's left edge is measured from, so a + * four-pixel disagreement between what glide draws and what we compute is a four-pixel seam of + * white against the tint — visible, and invisible to every gate. Pinning makes the two the same + * number by construction; mirroring the ladder (rather than pinning one constant) is what keeps + * the pin from changing the marker column's width on tables of more than 100 rows. */ +function rowMarkerPx(rows: number): number { + return rows > 10_000 ? 48 : rows > 1000 ? 44 : rows > 100 ? 36 : 32; +} + +/** The scrollbar gutter this browser steals from a scrollable box, measured once. + * + * ⛔ WHY THE VOID HAS TO KNOW. The void rectangles sit ON TOP of glide's scroller, so a + * rectangle drawn to the box's own edge paints over the scrollbar and hides it. The two cases + * are CROSSED, which is the part that is easy to get backwards: the BOTTOM void has to stop + * short when the columns overflow (a horizontal scrollbar), and the RIGHT void has to stop + * short when the rows overflow (a vertical one). Both happen in ordinary use — twelve columns + * filtered to five rows is exactly the owner's case. + * + * 0 on overlay-scrollbar platforms (macOS, touch), which is correct: nothing is stolen there. */ +let scrollbarGutterPx: number | null = null; +function scrollbarGutter(): number { + if (scrollbarGutterPx !== null) return scrollbarGutterPx; + if (typeof document === "undefined") return 0; + const probe = document.createElement("div"); + probe.style.cssText = + "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll"; + document.body.append(probe); + scrollbarGutterPx = probe.offsetWidth - probe.clientWidth; + probe.remove(); + return scrollbarGutterPx; +} +/* ═══ end W18-B VOID (module scope) ═══ */ + +interface LocalWorkspace { + fields: Field[]; + views: SavedView[]; + activeViewId: string; + /** When this copy was written (browser clock — same-machine freshness only, + * never date semantics). Absent on copies from before the echo reconcile. */ + savedAt?: number; + /** Wave-6 item 3c — per-key freshness for this browser's own field-def + * writes (edits + tombstones), consumed by reconcileFields at init. */ + fieldStamps?: FieldStamps; + /** BUG-1 (wave 11) — viewId → the filter tree already pushed back to the host, so the + * echo re-emit happens once per user edit rather than once per remount. NOT a timestamp: + * `savedAt` beside it is rewritten on every init, so any time-based bound is refreshed by + * the remount it is meant to bound. See viewEcho.echoReemit. */ + reemitted?: Record; + /** 2026-08-04 — viewId → when THIS browser deleted it. The views half of the tombstone + * rule fields and folders already had, needed the moment the rail adopts host views + * after mount (liveWorkspace.ts). Persisted, not a ref, because a remount inside the + * echo window would otherwise re-adopt a view the user deleted a second ago. */ + viewTombstones?: Tombstones; + /** D-19 (wave 20) — viewId → when THIS browser last WROTE it. The counterpart of the + * tombstones above: they stop a deleted view coming back, this stops a just-created one + * being dropped by the host-list rule at init, before its upsert has round-tripped. + * Same window, same prune, same browser-clock caveat. */ + viewWrites?: Tombstones; + /** ⭐ W36-T03 — when THIS browser last MOVED to a different view. Its own stamp, because + * `savedAt` above is rewritten on every field/view/config write and so cannot bound anything + * (the trap `viewEcho.filtersKey` documents). Absent on copies written before this wave, which + * `resolveActiveView` reads as "not fresh" — the safe direction: the server's answer wins. */ + activeViewAt?: number; +} + +function sameConfig(a: ViewConfig, b: ViewConfig): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +function normalizeConfig(config: Partial | undefined, fields: Field[]): ViewConfig { + const base = defaultViewConfig(fields); + return { + ...base, + ...config, + filters: Array.isArray(config?.filters) ? config.filters : [], + // Legacy views persisted a flat rule array with an implicit AND — anything + // other than an explicit "or" normalizes to "and", so old views are unchanged. + filterConj: config?.filterConj === "or" ? "or" : "and", + sorts: Array.isArray(config?.sorts) ? config.sorts : [], + order: Array.isArray(config?.order) && config.order.length ? config.order : base.order, + visible: + Array.isArray(config?.visible) && config.visible.length + ? config.visible + : base.visible, + widths: config?.widths && typeof config.widths === "object" ? config.widths : {}, + memberPids: Array.isArray(config?.memberPids) ? config.memberPids : [], + // Wave-6 items 10/11. Both normalize junk to ABSENT (grid / 1 frozen), so + // a legacy view is byte-identical to itself after a round trip. W13 (C4 as + // amended): cleanDisplay keeps grid-carried field picks. + display: cleanDisplay(config?.display), + frozenCount: clampFrozenCount(config?.frozenCount), + }; +} + +/** + * The pinned system view, as this browser mints it when no host copy has arrived. + * + * WAVE 21 item 3 (R6) — the NAME is the scope's now (`allViewName`), not the literal "All + * customers" this minted on every topic including `ut_*` databases of anything at all. The + * ID is unchanged and deliberately so (see `ALL_VIEW_ID`). + */ +/** + * ⭐ W36-T04/T05 — a script view AS THE RAIL SEES IT. One builder, because three doors open these + * (a rail click through `selectView`, the agent panel's list, and the create that follows a + * request) and a second spelling of this config is how two of them would end up disagreeing about + * which mode is active. ⛔ `display.mode` is what makes the grid stand DOWN: `displayMode` reads + * it, and without it the grid paints underneath the script panel. + */ +function scriptProjectionView(row: { id: string; name: string }): SavedView { + return { + id: row.id, + name: row.name, + kind: "custom", + config: { ...defaultViewConfig([]), display: { mode: "script" } }, + }; +} + +function allRecordsView(fields: Field[], scope: string): SavedView { + return { + id: ALL_VIEW_ID, + name: allViewName(scope), + kind: "system", + locked: true, + config: defaultViewConfig(fields), + }; +} + +/** Adapt E's already-validated Query view to the existing renderer without creating a native view. */ +function queryPreviewView(binding: QueryVirtualBinding, fields: Field[]): SavedView { + const raw = binding.view; + const keys = new Set(fields.map((field) => field.key)); + const config = defaultViewConfig(fields); + const visible = Array.isArray(raw.visible) + ? raw.visible.filter((key): key is string => typeof key === "string" && keys.has(key)) + : []; + if (visible.length) { + config.visible = visible; + config.order = [...visible, ...config.order.filter((key) => !visible.includes(key))]; + } + config.filters = Array.isArray(raw.filters) ? raw.filters as FilterNode[] : []; + config.filterConj = raw.filterConj === "or" ? "or" : "and"; + config.sorts = Array.isArray(raw.sorts) + ? raw.sorts.filter((item): item is { colId: string; dir?: unknown } => + !!item && typeof item === "object" && typeof (item as { colId?: unknown }).colId === "string" + && keys.has((item as { colId: string }).colId) + ).map((item) => ({ colId: item.colId, dir: item.dir === "desc" ? "desc" : "asc" })) + : []; + config.groupBy = typeof raw.groupBy === "string" && keys.has(raw.groupBy) ? raw.groupBy : null; + if (raw.display && typeof raw.display === "object") config.display = raw.display as DisplaySpec; + /** + * ⭐⭐ W35-T25 — THE MEMBERS AN EDIT CAN MOVE, READ BACK FROM THE SERVER'S COPY. + * + * R2 made the spec editable and `routes_query.QUERY_EDITABLE_SPEC` is the allow-list it may + * move. Every one of them has to be read HERE too, or the round trip is one-directional: the + * edit posts, the server stores it, and the next mount rebuilds the view from the AI's members + * alone — so a resize would survive a reload on THIS browser (the local bucket) and vanish on + * any other, which is the worst of the three possible behaviours because it looks like it works. + * + * ⚠ Each is validated against the CURRENT field list, exactly like the members above: the + * server cleans on write, this cleans on read, and neither trusts the other's vintage. + */ + if (Array.isArray(raw.order)) { + const order = raw.order.filter((key): key is string => typeof key === "string" && keys.has(key)); + if (order.length) config.order = [...order, ...config.order.filter((key) => !order.includes(key))]; + } + if (raw.widths && typeof raw.widths === "object") { + const widths: Record = {}; + for (const [key, width] of Object.entries(raw.widths as Record)) + if (keys.has(key) && typeof width === "number" && width > 0) widths[key] = width; + config.widths = widths; + } + if (typeof raw.colorBy === "string" && keys.has(raw.colorBy)) config.colorBy = raw.colorBy; + if (typeof raw.rowHeightMode === "string") + config.rowHeightMode = raw.rowHeightMode as ViewConfig["rowHeightMode"]; + if (typeof raw.frozenCount === "number") config.frozenCount = clampFrozenCount(raw.frozenCount); + return { + id: binding.artifactId, + name: typeof raw.name === "string" && raw.name ? raw.name : binding.source.label, + kind: "custom", + locked: true, + config, + }; +} + +function readLocal(storageKey: string): LocalWorkspace | null { + try { + const raw = localStorage.getItem(`aios-grid:${storageKey}`); + return raw ? (JSON.parse(raw) as LocalWorkspace) : null; + } catch { + return null; + } +} + +/** + * ⭐⭐ W37-T23 / D-334 — the LOCAL-ONLY stratum's prefix, named once. + * + * A key the server never issues (a real `storageKey` is `_table_workspace`-shaped), so it can + * never collide with one. It was written out twice as a template literal and tested nowhere; the + * persist effect now tests it, and a prefix spelled in two places is a prefix that eventually + * disagrees with itself. + */ +const LOCAL_KEY_PREFIX = "local:"; + +function writeLocal(storageKey: string, value: LocalWorkspace): void { + try { + localStorage.setItem(`aios-grid:${storageKey}`, JSON.stringify(value)); + } catch { + // Storage can be blocked in hardened/opaque iframes. The host bridge remains durable. + } +} + +function nextViewId(): string { + return `view_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`; +} + +/** A multiselect cell's SET, out of its comma-joined string (the `multi` contract). */ +function splitMulti(v: string): string[] { + return v + .split(",") + .map((s) => s.trim()) + .filter((s) => s !== ""); +} + +/** Wave-5 item 3 — does any leaf of the filter tree name this column? + * + * ⚠ Wave-20 item 2 RETARGETED the note that stood here ("measure leaves carry measure keys, + * which are never field keys, so they cannot false-positive"). True about false POSITIVES, + * and precisely why it was a false NEGATIVE: a measure column's own condition names the + * MEASURE, so this answered "not filtered" for a column the user had visibly filtered, and + * the menu never offered "Don't filter by this field" on it. Both doors now go through + * `ruleColumnKeys` — the same resolution the tint uses, so the menu and the colour can never + * disagree about which columns a filter is about. */ +function treeNamesField( + nodes: FilterNode[], + key: string, + measureCols: Map +): boolean { + for (const n of nodes) { + if (isFilterGroup(n)) { + if (treeNamesField(n.children, key, measureCols)) return true; + } else if (ruleColumnKeys(n as FilterRule, measureCols).includes(key)) return true; + } + return false; +} + +/** Wave-5 item 3 — "Don't filter by this field": drop every leaf naming the column, prune + * groups that end up empty. Returns new arrays throughout (the config is state). */ +function dropFieldFromTree( + nodes: FilterNode[], + key: string, + measureCols: Map +): FilterNode[] { + const out: FilterNode[] = []; + for (const n of nodes) { + if (isFilterGroup(n)) { + const children = dropFieldFromTree(n.children, key, measureCols); + if (children.length) out.push({ ...n, children }); + } else if (!ruleColumnKeys(n as FilterRule, measureCols).includes(key)) { + out.push(n); + } + } + return out; +} + +// The header sprite map moved to iconShapes.ts (wave-8 I20/I21) — it is data + +// SVG-source builders with no React in it, which is what lets verify_icons.py +// import it under node and assert the (i) actually contrasts. + +function slugify(label: string): string { + return ( + label + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_|_$/g, "") + .slice(0, 28) || "field" + ); +} + +interface FieldBuildExtra { + formula?: string; + max?: number; + scope?: FieldScope; + label?: string; + colorCodeOptions?: boolean; + optionColors?: Record; + /** + * ⭐⭐ 2026-08-09 — THE RELATIONAL BAGS, WHICH THIS INTERFACE USED TO DROP ON THE FLOOR. + * + * `ColumnMenu.FieldConfigExtra` has carried them since 2026-08-07 and `extraFor` builds a + * complete bag; this type — the receiving end of the same `extra` argument — never declared + * either key, so `buildOverlayField` could not copy what it could not see. The measured result + * on nurilab: a column stored as + * `{key:'custom_video_views_90j26', label:'Video Views', type:'rollup', source:'overlay'}` + * with NO bag, in one user's workspace stratum, which `compute_relation_cells` never reads. + * Named, configured, rendering, and permanently blank — the owner's *"the Rollup doesn't + * work"*. ⛔ Both halves were needed: this type, and the `createField` route below. + */ + link?: Record; + rollup?: Record; + /** + * ⭐⭐ W37-T22 (owner item 15 / R4, contract C7) — the geocode pseudo-kind's bag, declared HERE + * as well as on `ColumnMenu.FieldConfigExtra` for the exact reason the two lines above it were + * added: this type is the RECEIVING end of the same `extra` argument, and a key it does not + * declare is a key `buildOverlayField` cannot copy. The relational pair's own note records what + * that cost — a column stored with no bag, "named, configured, rendering, and permanently + * blank". Two declarations of one bag is the price of the two-file split; one of them missing + * is the defect. + */ + geocode?: Record; + /** Item 15 (C-RENAME) — the option renames this save carries, by row identity. Consumed by + * `retypeField` (which emits `choice_rename`) and ignored by every create path: a field + * being CREATED has no values to migrate. */ + renames?: { from: string; to: string }[]; +} + +/** + * One constructor for a user-created field, shared by "insert field" and Change-field's + * "New field" half (wave-2 item 5) so the two doors cannot drift. A `multiselect` carries its + * declared options AND `multi: true` — the cell is a comma-joined SET, and the flag is what the + * grouping contract keys on (belt to the type-derived brace in groupRows). + * + * Wave-5: `formula` and `created_time` are built in the HOST's OWN emission shape (source + * 'odoo' + derived — aios_grid.READONLY_CUSTOM_TYPES), so the optimistic local def and the + * next payload's echo are byte-identical and nothing restyles on the round trip. `rating` + * carries top-level `max`, `formula` carries top-level `formula` (the ~20:20 contract + * amendment: options stays the select-family list). Wave-6 item 6: both types are now + * `filterable: true` — the host flipped its emission, and conditions on them evaluate in the + * client engine over computedRows (this table's counts are client-mode; a windowed table's + * columns come from the semantic model, so the SQL engine never sees these types). + */ +/** + * ⭐⭐ 2026-08-09 (owner ruling) — DOES THE PRE-SET LOCK CLOSE THIS COLUMN'S SCHEMA? + * + * Owner: *"No rollup field should be uneditable, everything is custom and changeable always."* + * A ROLLUP holds no data of its own — it is a question asked of other rows, re-askable at any + * time, and re-asking it costs nothing because the answer is recomputed from the authoritative + * store on the next pass. `preset` still closes every column that HOLDS something: retyping a + * pre-set `followers` column would strand real measurements in a column that can no longer read + * them. + * + * ⛔ THE CLIENT MIRROR OF `core.user_tables.preset_editable`, and `verify_rollup_editor.py` + * holds the two in step. This check has THREE enforcement points (here, `may_edit_field`, and + * `routes_tables._field_or_refuse`'s sentence); a client that kept hiding the editor while the + * route allowed the PATCH would read as "you fixed nothing", which is the failure mode a fix in + * one of three places always wears. + */ +function isSchemaLocked(field: Field): boolean { + return field.automation?.preset === true && field.type !== "rollup"; +} + +function buildOverlayField( + label: string, + type: FieldType, + options?: string[], + extra?: FieldBuildExtra +): Field { + const key = `custom_${slugify(label)}_${Math.random().toString(36).slice(2, 7)}`; + if (type === "formula" || type === "created_time") { + return { + key, + label, + type, + source: "odoo", + derived: true, + filterable: true, + default: true, + custom: true, + ...(type === "formula" + ? { agg: "sum", ...(extra?.formula ? { formula: extra.formula } : {}) } + : {}), + }; + } + return { + key, + label, + type, + source: "overlay", + default: true, + custom: true, + agg: ["currency", "int"].includes(type) ? "sum" : undefined, + // The relational bags ride the local definition too, so the optimistic field and the + // server's echo describe the same column. Without them the grid held a bagless twin of a + // field the store had configured, and `mayEditField`/`isDerivedLink` read the twin. + ...(extra?.link ? { link: extra.link as Field["link"] } : {}), + ...(extra?.rollup ? { rollup: extra.rollup as Field["rollup"] } : {}), + // ⭐⭐ W37-T22 (R4 / C7) — the geocode bag rides the local definition too, so the optimistic + // field and the server's echo describe the same column. Without this line the grid would hold + // a bagless twin of a column the store HAD configured, and every reader of `field.geocode` + // (the enricher's target picker, the map's coordinate offer) would read the twin. + ...(extra?.geocode ? { geocode: extra.geocode as Field["geocode"] } : {}), + ...((type === "select" || type === "multiselect") && options?.length ? { options } : {}), + ...((type === "select" || type === "multiselect") + ? { + colorCodeOptions: extra?.colorCodeOptions !== false, + ...(extra?.optionColors && Object.keys(extra.optionColors).length + ? { optionColors: extra.optionColors } + : {}), + } + : {}), + ...(type === "multiselect" ? { multi: true } : {}), + ...(type === "rating" ? { max: extra?.max ?? 5 } : {}), + }; +} + +/** The surface this grid draws. `cohort` is the same table over hand-curated SETS — the server + * confirms it by stamping `workspace.cohortMode`, which is the only thing the body below reads. + * + * MEMOIZED (owner item 1, 2026-07-31): the shell re-renders on every chrome state flip — nav + * collapse, toast, settings — and an unmemoized grid re-rendered its whole 3,400-line tree + * each time. Measured cost: 200ms-5s of main-thread block, which ate the 240ms rail-fold + * transition whole (the fold froze, then SNAPPED — the exact "static" the owner named). The + * props surface is one stable string, so memo makes chrome state changes free; the grid still + * re-renders for its own state (edits, resize) and remounts on route change via `key`. */ +interface CustomerGridProps { + scope?: SurfaceScope; + /** A Query-owned immutable artefact. `scope` remains the renderer's mapped data scope. */ + queryBinding?: QueryVirtualBinding; + /** Read-only Grid view embedded in a linked-record modal. It keeps the standard + * filter/sort/search toolbar while withdrawing schema and row mutations. */ + embedded?: boolean; + /** Exact linked pids to project from the target database. */ + embeddedRecordIds?: readonly number[]; + /** Selection mode used by an editable ordinary-Link modal. */ + embeddedSelectable?: boolean; + embeddedSelectedIds?: readonly number[]; + onEmbeddedSelectionChange?: (recordIds: number[]) => void; +} + +interface LinkGridModalProps { + label: string; + table: SurfaceScope; + recordIds: readonly number[]; + editable?: boolean; + single?: boolean; + onSave?: (recordIds: number[]) => void; + onClose: () => void; +} + +function LinkGridModal({ label, table, recordIds, editable = false, single = false, + onSave, onClose }: LinkGridModalProps) { + const panelRef = useRef(null); + const [selectedIds, setSelectedIds] = useState(() => [...recordIds]); + const changeSelectedIds = useCallback((ids: number[]) => { + const next = single ? ids.slice(-1) : ids; + setSelectedIds((current) => + current.join(",") === next.join(",") ? current : next + ); + }, [single]); + useOverlayLayer({ + panelRef, + onDismiss: onClose, + dismissOnOutside: true, + initialFocus: "[data-overlay-autofocus]", + trapFocus: true, + }); + return ( + +
+
+
+
+
{label}
+
+ {(editable ? selectedIds.length : recordIds.length).toLocaleString()} linked{ + single ? " (one allowed)" : "" + } {(editable ? selectedIds.length : recordIds.length) === 1 ? "record" : "records"} +
+
+ +
+
+ +
+ {editable ? ( +
+ + +
+ ) : null} +
+
+
+ ); +} + +function isQueryPreviewRoute(): boolean { + return typeof window !== "undefined" && window.location.hash.startsWith("#/query"); +} + +function CustomerGrid(props: CustomerGridProps = {}) { + const scope = props.scope ?? "customer"; + const queryRoute = isQueryPreviewRoute(); + const [eventBinding, setEventBinding] = useState(); + const [eventRejected, setEventRejected] = useState(false); + useEffect(() => { + if (!queryRoute || props.queryBinding) return; + const receive = (event: Event) => { + const candidate = (event as CustomEvent).detail; + if (!acceptsQueryPreview(candidate, scope)) { + setEventBinding(undefined); + setEventRejected(true); + return; + } + setEventBinding(candidate); + setEventRejected(false); + }; + window.addEventListener(QUERY_BINDING_EVENT, receive); + return () => window.removeEventListener(QUERY_BINDING_EVENT, receive); + }, [queryRoute, props.queryBinding, scope]); + + const queryBinding = props.queryBinding ?? eventBinding; + if (queryBinding && !acceptsQueryPreview(queryBinding, scope)) { + return ( +
+ This Query preview is unavailable because its source binding is invalid. +
+ ); + } + if (queryRoute && !props.queryBinding && !queryBinding) { + return ( +
+ {eventRejected + ? "This Query preview is unavailable because its source binding was refused." + : "Opening Query preview…"} +
+ ); + } + return ; +} + +function CustomerGridSurface({ + scope = "customer", + queryBinding, + embedded = false, + embeddedRecordIds, + embeddedSelectable = false, + embeddedSelectedIds = [], + onEmbeddedSelectionChange, +}: CustomerGridProps = {}) { + const isQueryPreview = queryBinding !== undefined; + /** + * ⭐⭐ W35-T23 (owner item 4 / R2) — **`previewReadOnly` IS GONE, AND THE SPLIT IS THE TICKET.** + * + * It used to be `embedded || isQueryPreview`: ONE flag switching off view persistence, field + * editing, add-row and the toolbar's right element for two surfaces that are not the same + * thing. Owner: *"I should be able to interact in each of the View under Query as well, exactly + * like how I would be able to interact with it under Database view."* R2 makes Query views + * live — resize, sort, group, hide, row height, export, edit a cell, add a row — **subject to + * the SOURCE database's own locks**, which is the predicate that does the work now. + * + * ⛔ TWO QUESTIONS, NOT ONE, AND CONFLATING THEM IS WHAT MADE THE SINGLE FLAG WRONG: + * · **`embedded`** — *may this surface WRITE anything at all?* A linked-record grid inside a + * modal may not, and keeps every refusal it has today. Unchanged, deliberately. + * · **`hostWorkspace`** — *does this surface own the SOURCE DATABASE's workspace?* Its views, + * its `storageKey`, its view rail, its alert list. **A Query surface does NOT**, and that + * has nothing to do with read-only: the artefact has its own single view, its own storage + * key (`query:`) and its own transport. Opening `includeWorkspace` for Query would + * load the source's view list into a Query surface — two surfaces sharing one view list, + * which is the trap this ticket names by name. + * + * ⚠ SO A QUERY SURFACE IS `!embedded && !hostWorkspace`: it writes, and it writes somewhere + * else. Any new guard added below has to answer WHICH of the two questions it is asking; if + * the answer is "both", it is probably asking the wrong one. + */ + const hostWorkspace = !embedded && !isQueryPreview; + // Wave 16 C-TOPIC: which TABLE this tree is drawing, derived from the one scope prop. + const topic = topicForScope(scope); + const { + fields: payloadFields, + rawRows: fetchedRows, + payload, + loading, + overlayEdits, + setOverlayEdits, + patchOverlay, + requestWindow, + } = useCustomerData(scope, { + // ⚠ `bindSurface` is the MODULE-scope write target every emitted event carries. A Query + // surface's cell edits belong to the SOURCE database, so it must bind — the linked-record + // grid must not, because its parent still owns that scope. + bindSurface: !embedded, + // ⛔ The one flag that stays on the WIDE predicate: neither surface reads the source + // database's own saved views. + includeWorkspace: hostWorkspace, + // `writable: false` blocks `patchOverlay`, i.e. every cell edit. R2 wants them in Query. + writable: !embedded, + }); + const embeddedIdsKey = embeddedRecordIds?.join(",") ?? ""; + /** + * ⭐ WAVE 27 · OWNER ITEM 2 (contract C2) — **ROWS THIS BROWSER JUST CREATED**, held locally + * until the server's own copy comes back. + * + * ⛔ THE OWNER'S WORDS: *"adding a new record visually takes too long, I need to be able to + * spam it."* The old path awaited the POST, then fired `ROWS_STALE_EVENT` and re-read the + * WHOLE table — so every "+" cost a round trip plus a full refetch before anything moved, and + * pressing it five times queued five full refetches of a table that grew by five rows. + * + * ⚠ ADD-ONLY AND SELF-PRUNING, which is `liveWorkspace.ts`'s discipline applied to rows: a + * pending row is merged in only while the fetched payload does NOT carry its pid, and the + * effect below drops it the moment the server's copy arrives. There is no third state and no + * merge of values — the server's row wins outright, because by then it IS this row. + */ + const [pendingRows, setPendingRows] = useState([]); + const rawRows = useMemo(() => { + const base = embeddedRecordIds + ? fetchedRows.filter((row) => new Set(embeddedRecordIds).has(row.pid)) + : fetchedRows; + if (!pendingRows.length) return base; + const known = new Set(base.map((r) => r.pid)); + const extra = pendingRows.filter((r) => !known.has(r.pid)); + return extra.length ? [...base, ...extra] : base; + // The scalar key keeps this stable when a caller reconstructs the same id list. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [fetchedRows, embeddedIdsKey, pendingRows]); + useEffect(() => { + // ⚠ PRUNED AGAINST THE FETCHED PAYLOAD, not against `rawRows` — `rawRows` contains the + // pending rows, so it can never report that one has been absorbed. Pruning against the + // memo's own output is the loop that keeps a placeholder alive forever. + if (!pendingRows.length) return; + const known = new Set(fetchedRows.map((r) => r.pid)); + if (!pendingRows.some((r) => known.has(r.pid))) return; + setPendingRows((cur) => cur.filter((r) => !known.has(r.pid))); + }, [fetchedRows, pendingRows]); + /** + * The rows as of the last render, for callbacks that must not be rebuilt per payload. + * `appendRow` reads it to mint the next row id the way the server does (`max + 1`); putting + * `rawRows` in that callback's dependency list would give the trailing "+" a new identity on + * every refetch, which is a re-render of the grid for a value only used at click time. + */ + const rowsRef = useRef(rawRows); + rowsRef.current = rawRows; + /** + * ⭐ WAVE 27 · ITEM 2 — THE NEXT ROW ID, ADVANCED SYNCHRONOUSLY AT MINT TIME. + * + * ⛔ A RENDER-TIME SNAPSHOT IS NOT ENOUGH FOR THE GESTURE THIS ITEM IS ABOUT. `rowsRef.current` + * is assigned during RENDER, so two "+" presses that land before React commits the first + * `setPendingRows` both read the same list and both mint the same id. The owner's words were + * *"I need to be able to spam it"* — that IS the failing case, and the damage is not a + * duplicate row: `minted` is the identity key the failure and re-anchor branches filter and + * map on, so ONE refused write would withdraw BOTH rows and one taken id would rewrite both. + * + * ⚠ SEEDED FROM THE ROWS ON EVERY MINT, then advanced: `Math.max(seen, storeMax) + 1`. Reading + * the store's max each time is what keeps the client in step after a refetch brings rows in + * from elsewhere; the ref is what stops a burst from colliding with itself in between. + */ + const nextRidRef = useRef(0); + const mintRid = useCallback(() => { + const storeMax = rowsRef.current.reduce((m, r) => Math.max(m, r.pid), 0); + nextRidRef.current = Math.max(nextRidRef.current, storeMax) + 1; + return nextRidRef.current; + }, []); + /** + * Withdraw rows this browser is still holding locally, because they have been DELETED. + * + * ⛔ THE PRUNE EFFECT CANNOT DO THIS AND THAT IS THE WHOLE BUG IT FIXES. Pruning is "the + * server's payload now CONTAINS this row, so drop my copy" — a deleted row will never appear + * in a payload again, so a pending row that is deleted before its first refetch stays merged + * into `rawRows` for the life of the mount. Add a record, press Ctrl+Z, and the row sits there + * looking undeleted while the store has already lost it. + * ⚠ CALLED AT EVERY `deleteTableRow` SITE (the selection delete and undo's `rowDelete` + * inverse), because those are the only two doors that remove a row — and the undo one is + * exactly the path that produces a still-pending victim. + */ + const withdrawPending = useCallback((rids: (string | number)[]) => { + if (!rids.length) return; + const gone = new Set(rids.map((r) => Number(r))); + setPendingRows((cur) => (cur.some((r) => gone.has(r.pid)) + ? cur.filter((r) => !gone.has(r.pid)) + : cur)); + }, []); + /** + * ⭐ WAVE 27 · OWNER ITEM 21 / RULING R14 — WHICH VIEWS ON THIS TABLE CARRY AN ALERT. + * + * ⛔ THE SERVER'S LIST IS THE ONLY ANSWER. The rail's "Alert me about new records…" is a + * one-way CREATE (it emits an event the shell turns into a POST) and nothing in the grid + * hears the outcome — a `no_filter` refusal is a real answer, so an optimistic local set + * would paint a badge on a view that has no alert. `GET /alerts` is the state. + * + * ⚠ RE-READ ON FOCUS, the same idiom the shell's own inbox uses. An alert created or deleted + * in another surface (the Alerts pane owns the delete door) reaches this rail on the next + * focus rather than instantly; the alternative was for the grid to guess at the outcome of a + * request another component made. + * ⚠ EMBEDDED GRIDS DO NOT ASK. A linked-record grid inside a modal is not a table anyone + * alerts on, and a second fetch per relation cell would be a workspace read per click. + */ + /** + * ⭐⭐ W35-T29 + T30 · CONTRACT C5 (R5, R6) — the RECORD stars for this database. + * + * ⛔ PER USER, unlike every other star in the product. A database, an agent or a Query view is + * starred through `config.important` on ONE record, so starring a SHARED view stars it for + * everybody who can see it; a record star lives in the caller's own per-username stratum and two + * accounts see two different sets. One word on screen, two scopes underneath (mailbox E-3). + * ⛔ AND IT IS ITS OWN DOOR, NOT A VIEW WRITE (D-170): a view write on a read-through grid 409s + * because it materialises the pool, and this must work on `ut_odoo_gl_lines`. + * + * ⚠ OFF FOR AN EMBEDDED GRID. A linked-record picker inside a modal is not a table anybody keeps + * marks on, and the read would be one request per relation cell. + */ + const recordStars = useRecordStars(scope, !embedded); + const [alerted, setAlerted] = useState([]); + useEffect(() => { + // `hostWorkspace`, not `embedded`: the alert badges paint on the SOURCE database's view rail, + // which a Query surface does not render at all (`hideViews`). Fetching them would be a + // workspace read whose answer has nowhere to go. + if (!hostWorkspace) return; + let live = true; + const pull = () => { + void fetchAlerts().then((r) => { + if (!live || !r.ok) return; // a failed read leaves the badges as they were + setAlerted(r.value.filter((a) => a.topic === scope).map((a) => a.viewId)); + }); + }; + pull(); + window.addEventListener("focus", pull); + return () => { + live = false; + window.removeEventListener("focus", pull); + }; + }, [hostWorkspace, scope]); + + const [fields, setFields] = useState([]); + const [views, setViews] = useState([]); + const [activeViewId, setActiveViewId] = useState(ALL_VIEW_ID); + const [config, setConfig] = useState(() => defaultViewConfig([])); + /** W36-T02 — the live `config`, readable from an effect that must not DEPEND on it. The + * after-mount reconcile needs to know whether an edit is unsaved before it re-seeds the open + * view; taking `config` as a dependency would re-run that whole pass on every drag and + * keystroke instead of only when the host payload moved. */ + const configRef = useRef(config); + configRef.current = config; + const [workspaceReady, setWorkspaceReady] = useState(false); + const [saveState, setSaveState] = useState<"saved" | "saving">("saved"); + const [search, setSearch] = useState(""); + const [collapsed, setCollapsed] = useState>(new Set()); + const [hoverRow, setHoverRow] = useState(undefined); + /** + * Owner item 19 — the hover-only Expand button: which record it opens and where it sits, in + * VIEWPORT coordinates (glide's `getBounds` space, the same one `.cg-header-tip` uses). + * + * A real DOM button rather than a mark painted into the canvas, and that is the whole design: + * a drawn affordance needs a second hit-test that drifts out of step with the drawing, and + * neither `tsc` nor a screenshot can see the drift — the button is painted, and clicking it + * does nothing ([[ui-invisible-to-assertions]]). An element IS its own hit test. + */ + const [expandAt, setExpandAt] = useState< + { pid: number; x: number; y: number; size: number } | null + >(null); + /** + * ⭐ W35-T29 — the same idea at the other end of the same cell: which record the hovered star + * belongs to, and where it sits. A separate state from `expandAt` because the two have + * different minimum row heights (this mark is smaller), so a compact row can carry one and not + * the other, and a shared state would have to pick. + */ + const [starHover, setStarHover] = useState< + { pid: number; x: number; y: number; size: number } | null + >(null); + const [detailPid, setDetailPid] = useState(null); + const [columnMenu, setColumnMenu] = useState(null); + /** Open choice list for a `select` / `user` cell — see onCellClicked. */ + const [picker, setPicker] = useState<{ + pid: number; + fieldKey: string; + anchor: AnchorRect; + } | null>(null); + /** Owner item 5 (2026-07-31) — closing a picker RETURNS FOCUS TO THE GRID, so Enter keeps + * navigating (pick → Enter → next record) instead of stranding focus on a dead overlay. */ + const closePicker = useCallback(() => { + setPicker(null); + requestAnimationFrame(() => gridRef.current?.focus()); + }, []); + /** ⭐ Wave-23 C7 — which `json` cell the big viewer is open on. NOT anchored like the picker + * above: a document is not a choice list, so it opens as a centred modal (the record drawer's + * surface) rather than a popover the size of the cell it came from. */ + const [jsonAt, setJsonAt] = useState<{ pid: number; fieldKey: string } | null>(null); + const closeJson = useCallback(() => { + setJsonAt(null); + requestAnimationFrame(() => gridRef.current?.focus()); + }, []); + /** A relation opens as the target database's real Grid view, projected to the linked ids. + * This is intentionally distinct from JsonViewer: filters, sorts, search, column visibility, + * and the standard cell renderers all remain available inside the large modal. */ + const [linkAt, setLinkAt] = useState<{ pid: number; fieldKey: string } | null>(null); + const closeLink = useCallback(() => { + setLinkAt(null); + requestAnimationFrame(() => gridRef.current?.focus()); + }, []); + /** WAVE 21 item 11 (R10) — is "Select records from a list" open? Opened from the view + * rail's "…" and closed by the dialog; the SELECTION it produces outlives it. */ + const [selectFromFile, setSelectFromFile] = useState(false); + const [importOpen, setImportOpen] = useState(false); + /** The selection bar's "Add to cohort" popover (owner item 2's purpose for the checkboxes). */ + const [selAddOpen, setSelAddOpen] = useState(false); + const [selListName, setSelListName] = useState(""); + const selAddRef = useRef(null); + /** Owner item 9 (wave 20) — its counterpart: "Remove from cohort…", in ORDINARY views. */ + const [selRemoveOpen, setSelRemoveOpen] = useState(false); + const selRemoveRef = useRef(null); + /** Owner item 4 / C-ADDROW — the pid the trailing "+" just created, so the cursor can land on + * it once the re-read actually brings it back (the row does not exist locally before that). */ + const [newRowPid, setNewRowPid] = useState(null); + /** + * ⭐ WARN-THEN-ALLOW on delete (owner, 2026-08-06) — the SIGNATURE of the selection currently + * armed, or `""`. + * + * A signature and not a boolean, deliberately: it is the pid list itself, so changing the + * selection DISARMS automatically. A boolean would stay true while you re-selected, and the + * second Delete would then destroy a set nobody had been warned about — which is worse than no + * warning at all, because the warning is what made it feel checked. + */ + const [delArmed, setDelArmed] = useState(""); + /** Wave-2 item 2c — cohort mode: the active cohort and the "+ Add customers" picker. Picks + * ACCUMULATE across searches (search, tick, search again, confirm once). */ + const [activeCohortId, setActiveCohortId] = useState(null); + const [addCustOpen, setAddCustOpen] = useState(false); + const [addCustQuery, setAddCustQuery] = useState(""); + const [addCustPicked, setAddCustPicked] = useState>(new Set()); + const addCustRef = useRef(null); + /** Wave-5 item 1 — "Filter by this field": a monotonic signal the Toolbar consumes (it + * appends the seeded condition and opens the builder). Wave-6 item 6c: a MEASURE-carrying + * column seeds the equivalent measure condition instead (same measure key + window). */ + const [filterSeed, setFilterSeed] = useState< + | { key?: string; measure?: { key: string; window: WindowSpec }; n: number } + | null + >(null); + /** Wave-6 item 4 — the header-row "+" (insert at end): anchors the create-only menu. */ + const [plusMenu, setPlusMenu] = useState(null); + /** + * Wave-6 item 3c — per-key freshness of this browser's own field-def writes, persisted with + * the workspace copy and consumed by reconcileFields at init. A ref, not state: stamps ride + * along with the setFields that caused them; nothing renders from a stamp. + */ + const fieldStampsRef = useRef<{ edited: Record; deleted: Record }>( + { edited: {}, deleted: {} } + ); + /** Wave-5 item 6 — the header description tip. Floated, pointer-events:none — a tooltip + * that can receive the pointer swallows the NEXT click ([[ui-invisible-to-assertions]]). */ + const [headerTip, setHeaderTip] = useState<{ text: string; x: number; y: number } | null>( + null + ); + /** Wave-9 I2 — the CELL tip: hovering a cell whose text is cut off reveals it in full. + * Same contract as the header tip above (floated, pointer-events:none, aria-hidden) for + * the same reason — a tooltip that can receive the pointer swallows the NEXT click, which + * this surface has already paid for once ([[ui-invisible-to-assertions]]). */ + const [cellTip, setCellTip] = useState<{ text: string; x: number; y: number } | null>(null); + + /** BUG-1 — viewId → the filter tree we have already pushed back to the host. Persisted with + * the local workspace because the remount it guards against destroys any ref. */ + const reemittedRef = useRef>({}); + /** 2026-08-04 — views THIS browser deleted, by id. Read by the live adopt below AND by + * init, so the mount path and the after-mount path answer "was this deleted here?" the + * same way. Persisted with the local workspace for the same reason `reemitted` is. */ + const viewTombstonesRef = useRef({}); + /** D-19 — viewId → when this browser last wrote it (see LocalWorkspace.viewWrites). */ + const viewWritesRef = useRef({}); + const initializedKey = useRef(null); + /** W36-T03 — when this browser last MOVED to a different view, and the id it moved to. Kept as + * a ref rather than state because nothing renders from it; read by the persist effect. */ + const activeViewAtRef = useRef(undefined); + const activeViewSeenRef = useRef(null); + const saveTimer = useRef(null); + const gridRef = useRef(null); + const gridBoxRef = useRef(null); + /** Internal clipboard provenance. The browser exposes pasted text, not its source column; + * recording the grid's own copy keeps cross-field paste fail-closed without blocking + * ordinary external text pasted into an editable field. */ + const copyProvenanceRef = useRef(EMPTY_GRID_COPY_PROVENANCE); + useEffect(() => { + const onCopy = () => { + // The native copy event follows our grid key event. Preserve that provenance once; + // every later copy (address bar, another app surface, another input) invalidates it. + copyProvenanceRef.current = observeCopyEvent(copyProvenanceRef.current, Date.now()); + }; + const onWindowBlur = () => { + copyProvenanceRef.current = EMPTY_GRID_COPY_PROVENANCE; + }; + document.addEventListener("copy", onCopy, true); + window.addEventListener("blur", onWindowBlur); + return () => { + document.removeEventListener("copy", onCopy, true); + window.removeEventListener("blur", onWindowBlur); + }; + }, []); + const [gridSize, setGridSize] = useState({ width: 800, height: 600 }); + + /** + * ⛔ WAVE 21 item 3 (3c) — THE FALLBACK KEY IS THE SCOPE'S, and the literal it replaces is + * the cross-table bleed the owner reported as "RI fields on a new database". + * + * MEASURED, not theorised. `fetchWorkspace` collapses 403 / 5xx / network / unreadable into + * one silent `null` — deliberately, because the workspace is an ENHANCEMENT of the table and + * an older host simply does not serve one. But `null` also meant "no `storageKey`", and the + * fallback was the LITERAL `"customer-grid-standalone"` — ONE localStorage bucket for every + * scope this component can be mounted at. So on a tenant whose `/workspace` 403s for `ut_*` + * (routes_grid gated the route on `module_gate("customer_data")` — C4 fixes that half), the + * FIRST user table to be opened writes its views and custom fields into that bucket and the + * SECOND one reads them back as its own. Two databases with nothing to do with each other, + * sharing a schema, with no error anywhere. + * + * Scoping the key makes that structurally impossible: a bucket belongs to one surface, so + * the worst a failed workspace read can now do is show THIS table its own last-known local + * copy. The prefix marks it as the local-only stratum — a key the server never issues, so it + * can never collide with a real `storageKey` (those are `_table_workspace`-shaped). + * + * ⚠ Existing browsers lose the contents of the old shared bucket. That is the point: every + * byte in it is a workspace some other surface persisted, and there is no way to tell whose. + */ + const storageKey = isQueryPreview && queryBinding + ? `query:${queryBinding.workspaceBinding.key}` + : payload?.workspace?.storageKey ?? `${LOCAL_KEY_PREFIX}${scope}`; + + // Initialize once per permission/data scope. Host state wins; local state + // fills only missing objects and keeps the standalone path useful. + useEffect(() => { + if (!payload || payloadFields.length === 0 || initializedKey.current === storageKey) return; + if (isQueryPreview && queryBinding) { + /** + * ⭐⭐ W35-T23 + T25 (R2/R3) — THE SERVER OWNS A QUERY ARTEFACT'S SPEC, AND THIS SURFACE + * KEEPS NO SECOND COPY. That is the whole of "a resize survives a reload" now. + * + * `binding.view` IS the stored spec (`routes_query` accepts a `view_upsert` of the + * artefact's own view and `queryPreviewView` reads every editable member back off it), so a + * width dragged on one device is there on the next, on any device, without a local bucket. + * + * ⛔⛔ THE FIRST DRAFT SEEDED FROM `readLocal(storageKey)` AND IT WAS WRONG IN THE WORST + * SHAPE THIS REPO CATALOGUES — works, then does not, then works again. The local copy had to + * be gated on `binding.edited` so a Revert was not silently undone on that device; but + * `binding` is memoised on `active`, and NOTHING in the autosave path refetches the artefact + * — so after the FIRST edit in a session the flag was still `false`, and navigating away and + * back inside that session dropped the width while a full reload brought it back. A second + * store for one spec, plus a flag to arbitrate between them, is the shape R4 forbids one + * ruling over. There is one store now and nothing to arbitrate. + * + * ⚠ THE COST, STATED: an edit whose POST fails is lost at the next mount, and the reader is + * TOLD (the autosave toasts the failure). That is the honest trade against a local copy that + * can disagree with the server about what the artefact is. + */ + const view = queryPreviewView(queryBinding, payloadFields); + setFields(payloadFields); + setViews([view]); + setActiveViewId(view.id); + setConfig(view.config); + setWorkspaceReady(true); + initializedKey.current = storageKey; + return; + } + const local = embedded ? null : readLocal(storageKey); + // Item 3c: the def half of the no-blip layer. A RECENT local stamp beats a + // lagged host echo (rename survives, retype holds, a delete stays deleted); + // a caught-up echo returns host objects byte-identical (see optimism.ts). + const stamps = pruneStamps(local?.fieldStamps, Date.now()); + fieldStampsRef.current = { + edited: { ...(stamps.edited ?? {}) }, + deleted: { ...(stamps.deleted ?? {}) }, + }; + const initialFields = reconcileFields( + payloadFields, + local?.fields ?? [], + stamps, + Date.now(), + payload.workspace != null + ); + const hostViews = embedded ? [] : payload.workspace?.views ?? []; + const byId = new Map(); + byId.set(ALL_VIEW_ID, allRecordsView(initialFields, scope)); + // D-19 — the HOST'S LIST DECIDES WHICH VIEWS EXIST. A local copy the host no longer + // names is a ghost (deleted elsewhere, share revoked, store moved) and is dropped here, + // guarded by this browser's own recent writes so an optimistic create survives. + viewWritesRef.current = pruneTombstones(local?.viewWrites, Date.now()); + for (const view of seedLocalViews(local?.views ?? [], hostViews, viewWritesRef.current, + Date.now(), payload.workspace != null)) + byId.set(view.id, view); + // Host state wins — EXCEPT when it is a lagged echo of this browser's own + // in-flight edit, where taking it would turn a just-completed measure rule + // valueless: inactive, no pending marker, whole-book count. A rerun replaces + // the iframe, so this init runs after every host round trip; the reconcile + // is what keeps a rule with a typed rhs PENDING rather than inactive across + // that remount (see viewEcho.ts). + // + // BUG-1 (wave 11) — which views carry a FILTER TREE the host copy does not have, and have + // not already been told about. `echoReemit` owns both guards (see viewEcho.ts): identity + // alone would also fire on a display-only reconcile, and a time-based bound cannot work + // because the local-workspace effect below restamps `savedAt` on every init. + const reemit = new Map(); + // 2026-08-04 — the views tombstone map, seeded from the persisted copy so a remount + // INSIDE the echo window cannot walk back a view this browser just deleted. Init had + // no such guard before (fields and folders both did), which was survivable only while + // every host round trip was itself a remount. + const tombstones = pruneTombstones(local?.viewTombstones, Date.now()); + viewTombstonesRef.current = tombstones; + for (const view of hostViews) { + // Both doors, the `reconcileFields` rule: a tombstone drops the host copy AND any + // local copy, so a delete that raced localStorage cannot re-enter through either. + if (tombstones[view.id] !== undefined) { + byId.delete(view.id); + continue; + } + const merged = reconcileEchoView( + view, byId.get(view.id), local?.savedAt, Date.now() + ); + const key = echoReemit(merged, view, local?.reemitted?.[view.id]); + if (key !== null) reemit.set(view.id, key); + byId.set(view.id, merged); + } + const initialViews = [...byId.values()].map((view) => ({ + ...view, + config: normalizeConfig(view.config, initialFields), + })); + /** + * ⭐⭐ W36-T03 — the local preference is now BOUNDED, like every other local stratum here. + * It used to win unconditionally, so two machines on one account could not be brought onto + * the same view by anything the user did on either of them. `view_select` writes the server + * copy (`table_store.save_active_view`), so "where the user left off survives a new browser" + * — the 2026-07-31 owner item — is served by rung 2 and by rung 3 when the store had nothing. + */ + const requested = resolveActiveView( + { id: local?.activeViewId, at: local?.activeViewAt }, + payload.workspace?.activeViewId, ALL_VIEW_ID, Date.now()); + const active = initialViews.find((view) => view.id === requested) ?? initialViews[0]; + /** + * ⭐ W36-T03 — seed the bound's two refs from what we just RESOLVED, and stamp nothing here. + * ⛔ Stamping at init would refresh the window on every mount, so a browser that reloads + * every couple of minutes would never yield to the server and the bound would be decorative. + * The stamp is only CARRIED, and only when we actually honoured this browser's own choice — + * if the server's answer won, the old stamp is about a different view and is dropped. + */ + activeViewSeenRef.current = active.id; + activeViewAtRef.current = + active.id === local?.activeViewId ? local?.activeViewAt : undefined; + setFields(initialFields); + setViews(initialViews); + setActiveViewId(active.id); + setConfig(active.config); + setWorkspaceReady(true); + initializedKey.current = storageKey; + + /** + * BUG-1 (wave 11) — TELL THE HOST. The reconcile above was right about the state and silent + * about the consequence: it restored a rule this browser completed and the host copy does + * not have, and then nothing sent it. The autosave effect cannot: init seeds `views` AND + * `config` from the same reconciled object, so `sameConfig` reports "saved" and no upsert + * is ever emitted. The rule stays ACTIVE client-side, PENDING forever, and unknown to + * `_cl_resolve_measures` — which is exactly W11-HOST's live trace, a pending view carrying + * no measure rule at all while the saved-view path resolves correctly. + * + * ⚠ THIS TERMINATES, and NOT because the host will store what we send. It might not: a + * measure key outside this user's BU offer is stripped by `clean_filter_tree`, so the host + * copy never gains the rule and `lagged` stays true forever. Termination comes from the + * IDEMPOTENCE KEY — the filter tree itself, persisted per view — so we tell the host about + * a given tree exactly once and only speak again when the USER edits the conditions. + * ⚠ A ref cannot hold that: the remount this exists to survive destroys it. It rides in + * localStorage beside the views, and `writeLocal` below carries it forward. + * + * Emitted from the NORMALIZED copy (`initialViews`), never from the raw host view: that is + * the config the client is actually filtering with, and sending anything else would ask the + * host to resolve a question nobody on screen is asking. + */ + if (hostWorkspace && reemit.size > 0) { + const stamped = { ...(local?.reemitted ?? {}) }; + for (const view of initialViews) { + const key = reemit.get(view.id); + if (key === undefined) continue; + viewWritesRef.current = stampTombstone(viewWritesRef.current, view.id, + Date.now()); + viewWritesRef.current = stampTombstone(viewWritesRef.current, view.id, Date.now()); + emitHostEvent({ id: eventId("view"), type: "view_upsert", view }); + stamped[view.id] = key; + } + reemittedRef.current = stamped; + } else { + reemittedRef.current = { ...(local?.reemitted ?? {}) }; + } + }, [payload, payloadFields, storageKey, hostWorkspace, scope, isQueryPreview, queryBinding]); + + /** + * ⭐ W36-T03 — the view-preference stamp, and it is its OWN effect on purpose. + * + * ⛔ IT CANNOT LIVE INSIDE THE PERSIST EFFECT BELOW. `verify_grid_ux.py` pins that effect's + * guard as immediately followed by `writeLocal(storageKey` — the W35-T23 scan proving a Query + * surface writes no second copy of an artefact's spec — so a statement between the two turns + * that gate red, and that file is in no lane's fence to repair. Declared FIRST, so React runs + * it before the persist effect in the same commit and the ref it writes is already current. + * + * ⚠ Only a real MOVE stamps. `seen` is seeded by init from the id init itself resolved, so the + * first pass after a mount is never mistaken for the user changing view — which matters, + * because a stamp refreshed on every mount would make the freshness bound decorative. + */ + useEffect(() => { + if (!workspaceReady || !hostWorkspace) return; + if (activeViewSeenRef.current === activeViewId) return; + activeViewSeenRef.current = activeViewId; + activeViewAtRef.current = Date.now(); + }, [workspaceReady, hostWorkspace, activeViewId]); + + useEffect(() => { + // ⛔ `hostWorkspace`, and a Query surface is deliberately EXCLUDED (W35-T23 + T25). Its spec + // lives on the server, which is the only copy — writing a second one here would be a store + // with no reader, and gating a read on which of the two is fresher is the bug the init + // effect's note above describes. A LOCKED source's own workspace is a different question and + // is unaffected: that is `hostWorkspace` true, `recordsMutable` false. + // ⭐⭐ W37-T23 / D-334 — A HOST-BACKED SURFACE NEVER PERSISTS INTO THE `local:` FALLBACK. + // + // ⛔ THE RACE, MEASURED IN THE SOURCE RATHER THAN GUESSED. `useCustomerData`'s load commits + // `setData(workspace ? withWorkspace(payload, workspace) : payload)` — so a `/workspace` call + // that FAILS or has not landed produces a payload with rows and NO workspace, deliberately + // (its own note: a workspace failure must not cost you the table). `storageKey` then falls + // back to `local:` while `hostWorkspace` is a CONSTANT `true` for this surface, and + // this effect wrote the WHOLE workspace — every field, every view, the active view — into a + // bucket the server never issued and never reads. + // ⚠ THE BUCKET IS STILL READ, and that is the point of it: a failed workspace read may show + // this table its own last-known copy. What stops is WRITING a fresh full copy into it, which + // is what made a raced first init leave a second, divergent workspace behind. + // ⛔ THE TEST IS PART OF THE GUARD EXPRESSION, NOT A STATEMENT ABOVE `writeLocal`. + // `verify_grid_ux.py` pins this effect's guard as IMMEDIATELY followed by `writeLocal(storageKey` + // (the W35-T23 scan proving a Query surface writes no second copy), and a statement between the + // two turns that gate red on a file no lane owns. + if (!workspaceReady || !hostWorkspace || storageKey.startsWith(LOCAL_KEY_PREFIX)) return; + writeLocal(storageKey, { + fields, + views, + activeViewId, + savedAt: Date.now(), + fieldStamps: pruneStamps(fieldStampsRef.current, Date.now()), + // BUG-1 — carried forward, never recomputed here. Dropping it would restore the loop: + // the next init would find no record of what it already told the host and say it again. + reemitted: reemittedRef.current, + // Pruned on the way out, like every other stamp map here: the persisted blob is a + // recent window, never an archive of everything this tab ever deleted. + viewTombstones: pruneTombstones(viewTombstonesRef.current, Date.now()), + // D-19's other half — without persisting these, a reload inside the echo window would + // drop a view this browser created seconds ago. + viewWrites: pruneTombstones(viewWritesRef.current, Date.now()), + // ⭐ W36-T03 — carried, not recomputed. This effect fires on every field, view and config + // write, so stamping `Date.now()` here would refresh the bound on activity that has nothing + // to do with which view is open — which is exactly why `savedAt` beside it cannot serve as + // the bound. The ref below moves only when `activeViewId` actually changes. + activeViewAt: activeViewAtRef.current, + }); + }, [workspaceReady, storageKey, fields, views, activeViewId, hostWorkspace]); + + /** + * ⭐ THE LIVE WORKSPACE (owner report, 2026-08-04) — what has APPEARED since we mounted. + * + * The init effect above runs ONCE per mount (`initializedKey`), which under Streamlit was + * indistinguishable from "once per host round trip" because a rerun replaces the iframe. + * The standalone shell has no rerun: this component stays mounted until `key={active.key}` + * changes in Shell.tsx — switching modules. So a cohort created here reached the store, + * came back in the very next `/workspace` re-read, sat in `payload.workspace.views`, and + * never reached the rail. That is the owner's report, exactly: it appears after you visit + * another module and come back, because that is the only thing that remounts this tree. + * + * ⚠ FIELDS FIRST, AND THE ORDER IS LOAD-BEARING. `fields_from_workspace(ws, + * cohorts=bool(cohort_lists))` emits the derived "Locked views" column only once a cohort + * EXISTS — so a user's FIRST cohort changes the field contract in the same payload that + * carries the new view. The adopted views are normalized against `nextFields`, not against + * the `fields` state (which React has not committed yet), or a projected `config.order` + * naming the new column would be normalized against a list that does not have it. + * + * ⚠ Add-only, tombstoned, and identity-stable — see liveWorkspace.ts for why each of those + * is a correctness property rather than a nicety. + */ + const hostWorkspaceViews = payload?.workspace?.views; + useEffect(() => { + // `hostWorkspace`: this adopts views out of `payload.workspace`, which a Query surface never + // fetches. Keyed on `embedded` it would read a workspace that is not there. + if (!workspaceReady || !hostWorkspace) return; + const now = Date.now(); + /** + * ⭐⭐ W36-T02 (owner item 1) — CORRECT, THEN ADOPT. The two steps answer two questions and + * this effect used to ask only the second one. + * + * ⛔ THE DEFECT, reproduced in `_test/gridUx.test.ts` T01/D before it was fixed: both merges + * below are add-only, and add-only was implemented as "skip any id I already hold" — which + * also means CANNOT CORRECT. So an edit made on the owner's machine reached this browser (the + * change-token poller notices and `reread()` fetches it) and was then thrown away, leaving a + * pre-edit filter tree and a pre-edit `agg` in place until a remount. Measured + * `{rows: 2, totals: false}` against a reload's `{rows: 4, totals: true, sum: "$4,000"}` off + * the SAME server state — the owner's *"only when I refresh it works"*, both symptoms, one + * cause. ⚠ The PRD blamed an init RACE; that one is real but self-healing, because + * `storageKey` is derived from the workspace and re-arms the guard. See mailbox/B.md B-1. + */ + const heldFields = reconcileHeldFields(fields, payloadFields, fieldStampsRef.current, now); + const nextFields = adoptNewFields( + heldFields, payloadFields, fieldStampsRef.current.deleted, now + ); + if (nextFields !== fields) setFields(nextFields); + const normalize = (config: Partial | undefined) => + normalizeConfig(config, nextFields); + const heldViews = reconcileHeldViews(views, hostWorkspaceViews, viewWritesRef.current, + now, normalize); + const nextViews = adoptNewViews(heldViews, hostWorkspaceViews, viewTombstonesRef.current, + now, normalize); + if (nextViews !== views) setViews(nextViews); + /** + * ⛔⛔ AND THE OPEN VIEW HAS TO FOLLOW ITS OWN DEFINITION, or the correction above is + * invisible. `config` is separate state, seeded at init and re-seeded from `views` ONLY when + * the user switches view (`selectView`) — so a corrected `views` array alone changes nothing + * on screen for the view already open, which is the half of this defect that a fix to the + * merge would silently leave behind (measured: the `adoptNewViews` negative control moves the + * identity assertion and NOT the row count). + * + * ⚠ `sameConfig(before.config, config)` IS THE UNSAVED-EDIT GUARD, and it is the autosave's + * own predicate rather than a second one. While a person is mid-edit the autosave has already + * diverged `config` from the view it is debouncing, so this refuses to touch it; the instant + * that write commits, `viewWrites` is stamped and `reconcileHeldViews` yields to the local row + * for a whole echo window anyway. Two guards, neither invented here. + */ + if (nextViews !== views) { + const before = views.find((view) => view.id === activeViewId); + const after = nextViews.find((view) => view.id === activeViewId); + // ⚠ `configRef`, NOT `config`. Reading the live value through a ref keeps `config` OUT of + // the dependency list, and that is a cost decision rather than a style one: `config` moves + // on every drag, sort and filter keystroke, and each re-entry would re-stringify every held + // field and view for an answer that cannot have changed — the host payload did not move. + if (before && after && before !== after && sameConfig(before.config, configRef.current)) + setConfig(after.config); + } + // ⚠ TERMINATION IS THE IDENTITY CONTRACT, not a flag: once corrected, both merges return + // their input by reference and `nextViews === views`, so the next pass sets nothing and the + // effect stops. That is why `views` can be a dependency at all. + }, [workspaceReady, hostWorkspaceViews, payloadFields, fields, views, activeViewId, + hostWorkspace]); + + /** Item 3c — stamp a def write / a delete. Pruned at every touch so the persisted blob + * stays a recent window, never an archive. */ + const stampFieldEdit = useCallback((key: string) => { + const s = pruneStamps(fieldStampsRef.current, Date.now()); + fieldStampsRef.current = { + edited: { ...(s.edited ?? {}), [key]: Date.now() }, + deleted: { ...(s.deleted ?? {}) }, + }; + }, []); + const stampFieldDelete = useCallback((key: string) => { + const s = pruneStamps(fieldStampsRef.current, Date.now()); + const edited = { ...(s.edited ?? {}) }; + delete edited[key]; + fieldStampsRef.current = { + edited, + deleted: { ...(s.deleted ?? {}), [key]: Date.now() }, + }; + }, []); + + const updateConfig = useCallback( + (next: ViewConfig) => { + if (isQueryPreview && queryBinding) { + signal(TOAST_EVENT, refuseQueryMutation(queryBinding, "update").message); + return; + } + if (next.groupBy !== config.groupBy) setCollapsed(new Set()); + setConfig(next); + }, + [config.groupBy, isQueryPreview, queryBinding] + ); + + const { + visibleCols, + fieldByKey, + order, + visible, + lockedKey, + onColumnResize, + onColumnMoved, + onColumnProposeMove, + setColumnVisible, + insertColumn, + } = useGridColumns(fields, config, updateConfig); + + /** + * ⭐ Wave-20 owner item 4 (ruling R8, contract C-ADDROW) — **THE GHOST ROW.** + * + * A trailing "+" row at the bottom of the grid, the way Airtable grows a table, replacing the + * "Add record" button that used to sit in the shell's header bar (S4 deleted it this wave). + * + * ⛔ USER DATABASES ONLY. A connector's rows are read-synced from Odoo; a "+" there could only + * refuse, and R8 names that a fake affordance. The test is the SCOPE (`ut_*`), which is also + * the only scope with a rows endpoint to POST to — so the affordance and the capability come + * from the same fact rather than from two lists that can drift. + * + * ⚠ THE ROW IS NOT ADDED LOCALLY. `rawRows` is the server's answer, and a client-invented row + * would have no rid, no defaults and no place in anyone else's copy. The POST clears the rows + * cache and fires `ROWS_STALE_EVENT`, `useCustomerData` re-reads, and the row arrives with the + * id the store gave it. The cursor then follows it (`newRowPid` + the effect below) — which is + * why the pid is remembered instead of glide's `"bottom"` being returned here: at the moment + * this resolves, the appended row does not exist yet, so "bottom" would land on the last OLD + * row. + */ + const isUserTable = !embedded && scope.startsWith("ut_"); + const recordsMutable = payload?.recordsMutable !== false; + const canMutateRecords = isUserTable && recordsMutable; + /** + * ⭐ 2026-08-07 — the databases a `link` column may point at, fetched when the column menu + * OPENS rather than on every render of the grid. + * + * ⚠ ON DEMAND IS THE WHOLE DESIGN. `GET /tables` walks every table in the tenant and returns + * their full field lists; hanging that off the grid's mount would put a workspace-wide read + * behind every page view for a picker almost nobody opens. The column menu is the only surface + * that needs it, so it is the surface that asks. + * ⚠ The list is left standing once fetched — a database created in another tab mid-session is + * a staleness a menu re-open corrects, and re-fetching per open would spend the same read + * repeatedly for a list that changes about once a week. + */ + const [linkTargets, setLinkTargets] = useState([]); + useEffect(() => { + // ⛔⛔ 2026-08-09 — `plusMenu` TOO, AND THAT OMISSION WAS THE WHOLE BUG. Owner: *"I am not + // able to see Choose Column for Post rows link"*. There are TWO doors into this editor — + // a column's own menu (`columnMenu`) and the "+" Add-field button (`plusMenu`) — and this + // effect knew about one. Via "+", `linkTargets` stayed `[]`, so `target` never resolved and + // the rollup Column picker held nothing but its placeholder, permanently. Editing an + // EXISTING column worked, which is what made it look like a rollup bug rather than a fetch + // that never fired. MEASURED on the live app: 5 selects rendered, "Column to roll up" with + // exactly 1 option while `GET /tables` returns `ut_ig_posts` with 22 fields. + // ⚠ A picker that is EMPTY and a picker whose data never loaded look identical, which is + // why this survived: both render "Choose a column…" over nothing. + if ((!columnMenu && !plusMenu) || linkTargets.length) return; + let live = true; + void fetchLinkTargets().then((t) => { + if (live) setLinkTargets(t); + }); + return () => { + live = false; + }; + }, [columnMenu, plusMenu, linkTargets.length]); + /** + * ⭐⭐ 2026-08-09 — the READ-THROUGH rollup's offer, fetched on the same terms as the link + * targets above: only when the column menu opens, and once per session. + * + * ⚠ THE GUARD IS A SEPARATE `asked` FLAG, NOT `topics.length`. An empty offer is the CORRECT + * and common answer (a tenant with nothing connected), so guarding on the length would re-ask + * on every single menu open for exactly the workspaces where the answer can never change — + * the one case the "fetch once" rule exists for. + */ + const [rollupSourceOffer, setRollupSourceOffer] = + useState({ topics: [], windows: [] }); + const rollupOfferAsked = useRef(false); + useEffect(() => { + // ⛔ SAME TWO DOORS as the link targets above. This one is less visible because an empty + // offer is the correct answer for a tenant with nothing connected — so a source offer that + // never loaded is indistinguishable from one that is legitimately empty, and the mode switch + // simply never appears. Fixing only the link half would have left that asymmetry in place. + if ((!columnMenu && !plusMenu) || rollupOfferAsked.current) return; + rollupOfferAsked.current = true; + let live = true; + void fetchRollupSources().then((o) => { + if (live) setRollupSourceOffer(o); + }); + return () => { + live = false; + }; + }, [columnMenu, plusMenu]); + /** + * ⭐ WAVE 27 · OWNER ITEM 22 — the values the ACTIVE VIEW forces on a new record. + * + * ⛔ THE DEFECT: "+" posted `{values: {}}` on every view, so a record added while a filter was + * on was created, was real, and was invisible — the button read as broken and the row the user + * then typed into did not exist as far as they could see. `filterSeed.ts` owns the derivation + * and its whole design is how much it REFUSES to derive (negations, ranges, `or` branches, + * contradictions, machine columns — each with its reason at the clause). + * + * ⚠ ONE FUNCTION, TWO CALLERS, deliberately: item 2's optimistic add must insert the row + * carrying these same cells, or the row it paints locally fails the filter it was added under + * and disappears on the next read — the exact blip C2 exists to prevent. + */ + const seedValues = useCallback( + () => filterSeedValues(config.filters, config.filterConj, fields, payload?.viewer), + [config.filters, config.filterConj, fields, payload?.viewer] + ); + /** + * ⭐ WAVE 27 · OWNER ITEM 2 (contract C2) — the "+" paints its row NOW. + * + * ⛔ THE ID IS MINTED THE WAY THE SERVER MINTS IT — `max(numeric row id) + 1`, which is + * `core.user_tables.add_row`'s own rule — and then POSTed as `{rid, values}` through the door + * the undo path already opened (C-ADDROW). That is what makes the optimism honest rather than + * hopeful: on the overwhelmingly common single-writer path the server stores exactly the id + * this browser drew, so the row on screen and the row in the store are the same record from + * the first frame. + * + * ⚠ AND WHEN THEY ARE NOT, IT RE-ANCHORS. Two people adding at once means the second POST + * finds the id taken; `add_row` falls back to `max+1` and ANSWERS with what it actually wrote + * (its own note says so), so the local row is re-keyed to the returned pid. Assuming the + * requested id came back is the one way this could leave a row on screen that no longer + * matches anything in the store. + * + * ⛔ NO `ROWS_STALE_EVENT` ON THE HAPPY PATH. That event re-reads the whole table, which is + * the cost item 2 exists to remove — and re-reading would also un-paint the row for the + * duration of the fetch, which is the NO-BLIP law's exact subject. The pending row is pruned + * by the effect above when a payload that already contains it arrives, from whatever cause. + * + * ⚠ THE NARROW RACE, STATED: a cell PATCHed within the POST's round trip could reach the + * server before the row exists. The window is now ~20ms (A moved the relation refresh off + * this path), it needs a keystroke inside it, and the PATCH's failure is a rolled-back + * overlay edit rather than lost data — the alternative, blocking the paint until the POST + * resolves, is the defect being fixed. + */ + const appendRow = useCallback(async (): Promise => { + if (!canMutateRecords) return undefined; + const seeded = seedValues(); + const minted = mintRid(); + const optimistic: Row = { ...seeded, pid: minted }; + setPendingRows((cur) => [...cur, optimistic]); + setNewRowPid(minted); + const made = await addTableRow(scope, seeded, minted); + if (!made) { + // `addTableRow` already said WHY, in the server's own words (a row cap, a refused profile + // cell, an unreachable host). The row is withdrawn rather than left standing: a row that + // survives its own failed write is the lie this whole path has to avoid. + setPendingRows((cur) => cur.filter((r) => r.pid !== minted)); + setNewRowPid(null); + return undefined; + } + if (made.pid !== minted) { + setPendingRows((cur) => + cur.map((r) => (r.pid === minted ? { ...r, pid: made.pid } : r)) + ); + setNewRowPid(made.pid); + } + // R4 — the append is undoable: Ctrl+Z deletes the row it just created, and a redo restores + // it under the SAME rid (the server's `{rid}` passthrough, C-ADDROW). + // ⛔ ITEM 22 MOVED THIS LINE. It read `values: {}` with a comment saying "an empty new record + // carries no values" — true until this wave, and now false: a row added on a filtered view is + // born holding the cells that filter forces. `redo` replays `r.values` verbatim + // (`:1569`), so leaving the literal would have made Ctrl+Z → Ctrl+Y restore the row under the + // right id with its seeded cells silently dropped — [[undo-capture-before-the-write]] in its + // quietest form, since the id and the row count would both be right. + undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), { + kind: "rowAdd", table: scope, rows: [{ rid: made.rid, values: seeded }], + }); + // ⛔ NO `signal(ROWS_STALE_EVENT)` HERE ANY MORE (item 2 / C2). It re-read the whole table + // on every "+", which is the whole cost the owner reported — and every OTHER caller of it + // still fires, so a row created by an automation, another tab or an undo still arrives. + // The one thing that used to depend on this refetch was the cursor landing on the new row, + // and the row is now on screen before the POST resolves, so it lands immediately. + // `undefined` is returned rather than glide's "bottom": "bottom" is resolved against the + // rows glide knew about when the gesture started, which is the row BEFORE this one. + return undefined; + }, [canMutateRecords, scope, seedValues, mintRid]); + + /* wave20 item 2 — measure key + window -> the columns that display it. Built ONCE per field + list and handed to every consumer of "which column is this rule about", so the tint + (`columnTones`, inside the hook above) and the column menu's filter doors resolve a + measure condition the same way. See `types.ruleColumnKeys`. */ + const measureCols = useMemo(() => measureColumnIndex(fields), [fields]); + + // Airtable behavior: configuration changes to the active view autosave. + useEffect(() => { + // ⭐⭐ W35-T23 — a Query view AUTOSAVES like any other view; only its TRANSPORT differs. + if (!workspaceReady || embedded) return; + const active = views.find((view) => view.id === activeViewId); + if (!active || sameConfig(active.config, config)) { + setSaveState("saved"); + return; + } + setSaveState("saving"); + if (saveTimer.current !== null) window.clearTimeout(saveTimer.current); + saveTimer.current = window.setTimeout(() => { + const updated = { ...active, config }; + setViews((current) => + current.map((view) => (view.id === updated.id ? updated : view)) + ); + viewWritesRef.current = stampTombstone(viewWritesRef.current, updated.id, + Date.now()); + // ⛔ THE ROUTER, NEVER A BARE `emitHostEvent`. A raw host event carries the MODULE write + // scope, which for a Query surface is the SOURCE database — so autosaving a resize would + // have written a view into the source's own workspace under the artefact's id. The router + // is what keeps "which store does this spec belong to" answered in one place. + // ⛔⛔ WAVE 35 QA — THE SUBJECT OF A QUERY WRITE IS THE ARTEFACT, AND STAMPING IT IS WHAT + // MAKES W35-T23 TRUE. This emitted `updated` unchanged, whose `id` is the SOURCE database's + // active view (the grid mounts on `gridScopeFor(active.source.database)`, so `views` are the + // source's). `routeQueryViewMutation` requires `view.id === binding.artifactId` and the + // server requires the same — so EVERY spec write on a Query surface was refused before it + // left the browser, and the person saw *"A Query view holds one saved answer…"*, the message + // written for a genuine duplicate-view attempt. + // ⚠ MEASURED, not deduced: resize, sort, group, hide and row height all failed identically + // on a pre-wave artefact AND a brand-new one — six for six, zero network writes, `edited` + // never flipping. The owner's item was *"I should be able to interact in each of the View + // under Query as well, exactly like how I would be able to interact with it under Database + // view"*, and the router's own docstring already says these are one thing to it. + // ⚠ ONLY the emitted subject is re-identified. `setViews` and the tombstone keep using + // `updated.id`, because the LOCAL row really is the source's view — the artefact id is who + // the WRITE is about, not who the grid is showing. + const subject = queryBinding ? { ...updated, id: queryBinding.artifactId } : updated; + const routed = routeQueryViewMutation(queryBinding, scope, { + id: eventId(queryBinding ? "query-view-update" : "view"), + type: "view_upsert", + view: subject as unknown as Record, + }, { query: mutateQueryWorkspace, native: emitHostEvent }); + if (routed.channel === "refused") + signal(TOAST_EVENT, routed.refusal?.message ?? "That view change could not be saved."); + // ⚠ REPORTED, not swallowed. A write that fails in silence reads exactly like a read that + // never happened [[lost-write-looks-like-failed-read]], and this is the one path a person + // triggers by dragging a column edge. + else if (routed.channel === "query") + void routed.result.then((result) => { + if (!result.ok) signal(TOAST_EVENT, result.message); + }); + setSaveState("saved"); + saveTimer.current = null; + }, 420); + return () => { + if (saveTimer.current !== null) window.clearTimeout(saveTimer.current); + }; + }, [workspaceReady, activeViewId, config, views, embedded, queryBinding, scope]); + + // Numeric dimensions force a glide relayout when either the component frame + // or Streamlit's main column changes width (notably sidebar collapse). + useLayoutEffect(() => { + const element = gridBoxRef.current; + if (!element) return; + const measure = () => { + const rect = element.getBoundingClientRect(); + if (rect.width > 0 && rect.height > 0) { + setGridSize({ + width: Math.floor(rect.width), + height: Math.floor(rect.height), + }); + } + }; + measure(); + const observer = new ResizeObserver(measure); + observer.observe(element); + window.addEventListener("resize", measure); + return () => { + observer.disconnect(); + window.removeEventListener("resize", measure); + }; + }, [workspaceReady]); + + // CG-3: one mode per table, decided by whether the payload is a window. See types.ts + // `TableMode` for why both engines running at once is the failure this prevents. + const mode = tableMode(payload?.counts); + const serverWindowed = mode === "server-windowed"; + // Owner items 4+6 — the Cohort page's grid renders without the Views sidebar. + const hideViews = !hostWorkspace || payload?.workspace?.hideViews === true; + // Wave-6 item 10 — how this view displays. A WINDOWED table is always the grid: list/ + // calendar/kanban compute over the whole matched set, and one page is not it (CG-3's rule, + // the same reason grouping is off there). + const displaySpec = cleanDisplay(config.display); + const displayMode: DisplayMode = serverWindowed ? "grid" : displaySpec?.mode ?? "grid"; + + // ═══════════════════════════════════════════════════════════════════════════════════════ + // ⭐⭐ WAVE 30 · W30-T42 (contract C2) — PAGING A READ-THROUGH GRID. + // ═══════════════════════════════════════════════════════════════════════════════════════ + // + // The client engine is already refused in this mode (`useVisibleRows` passes rows straight + // through), which is C2's "F may render no client-side predicate over a windowed grid". That + // clause on its own would leave a filter chip INERT: the user narrows 32,826 orders, nothing + // filters, and the count keeps reading 32,826 — honest about the scope and silent about the + // question. So the predicate has to reach the evaluator that CAN answer it, which is SQL. + // + // ⛔ THE SAVED VIEW'S OWN OBJECTS GO ON THE WIRE, UNTRANSLATED (see `windowRowsPath`). + const windowPredicate = useMemo( + () => windowPredicateKey(config.filters, config.filterConj, config.sorts, search), + [config.filters, config.filterConj, config.sorts, search] + ); + const windowRequest = useCallback( + (offset: number) => ({ + offset, + limit: WINDOW_ROWS, + filters: config.filters, + filterConj: config.filterConj, + sorts: config.sorts, + search, + }), + [config.filters, config.filterConj, config.sorts, search] + ); + const sentPredicate = useRef(null); + useEffect(() => { + if (!serverWindowed) { + sentPredicate.current = null; + return; + } + if (sentPredicate.current === windowPredicate) return; + // ⚠ THE FIRST WINDOW WENT OUT WITH NO QUERY ARGS AT ALL — `config` is this component's own + // state, seeded from a workspace call that had not landed when the rows request left. So a + // view carrying no filter, no sort and no search has ALREADY been answered, and firing here + // would spend a round trip to receive the bytes on screen. Any other predicate is a real + // question and goes out. One function owns both the key and the empty case, so "no + // predicate" cannot be spelled two ways. + if (sentPredicate.current === null && windowPredicate === EMPTY_WINDOW_PREDICATE) { + sentPredicate.current = windowPredicate; + return; + } + sentPredicate.current = windowPredicate; + requestWindow(windowRequest(0)); + }, [serverWindowed, windowPredicate, windowRequest, requestWindow]); + + /** + * The scroll → next window. glide reports the visible RECTANGLE; `nextWindowOffset` decides. + * + * ⚠ `counts.shown` IS THE LOADED COUNT, not `rawRows.length`. They are the same number today + * and would stop being one the moment anything layers a row in that the server did not send + * (`pendingRows` does exactly that on editable tables) — and the offset this produces is a + * promise to the server about where our contiguous run ends. It has to come from the merge + * that built the run. + */ + const onVisibleRegionChanged = useCallback( + (range: Rectangle) => { + if (!serverWindowed) return; + const offset = nextWindowOffset({ + lastVisibleRow: range.y + range.height, + loaded: payload?.counts?.shown ?? 0, + matched: payload?.counts?.matched ?? 0, + limit: WINDOW_ROWS, + }); + if (offset !== null) requestWindow(windowRequest(offset)); + }, + [serverWindowed, payload?.counts?.shown, payload?.counts?.matched, requestWindow, + windowRequest] + ); + + // CG-8 — measure conditions, answered by the host as `{ruleId: pid[]}`. Converted to Sets + // once here rather than per row: the pipeline runs this over every row of the book. + // + // ⚠ REPLACED, not merged. Merging looks like the safer choice — "hold the previous result" + // is the design — but it holds it FOREVER: the host omits a rule's id when it could not + // resolve it (a warming store, a scope change mid-flight), and a merged map keeps yesterday's + // answer under today's question with `pendingMeasures` reporting zero, so the marker never + // shows. That is the widening sin wearing a confident count. + // + // Holding the previous result across a LOCAL edit does not need the merge and never did: the + // memo is keyed on `payload.measureSets` identity, which does not change while the user is + // typing, so the ref already carries the last answers through to the next payload. Merge and + // replace therefore differ only in the dangerous case. + const measures = useMemo(() => payload?.measures ?? [], [payload?.measures]); + const measureSetsRef = useRef({}); + const measureSets = useMemo(() => { + const incoming = payload?.measureSets; + if (incoming) { + const next: MeasureSets = {}; + for (const [ruleId, pids] of Object.entries(incoming)) next[ruleId] = new Set(pids); + measureSetsRef.current = next; + } + return measureSetsRef.current; + }, [payload?.measureSets]); + const pendingMeasureCount = useMemo( + () => pendingMeasures(config.filters, measureSets), + [config.filters, measureSets] + ); + // BUG-1 (wave 11) — the two key sets, published for the handshake. See the `.cg-shell` + // attributes below for why they are DOM attributes and not console logs. + const measureRuleKeys = useMemo( + () => activeMeasureRuleIds(config.filters).join(","), + [config.filters] + ); + const measureSetKeys = useMemo(() => Object.keys(measureSets).join(","), [measureSets]); + + // Owner item 5. Cohort membership rides `workspace.lists`, the same array the "Add to list" + // picker already reads, so the names offered and the sets tested come from one place. A + // cohort with no `pids` is a target you can add to but cannot filter on; the engine treats it + // as unanswerable (matches nothing) rather than absent (matches everything). + const lists = useMemo(() => payload?.workspace?.lists ?? [], [payload?.workspace?.lists]); + /* wave17 R1 / C-LOCKV — `railCohortId` is gone. It held the TRANSIENT lock the retired + Cohorts section applied on click: a lock that lived in component state and was never + persisted. A locked view now carries its own `config.cohortLock`, so SELECTING the view + is the lock and the saved config is the only source. One mechanism, and it survives a + reload — which the transient one never did. */ + const cohortSets = useMemo(() => { + const out: CohortSets = {}; + for (const l of lists) if (l.pids) out[l.id] = new Set(l.pids); + return out; + }, [lists]); + // The TENANT'S today. Never `new Date()`: a browser a day ahead of the server would resolve + // "the past month" to a different month than the host summed, and the count would deny the + // rows with nothing erroring. + const today = payload?.today; + // Wave-5 item 1 — who is looking (host-computed). ONE editability verdict for every edit + // door (cells, pickers, drawer): types.mayEditField — stratum + read-only-by-nature + + // permissions vs the viewer, fail-closed on restricted fields when the viewer is unknown. + const viewer = payload?.viewer; + const canEditField = useCallback( + (f: Field): boolean => !embedded && recordsMutable && mayEditField(f, viewer), + [embedded, recordsMutable, viewer] + ); + + // Wave-5 items 9/11 — CLIENT-COMPUTED cells, injected AT their field keys so the whole + // pipeline (filter/search/sort/group), the cells and the drawer read them like any other + // value. Formulas parse ONCE per definition change (never per row); evaluation reads the + // row WITH this session's overlay edits layered, so editing a referenced field recomputes + // live. A formula that does not parse, or errors on a row, yields BLANK — never a wrong + // number (formulaEngine.ts). `created_time` copies the row's `_created`. + // 2026-07-31 (owner item 2): formulas may reference OTHER formulas now, so parse order is + // TOPOLOGICAL (orderFormulas) — a formula runs after the formulas it reads, cycle members + // never run (blank, never a stale number), and each row's results feed the next formula + // through a per-row scope. + const formulaAsts = useMemo(() => { + const sources = new Map(); + for (const f of fields) { + if (f.type !== "formula") continue; + const src = formulaOf(f); + if (src) sources.set(f.key, src); + } + const { order, cyclic } = orderFormulas(sources); + const out: { key: string; ast: FormulaAst }[] = []; + for (const key of order) { + if (cyclic.has(key)) continue; + const p = parseFormula(sources.get(key)!); + if (p.ok) out.push({ key, ast: p.ast }); + } + return out; + }, [fields]); + const createdTimeKeys = useMemo( + () => fields.filter((f) => f.type === "created_time").map((f) => f.key), + [fields] + ); + const computedRows = useMemo(() => { + if (formulaAsts.length === 0 && createdTimeKeys.length === 0) return rawRows; + const env = { today: payload?.today }; + return rawRows.map((r) => { + const edits = overlayEdits[r.pid]; + const scope: Row = edits ? { ...r, ...edits } : { ...r }; + const out: Row = { ...r }; + for (const k of createdTimeKeys) { + out[k] = (r._created as string | undefined) ?? null; + scope[k] = out[k]; + } + for (const { key, ast } of formulaAsts) { + const v = evalFormula(ast, (k) => scope[k], env); + out[key] = v; + scope[key] = v; // later formulas read this one's result — the topo order above + } + return out; + }); + }, [rawRows, overlayEdits, formulaAsts, createdTimeKeys, payload?.today]); + + /** + * ⭐⭐ W37-T26 / CONTRACT C4 — `view id -> the pids that view resolves to`, for the VIEW + * MEMBERSHIP leaf (`Where View is [some view]`). + * + * ⛔ RESOLVED LAZILY, AND ONLY WHAT THE ACTIVE VIEW ACTUALLY NAMES. Resolving every view on + * every payload change would run N pipelines over the whole row set on each keystroke, on a + * grid whose FIRST-OPEN SPEED is another item of this same wave. `viewRefsOf` is usually empty, + * and then this memo allocates one object and returns. + * + * ⛔ RE-ENTRY IS THE TRAP AND `resolving` IS THE ANSWER. `viewFilterCycle` refuses a cycle at + * SAVE time, so a well-formed store has none — but a view saved BEFORE this ticket, or written + * by a caller that skipped the guard, can still contain one, and a resolver that trusted the + * guard would hang the browser. A view re-entered while it is being resolved is left ABSENT, + * which `evalNode` reads as unanswerable (matches nothing) and `unresolvedConditions` counts, + * so a stored loop degrades to an explained empty table rather than a frozen tab. + * + * ⚠ SKIPPED ENTIRELY ON A SERVER-WINDOWED GRID. There the SQL engine already filtered and this + * pipeline is a pass-through, so any set computed here would describe the WINDOW rather than + * the scope. The leaf is not offered there either (see the picker's `canFilterByView`), which + * is the honest state rather than a set that quietly means something else. + */ + const viewRefs = useMemo(() => viewRefsOf(config.filters), [config.filters]); + const viewSets = useMemo(() => { + const out: Record> = {}; + if (serverWindowed || viewRefs.length === 0) return out; + const byId = new Map(views.map((v) => [v.id, v])); + const resolving = new Set(); + const resolve = (id: string): Set | undefined => { + if (out[id]) return out[id]; + const view = byId.get(id); + if (!view || resolving.has(id)) return undefined; + resolving.add(id); + // A nested reference resolves through this same function, so `A is in B` where B is itself + // filtered on C answers correctly rather than treating B as empty. + const nested: Record> = {}; + for (const ref of viewRefsOf(view.config.filters)) { + const set = resolve(ref); + if (set) nested[ref] = set; + } + const { visibleRows: matched } = runPipeline({ + rawRows: computedRows, + fields, + filters: view.config.filters, + search: "", + sorts: view.config.sorts, + groupBy: null, + collapsed: new Set(), + memberPids: view.config.memberPids, + filterConj: view.config.filterConj ?? "and", + measureSets, + cohortSets, + viewSets: nested, + today, + }); + resolving.delete(id); + const pids = new Set(); + for (const r of matched) if (r.kind === "data") pids.add(r.record.pid); + out[id] = pids; + return pids; + }; + for (const id of viewRefs) resolve(id); + return out; + }, [viewRefs, views, serverWindowed, computedRows, fields, measureSets, cohortSets, today]); + /** + * ⭐⭐ W37-T26 / CONTRACT C4 — **THE CYCLE REFUSAL, AND IT IS THE TICKET'S `done-when` RATHER + * THAN A GUARD BOLTED ON AFTERWARDS.** View X filtered on "is in View Y" where Y is filtered on + * X cannot be resolved by anything: X needs Y needs X. This is the last moment a PERSON is + * present, so this is where it is refused, with the loop named. + * + * ⛔ IT REPLACES THE SETTER RATHER THAN WRAPPING IT. An `onFilters` that accepted the tree and + * then repaired it would leave the panel showing a condition the config does not carry, which + * reads as "it does not save" — the failure this file has paid for twice. Refused means the + * tree is not applied at all and the person is told why. + * ⚠ The HOST refuses the same shape (`aios_grid.view_filter_cycle`, called by + * `routes_grid.py`), because this guard is a browser's and a caller that POSTs a view directly + * never runs it. + */ + const applyFilters = useCallback( + (filters: FilterNode[]) => { + const id = activeViewId; + if (id) { + const cycle = viewFilterCycle(id, filters, (other) => + views.find((v) => v.id === other)?.config.filters); + if (cycle) { + const names = cycle.map((vid) => + views.find((v) => v.id === vid)?.name ?? "a view that is not here"); + signal(TOAST_EVENT, + `That would make a loop: ${names.join(" refers to ")}. A view cannot filter on a ` + + `view that filters on it, because neither can be worked out without the other.`); + return; + } + } + updateConfig({ ...config, filters }); + }, + [activeViewId, views, config, updateConfig] + ); + + const unresolvedCount = useMemo( + // ⭐ W37-T26 (C4) — `viewSets` rides this context too. Without it a view leaf would ALWAYS + // count as unresolved (the chip would cry wolf on a working filter) and, worse, a leaf naming + // a DELETED view would look identical to one naming a live one. + // ⭐⭐ D-229 — `fieldByKey` rides this context so a condition naming a column this table no + // longer has is COUNTED. `evalNode` ignores such a leaf (correctly), which means the grid was + // showing MORE rows than the filter panel claimed with nothing on screen saying so. + () => unresolvedConditions(config.filters, { cohortSets, viewSets, fieldByKey, today }), + [config.filters, cohortSets, viewSets, fieldByKey, today] + ); + + // Wave-2 item 2c — COHORT MODE (the Cohort page). The host serves the WHOLE pool (rows + + // derived values over the pool); the ACTIVE cohort scopes the table to its pids CLIENT-side. + const cohortMode = payload?.workspace?.cohortMode === true; + useEffect(() => { + if (!cohortMode) return; + if (activeCohortId && lists.some((l) => l.id === activeCohortId)) return; + setActiveCohortId(lists[0]?.id ?? null); + }, [cohortMode, lists, activeCohortId]); + const activeCohort = cohortMode + ? lists.find((l) => l.id === activeCohortId) ?? null + : null; + const cohortMemberSet = useMemo( + () => new Set(activeCohort?.pids ?? []), + [activeCohort] + ); + // The FIXED subset is the scope. A cohort with no membership shows nothing — falling back to + // the whole pool would put 1,500 rows under a rail entry that says 12, which is the widening + // sin wearing a sidebar. Everything downstream (pipeline, counts, selection, detail) runs + // over this scope, so the toolbar count is the cohort's matched count by construction. + const scopedRows = useMemo( + () => + cohortMode + ? computedRows.filter((r) => cohortMemberSet.has(r.pid)) + : computedRows, + [cohortMode, computedRows, cohortMemberSet] + ); + + const { visibleRows, pidToIndex } = useVisibleRows( + scopedRows, + fields, + config.filters, + search, + config.sorts, + // grouping a WINDOW would headline a page's count/subtotals as the group's + serverWindowed ? null : config.groupBy, + collapsed, + config.memberPids, + config.filterConj, + serverWindowed, + measureSets, + cohortSets, + today, + // Item 12 (C-LOCK) — the 14th positional, RECORD's engine input. The lock intersects + // FIRST, so everything downstream (conditions, memberPids, the ranking domain) operates + // inside the cohort. Absent = today's behaviour exactly. + // wave17 R1 / C-LOCKV: the view's SAVED lock is now the only source. A locked view is a + // saved view whose `config.cohortLock` names its own id, so opening it IS applying the + // lock — the wave-15 transient rail pick that used to outrank this is gone with its rail. + config.cohortLock + ); + + // Owner item 8 — the DISPLAY cap. The pipeline above still ran over the whole book (counts, + // "Add to list" and selection-by-pid all depend on that); only what glide PAINTS is sliced. + // pidToIndex is rebuilt over the slice because every index consumer (selection, detail + // navigation, hover) is positional against what is actually on screen. + const [displayCap, setDisplayCap] = useState(DISPLAY_PAGE); + /** + * ⛔ W30-T42 — NO CLIENT CAP ON A WINDOWED TABLE, AND THE TWO CAPS WOULD HAVE FOUGHT. + * + * The display cap exists because the whole-book path holds every row and glide should not be + * asked to lay out 33,000 of them at once. In `server-windowed` mode the WINDOW is already + * that bound — one page of `WINDOW_ROWS` — so a second cap on top would stop the grid 50 rows + * in, under a "Showing first 50 of 200" bar, while the toolbar says "showing 200 of 32,826". + * Two truncation stories about one table, neither of them wrong, together a lie. + * + * ⛔ AND IT WOULD HAVE BROKEN THE PAGING OUTRIGHT: the scroll decision reads how far down the + * LOADED rows the viewport reaches, and a capped grid can never scroll past the cap, so the + * end of the window would be unreachable and the next window never requested. + */ + const capped = !serverWindowed && visibleRows.length > displayCap; + /** + * ⭐⭐ WAVE-29 T33 (owner item 17) — THE TOTALS ROW, and the cap fix it forced. + * + * ⛔ THE SUMMARY IS OVER ALL M MATCHED ROWS, NEVER THE N PAINTED. The pipeline above already + * ran over the whole book; only what glide paints is sliced. A total computed from the slice + * would silently describe the first `DISPLAY_PAGE` records while sitting under a bar that says + * "Showing first N of M" — an answer to a question nobody asked, wearing the right label. + * + * ⛔ AND THE SLICE ITSELF WAS ALREADY WRONG FOR GROUPS. `visibleRows` is FLATTENED (header, + * rows, footer, header, …), so a straight `slice(0, cap)` could cut a group between its last + * row and its footer — the subtotal simply vanished, for the groups furthest down, with no + * marker of any kind. `sliceForDisplay` counts DATA rows toward the cap and keeps the + * structural rows of every group it admits, so a group is whole or absent. + */ + const totalsAggs = useMemo( + () => computeAggs( + visibleRows.flatMap((vr) => (vr.kind === "data" ? [vr.record] : [])), + fields + ), + [visibleRows, fields] + ); + /** + * ⭐⭐ WAVE 30 · W30-T42 (contract C2 / the ticket's own named trap) — THE TOTALS ROW OVER A + * WINDOW IS DISCLOSED, NOT DELETED. + * + * ⛔ THE TRAP. `totalsAggs` folds `visibleRows`, which in `server-windowed` mode is exactly the + * rows this browser has loaded. Painted in the table's footer with no denominator, "the sum of + * whatever happens to be in memory" wears the sum of 32,826 orders — a fabricated aggregate, + * the thing [[no-unverifiable-aggregates]] exists to forbid. Grouping is refused in this mode + * for the same reason (a group header over a window subtotals the PAGE while claiming to + * describe the group), and that refusal stays: a group is a claim about a SET, and the client + * cannot see the set. + * + * ⛔ BUT ABSENT IS NOT ONE OF THE TWO HONEST STATES. T42's done-when offers exactly two — + * "reads from the server" or "says plainly that it covers the loaded window" — and the wire + * carries no aggregates (`rows/total/totalUnfiltered/offset/limit/limits`), so the second one + * is the one available. The fold is CORRECT for a question nobody asked; naming the question + * is what makes it honest, and the loaded rows are on screen and scrollable, so the number + * still drills to rows. The disclosure is `windowedFoldNote`, and it is bound to `showTotals` + * by ONE memo below rather than by two conditions that could drift apart — a totals row over a + * window with its sentence missing is the whole defect back again. + */ + const showTotals = useMemo( + () => !config.groupBy && Object.keys(totalsAggs).length > 0, + [config.groupBy, totalsAggs] + ); + /** The denominator sentence — non-null EXACTLY when a totals row is painted over a window that + * does not hold the whole matched set. Null on a whole-book table (the total is total) and on + * a window that happens to hold everything (`windowedFoldNote` refuses to invent a + * truncation that is not there). */ + const foldNote = useMemo( + () => + serverWindowed && showTotals + ? windowedFoldNote(payload?.counts?.shown ?? 0, payload?.counts?.matched ?? 0) + : null, + [serverWindowed, showTotals, payload?.counts?.shown, payload?.counts?.matched] + ); + /** R6's SECOND SENTENCE, on the client side of the wire: the limits D's route DECLARED on this + * response. Received and painted nothing = the limit is silent again, one layer further out. */ + // ⭐ W32-T03: `counts` rides along so the clamped-window clause can say "Showing 5,000 of + // 963,783 rows" rather than naming the server's own word for what was limited. Optional on the + // callee, so the sentence degrades to its cause rather than to silence when a payload has none. + const limitNote = useMemo( + () => limitSummary(payload?.limits, payload?.counts), + [payload?.limits, payload?.counts] + ); + /** + * ⛔ AND THE LIMIT NOBODY DECLARED, WHICH IS THE ONE A PERSON ACTUALLY NOTICES. Nine controls + * this component gates on `serverWindowed` vanish the moment a grid becomes a window — export, + * cohorts, folders, grouping, the four alternative views, select-from-file, alert badges — all + * of which were working on this grid the day before, because the whole table used to be in the + * browser. Every refusal is right on its own; a screen where eight buttons quietly disappear + * is not. See `windowedCapabilityNote` for the list and why it is not a guess. + */ + const capabilityNote = useMemo( + () => (serverWindowed ? windowedCapabilityNote(payload?.counts?.matched ?? 0) : null), + [serverWindowed, payload?.counts?.matched] + ); + /** + * ⭐⭐ W35-T24 (owner item 4 / R2, R6's second sentence) — WHY THERE IS NO TRAILING "+". + * + * ⛔ NOT SCOPED TO QUERY, DELIBERATELY, AND THAT IS WIDER THAN THE TICKET ASKED. The predicate + * is the SOURCE's (`records_mutable`, DESIGN.md §4's locked-database vocabulary), so the answer + * belongs to the database rather than to the surface looking at it. Scoping the sentence to + * Query would make one database say two different things depending on which door you opened it + * through, which is the failure a shared predicate exists to prevent. + * + * ⚠ `embedded` IS EXEMPT. A linked-record grid inside a modal has no "+" because it is a + * picker, not a table, and a footnote apologising for that would be chrome in a dialog. + */ + /** + * ⭐ W35-T30 (C5) — the list the RAIL renders: this workspace's views, plus the server's + * "Starred records" projection when there is one. See the `views=` prop below for why it is + * appended here and not merged into `views`. + */ + /** + * ⭐⭐ W36-T04 (owner item 6 · R3 · contract C3) — THE CODE-SCRIPT VIEWS OF THIS DATABASE. + * + * ⛔ THEY ARE NOT WORKSPACE VIEWS, and that is the design rather than a shortcut. A script + * view IS its source and its version history, which live in E's own per-database store + * (`/api/v1/script-views`); a workspace view holds a filter tree and a column order, and the + * two have nothing in common but a name in a rail. Keeping them apart is also what makes R3's + * "unlimited versions per database, so an agent can leave three attempts side by side" + * expressible at all, and it is why `aios_grid.DISPLAY_MODES` never needs the word `script` + * (see the hold recorded in `types.ts` and in `icons.test.ts::HELD_MODES`). + * + * ⚠ THE SAME SHAPE THE STARRED PROJECTION USES: a row that JOINS the rail without being a + * stored view, resolved by id here rather than in the rail. One precedent, not two. + * ⚠ `hostWorkspace` only. A Query surface draws one artefact and has no database of its own + * to scope scripts to; an embedded picker is not a place to run anything. + */ + const [scriptRows, setScriptRows] = useState([]); + const [scriptBump, setScriptBump] = useState(0); + useEffect(() => { + if (!hostWorkspace || !isUserTable) return; + let live = true; + void listScriptViews(scope).then((answer) => { + if (live) setScriptRows(answer.views); + }); + return () => { + live = false; + }; + }, [hostWorkspace, isUserTable, scope, scriptBump]); + // The mode is what makes the rail draw the right icon, label and tone for it, AND what makes + // the grid stand down when one is open. Nothing ever sends this config to `view_upsert`, which + // is why the host mirror is not needed. + const scriptProjections = useMemo( + () => scriptRows.map(scriptProjectionView), + [scriptRows] + ); + /** + * ⛔ OPENING A SCRIPT VIEW IS A SELECTION, NOT A STATE POKE. `setActiveViewId` alone mounts the + * panel and leaves `config` on the previous view, so `displayMode` stays `"grid"` and the GRID + * PAINTS UNDERNEATH IT. This is the same work `selectView` does, reachable from the two doors + * that hold the row already (the panel's list, and the create that follows a request) and + * therefore cannot wait for `scriptProjections` to re-render. + */ + const openScriptView = useCallback((row: { id: string; name: string }) => { + const view = scriptProjectionView(row); + setActiveViewId(view.id); + setConfig(view.config); + setCollapsed(new Set()); + setSearch(""); + setDetailPid(null); + // ⚠ AND THE DISPLAY CAP, which `selectView` also resets. The script panel renders no rows, so + // nothing here reads it — but leaving it out would make this opener a FIVE-of-six copy of a + // six-step reset, and the next person could not tell the omission from an oversight. Symmetry + // is cheaper than the comment explaining why it is missing. + setDisplayCap(DISPLAY_PAGE); + }, []); + const railViews = useMemo(() => { + // ⛔ THE STARRED PROJECTION KEEPS ITS OWN STATEMENT, VERBATIM, AND IS NOT FOLDED INTO THE + // EXPRESSION BELOW. `verify_grid_ux.py`'s record-star scan pins this exact line as the proof + // of D-229's shape ("the row is not drawn at all until something is starred"), and that file + // is in no lane's fence to repair. Rewriting it as a ternary turned the gate red on a claim + // that was still true. Two projections, two statements, one list. + const starredRail = (): SavedView[] => { + const projection = recordStars.stars.view; + if (!projection || views.some((view) => view.id === projection.id)) return views; + return [...views, projection]; + }; + const base = starredRail(); + return scriptProjections.length === 0 ? base : [...base, ...scriptProjections]; + }, [views, recordStars.stars.view, scriptProjections]); + /** The one currently open, if any: its SOURCE is fetched on demand, never with the list. */ + const activeScriptId = scriptRows.some((row) => row.id === activeViewId) ? activeViewId : null; + const [scriptDoc, setScriptDoc] = useState(null); + const [scriptRun, setScriptRun] = useState(null); + const [scriptBusy, setScriptBusy] = useState<"" | "run" | "save">(""); + useEffect(() => { + if (activeScriptId === null) { + setScriptDoc(null); + setScriptRun(null); + return; + } + let live = true; + setScriptRun(null); + void readScriptView(activeScriptId).then((answer) => { + if (live) setScriptDoc(answer.view); + }); + return () => { + live = false; + }; + }, [activeScriptId]); + const onScriptRun = useCallback((draft: string) => { + if (activeScriptId === null) return; + setScriptBusy("run"); + void runScriptView(activeScriptId, draft).then((answer) => { + setScriptRun(answer); + setScriptBusy(""); + }); + }, [activeScriptId]); + /** + * ⭐⭐ W36-T05 (owner item 6) — THE VIEW AGENT PANEL'S OPEN STATE AND ITS ONE WRITE. + * + * ⛔ CLOSED MEANS ABSENT FROM THE TREE, not hidden. F's fold rule is + * `.shell-root:has(.cg-agent-panel) .shell-side` and `:has()` matches an element that EXISTS, + * so a `display: none` panel would hold the main rail folded to 48px over nothing at all + * (wiring W8). The ternary below is therefore load-bearing, not a style choice. + */ + const [agentOpen, setAgentOpen] = useState(false); + const [agentError, setAgentError] = useState(null); + const [agentBusy, setAgentBusy] = useState(false); + const onAgentCreate = useCallback((request: string) => { + setAgentBusy(true); + setAgentError(null); + void createScriptView(scope, seedScript(request), seedName(request)).then((answer) => { + setAgentBusy(false); + if (!answer.view) { + // ⚠ SHOWN IN THE PANEL, not toasted. The reader is looking at the box they just typed + // into; a toast that fades takes the answer away from the question. + setAgentError(answer.error ?? "That script view could not be created."); + return; + } + // R3 — unlimited per database, so there is no cap state to reach. The rail simply grows, + // which is what lets an agent leave three attempts side by side for the owner to compare. + setScriptRows((rows) => [...rows, answer.view as ScriptViewRow]); + openScriptView(answer.view); + setScriptDoc(answer.view); + setScriptRun(null); + }); + }, [scope, openScriptView]); + const onScriptSave = useCallback((source: string) => { + if (activeScriptId === null) return; + setScriptBusy("save"); + void saveScriptView(activeScriptId, source).then((answer) => { + if (answer.view) { + setScriptDoc(answer.view); + // The list carries the version number the rail shows, so a save refreshes it. + setScriptBump((b) => b + 1); + } else if (answer.error) { + signal(TOAST_EVENT, answer.error); + } + setScriptBusy(""); + }); + }, [activeScriptId]); + const lockedNote = useMemo( + // ⚠ The label is the ARTEFACT's source name when there is one, and otherwise nothing: the + // payload carries no database name, and "This database" is unambiguous on a surface whose + // header already says which one it is. Guessing a name from the scope key would print + // `ut_odoo_customers` at somebody. + () => (embedded ? null + : lockedRecordsNote(recordsMutable, isUserTable, queryBinding?.source.label)), + [embedded, recordsMutable, isUserTable, queryBinding] + ); + const displayRows = useMemo(() => { + const shown = capped ? sliceForDisplay(visibleRows, displayCap) : visibleRows; + // ⚠ APPENDED AFTER THE SLICE, so the cap can never eat the totals row itself — and it is the + // LAST row, which is what `freezeTrailingRows={1}` pins. + return showTotals + ? [...shown, + { kind: "group-footer" as const, groupKey: TOTAL_GROUP_KEY, aggs: totalsAggs }] + : shown; + }, [capped, visibleRows, displayCap, showTotals, totalsAggs]); + const displayPidToIndex = useMemo(() => { + if (!capped) return pidToIndex; + const m = new Map(); + displayRows.forEach((vr, i) => { + if (vr.kind === "data" && !m.has(vr.record.pid)) m.set(vr.record.pid, i); + }); + return m; + }, [capped, displayRows, pidToIndex]); + + // C-AVATAR (wave-14 item 11) — profile photos, absent until HOST serves them. The tick is the + // async half: `cells.setAvatarRepaint` fires it when an Image finishes decoding, and it is a + // dep of `getCellContent`, which is the only thing glide watches. See useGetCellContent. + const [avatarTick, setAvatarTick] = useState(0); + useEffect(() => { + setAvatarRepaint(() => setAvatarTick((t) => t + 1)); + return () => setAvatarRepaint(undefined); + }, []); + const userAvatars = payload?.workspace?.userAvatars; + /** + * Item 15 — the frozen strip's usable width, which is what a GROUP BAR's label is clipped to + * now that the first column is pinned in every mode. `null` when nothing is grouped (no bar to + * clip) — see `fitGroupLabel` for why the clip exists and why the count outranks the name. + */ + const groupLabelSpace = useMemo(() => { + if (!config.groupBy) return null; + const n = Math.min(frozenCountOf(config), visibleCols.length); + let px = 0; + // `GridColumn` is a union — only its SIZED member declares `width`, and `useGridColumns` + // always builds that one. Narrowed rather than asserted so an auto-sized column (which we + // do not create) would read 0 and simply not be counted, instead of throwing in a memo. + for (let i = 0; i < n; i += 1) + px += (visibleCols[i] as { width?: number } | undefined)?.width ?? 0; + return Math.max(0, px - GROUP_LABEL_PAD); + }, [config, visibleCols]); + /** + * ⭐ owner item 2 (2026-08-03) — WHICH MEASURE COLUMNS ARE STILL BEING CALCULATED. + * + * A measure column's numbers are resolved by the server (one aggregate over the whole book) + * and reach the browser on the workspace re-read that follows the create — seconds later. + * In between, the column is on screen with nothing in it, which the owner correctly read as + * an error rather than as a wait. + * + * ⛔ `editRequestId` IS THE TEST, and it is the honest one. It is set optimistically at the + * moment the create is emitted and cleared by `reconcileFields` when the host's own copy of + * that field comes back — and the host's copy travels in the SAME `/workspace` response as + * `derived`, which is where the values are. So the flag is true across exactly the window + * where the column exists and its numbers do not, and false the instant they land. + * + * The alternative — "no value anywhere in the column" — cannot tell a pending column from + * one that permanently failed to resolve (a BU-scoped caller on a company-level measure, + * say), and would spin forever on the second. This one resolves either way: when the echo + * arrives with no values, the flag clears and the cells go honestly blank. + */ + const pendingMeasureKeys = useMemo(() => { + const out = new Set(); + for (const f of fields) + if (f.editRequestId && f.key.startsWith("measure_")) out.add(f.key); + return out; + }, [fields]); + /** + * The skeleton's pulse. A canvas cell cannot hold a CSS animation, so the motion is repaints: + * this counter is a dependency of `getCellContent`, which is the only thing glide watches. + * + * ⚠ IT RUNS ONLY WHILE SOMETHING IS PENDING, and the effect's own guard is what stops it — + * an interval left running would repaint the whole canvas ~7×/s forever, on every grid, to + * animate nothing. 140ms × the 4-step ramp is a ~0.6s cycle: a wait, not a strobe. + */ + const [pulse, setPulse] = useState(0); + /** + * ⛔ AND IT GIVES UP. A skeleton that never resolves is worse than the blank it replaced: a + * blank cell is at least honest about having no number, while a permanent shimmer promises + * one that is never coming. + * + * The window it guards is narrow but real. `editRequestId` clears when `reconcileFields` takes + * the host's copy — and that function has a branch (`hostAuthoritative: false`, i.e. a payload + * with no workspace) whose `{...local, ...host}` spread would PRESERVE the flag forever. Today + * that branch cannot be reached with a measure column on screen (measures are offered through + * the workspace, so a payload without one cannot have produced this field), which is an + * argument about the current call graph and not a property of the code. This bound holds + * whether or not the argument stays true, and it costs one boolean. + */ + const [pendingGaveUp, setPendingGaveUp] = useState(false); + useEffect(() => { + setPendingGaveUp(false); + if (pendingMeasureKeys.size === 0) return; + let n = 0; + const id = window.setInterval(() => { + n += 1; + if (n > PULSE_MAX_TICKS) { + window.clearInterval(id); + setPendingGaveUp(true); // fall back to ordinary blank cells + return; + } + setPulse((p) => p + 1); + }, PULSE_MS); + return () => window.clearInterval(id); + }, [pendingMeasureKeys]); + const activePendingKeys = pendingGaveUp ? NO_PENDING_KEYS : pendingMeasureKeys; + const getCellContent = useGetCellContent( + displayRows, + visibleCols, + fieldByKey, + overlayEdits, + canEditField, + userAvatars, + avatarTick, + groupLabelSpace, + measureGroupText, + activePendingKeys, + pulse + ); + const { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid, + setActiveCell, clearSelection } = + useGridSelection( + displayRows, + displayPidToIndex, + visibleCols.length, + embeddedSelectable ? embeddedSelectedIds : [] + ); + const embeddedSelectedKey = embeddedSelectedIds.join(","); + const selectedPidKey = [...selectedPids].join(","); + useEffect(() => { + if (!embeddedSelectable) return; + if (selectedPidKey !== embeddedSelectedKey) + selectPids(embeddedSelectedIds as number[], "replace"); + }, [embeddedSelectable, embeddedSelectedKey, embeddedSelectedIds, selectedPidKey, selectPids]); + useEffect(() => { + if (!embeddedSelectable) return; + onEmbeddedSelectionChange?.([...selectedPids]); + }, [embeddedSelectable, onEmbeddedSelectionChange, selectedPids]); + /* ════════════════════════ owner item 16 / R4 / C-UNDO ════════════════════════ + THE RECORDING LAYER. `undoStack.ts` owns the stack and every inverse; this owns the one + thing it cannot: reading the value a cell held BEFORE the write, which only exists at the + call site. Everything below funnels through `patchAndRecord` / `patchManyAndRecord`, so a + write path that forgets to record is a write path that does not reach the store either. + + ⚠ A REF, NOT STATE. Nothing on screen depends on the stack in v1 (no undo button), so + keeping it in state would repaint the grid on every keystroke of a paste for no pixels. + Per SCOPE (a Ctrl+Z on the Customer grid must never rewrite a user table's cell) and per + tab (nothing persists it — a stack restored into a session that did not make those edits + would undo somebody else's work). */ + const undoBook = useRef({}); + const applyingUndo = useRef(false); + + /** The value a cell holds RIGHT NOW: the overlay stratum wins over the payload row, exactly + * as `useGetCellContent` renders it — so what undo restores is what was on screen. */ + const rowByPid = useMemo(() => { + const map = new Map(); + for (const r of rawRows) map.set(r.pid, r); + return map; + }, [rawRows]); + const currentValue = useCallback( + (pid: number, key: string): UndoValue => { + const edited = overlayEdits[pid]?.[key]; + const raw = edited !== undefined ? edited : rowByPid.get(pid)?.[key]; + return raw == null ? null : (raw as UndoValue); + }, + [overlayEdits, rowByPid] + ); + + const recordCells = useCallback( + (changes: CellChange[], label: string) => { + if (applyingUndo.current || changes.length === 0) return; + undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), { + kind: "cells", label, changes, + }); + }, + [scope] + ); + + /** ONE cell write, recorded. Every editor, picker and drag goes through this. */ + const patchAndRecord = useCallback( + (pid: number, updates: Partial, label = "an edit") => { + const changes: CellChange[] = Object.entries(updates).map(([key, value]) => ({ + pid, key, + before: currentValue(pid, key), + after: (value ?? null) as UndoValue, + })); + patchOverlay(pid, updates); + recordCells(changes, label); + }, + [currentValue, patchOverlay, recordCells] + ); + + /** + * ⭐⭐ W37-T29 / CONTRACT C6 (rulings R2, R10) — E's CHAT, AND EVERY POWER IT ACTS THROUGH. + * + * ⛔ THE PANEL REACHES INTO NOTHING. C6 makes every action a callback this file supplies, so the + * chat can be WALLED by simply not handing it a power — which is why `canEdit` is the caller's + * own mutate right and not a flag the panel decides. E's own T40 note records the matching + * defect on their side: a view that draws a control from the data it is handed, rather than + * from the permission, is a wall that depends on its caller's discipline. + * ⚠ REQUIRED PROPS, all eleven. An optional prop is how a panel ships mounted-but-inert, which + * is this repo's most repeated defect (five whole unreachable features in one wave). + */ + const [chatOpen, setChatOpen] = useState(false); + /** The config to put back when the chat is asked to undo its own view change (E-4 `onUndoView`). + * A snapshot rather than a diff: the chat may change several axes at once, and a per-axis undo + * would restore some of them. */ + const chatUndoRef = useRef(null); + const chatSchema = useMemo( + () => fields.map((f) => ({ key: f.key, label: f.label, type: f.type })), + [fields] + ); + /** + * (a) ANSWER A QUESTION, and it spends NO model call. + * + * ⭐ D-339's lesson is that an agent which cannot read the database answers from words alone and + * invents a number. This one reads the rows the grid already holds, so a counting question gets + * an arithmetic answer rather than a plausible one, and everything else returns + * `unanswerable: true` with an honest sentence instead of prose that sounds like an answer. + * ⚠ This is the FLOOR, not the ceiling: W37-T42 (lane E) widens it, and the published + * `GridChatAnswer` shape does not change when it does. Answering `ASK E-5` in code: option (a), + * the host owns the door, and today the host can answer a real class of question without one. + */ + const onChatDescribe = useCallback( + async (question: string): Promise => { + const q = question.toLowerCase(); + const named = fields.find( + (f) => q.includes(f.label.toLowerCase()) || q.includes(f.key.toLowerCase()) + ); + const total = computedRows.length; + if (!named) { + // ⛔ NAME THE MISS. "There is no such field" is the answer D-339 asks for; silence, or a + // number computed over a column nobody meant, is what it forbids. + return { + text: `I could not find a column in ${topic.nouns} matching that question. This ` + + `database has ${total} records and these columns: ` + + `${fields.map((f) => f.label).join(", ")}.`, + unanswerable: true, + }; + } + const blank = computedRows.filter((r) => { + const v = r[named.key]; + return v === null || v === undefined || String(v).trim() === ""; + }).length; + return { + text: `${blank} of ${total} records have no ${named.label}, so ${total - blank} do. ` + + `Counted over the records this view is showing.`, + }; + }, + [fields, computedRows, topic.nouns] + ); + /** (b) CHANGE THE ACTIVE VIEW. Only the axes E's `readIntent` populates; `filter` is reserved + * and deliberately unread until the condition vocabulary is expressible (E's own note). */ + const onChatApplyView = useCallback( + (spec: GridChatViewSpec) => { + chatUndoRef.current = config; + const next: ViewConfig = { ...config }; + if (spec.sortBy && fieldByKey.has(spec.sortBy)) + next.sorts = [{ colId: spec.sortBy, dir: "asc" }]; + if (spec.groupBy !== undefined) + next.groupBy = spec.groupBy && fieldByKey.has(spec.groupBy) ? spec.groupBy : null; + if (spec.colorBy !== undefined) + next.display = spec.colorBy && fieldByKey.has(spec.colorBy) + ? { ...(next.display ?? { mode: "grid" }), colorField: spec.colorBy } + : next.display; + updateConfig(next); + }, + [config, fieldByKey, updateConfig] + ); + const onChatUndoView = useCallback(() => { + const prior = chatUndoRef.current; + if (!prior) return; + chatUndoRef.current = null; + updateConfig(prior); + }, [updateConfig]); + /** + * (d) WRITE THE CELLS THE PERSON CONFIRMED (R10). + * + * ⛔ REACHED ONLY FROM THE CONFIRM CONTROL, and it goes through `patchAndRecord` — the SAME path + * a typed edit takes — so the undo stack, the event log and the permission check are the ones + * that already exist. A second write path is a second set of rules, and the one that runs last + * silently wins. + */ + const onChatWriteCells = useCallback( + async (edits: GridChatEdit[]) => { + for (const edit of edits) { + const pid = Number(edit.rowId); + const field = fieldByKey.get(edit.field); + if (!Number.isFinite(pid) || !field || !canEditField(field)) continue; + patchAndRecord(pid, { [field.key]: edit.after }, "the chat"); + } + }, + [fieldByKey, canEditField, patchAndRecord] + ); + /** (c) RUN THE CUSTOM VIEW'S CODE. The panel gets what happened, never the spec: E's own rule is + * that a run returns a declarative spec and nothing here interprets a value as code. */ + const onChatRunScript = useCallback( + async (code: string): Promise => { + if (activeScriptId === null) + return { ok: false, error: "Open a Custom View first, then I can run its code." }; + // `runScriptView` answers with the RUN itself, not an envelope: a transport failure and a + // script failure both arrive as `{ok:false, error}`, which is exactly the two-state shape + // `GridChatRunResult` wants. Reported through the panel AND painted in the script view, so + // the person sees the same outcome wherever they are looking. + const run = await runScriptView(activeScriptId, code); + setScriptRun(run); + return { ok: run.ok, error: run.error }; + }, + [activeScriptId] + ); + + /** MANY cells, ONE stack entry — a paste and a bulk clear are each one user action (R4). */ + const patchManyAndRecord = useCallback( + (writes: { pid: number; updates: Partial }[], label: string) => { + const changes: CellChange[] = []; + for (const w of writes) + for (const [key, value] of Object.entries(w.updates)) + changes.push({ + pid: w.pid, key, + before: currentValue(w.pid, key), + after: (value ?? null) as UndoValue, + }); + for (const w of writes) patchOverlay(w.pid, w.updates); + recordCells(changes, label); + }, + [currentValue, patchOverlay, recordCells] + ); + + /** + * Apply one stack entry in one direction. The INVERSE lives in `undoStack.directed` — this + * only knows how to WRITE each op, and it writes through the same doors the user does + * (`patchOverlay`, the rows endpoint), so an undone edit is persisted exactly like the edit + * was. Nothing here is optimistic-only: a Ctrl+Z that reverted the screen and not the store + * would come back on the next reload. + */ + const applyEntry = useCallback( + async (entry: UndoEntry, dir: "back" | "forward") => { + const op = directed(entry, dir); + if (op.kind === "cells") { + const byPid = new Map>(); + for (const c of op.changes) { + const at = byPid.get(c.pid) ?? {}; + at[c.key] = (c.after ?? "") as Row[string]; + byPid.set(c.pid, at); + } + applyingUndo.current = true; + try { + for (const [pid, updates] of byPid) patchOverlay(pid, updates); + } finally { + applyingUndo.current = false; + } + } else if (op.kind === "rowAdd") { + // Restore under the OLD id where the store still has it free; the server answers with + // what it actually wrote and the re-read is what puts the rows back on screen. + // + // ⚠ SEQUENTIAL, not `Promise.all`. `add_row` picks `max(id)+1` when the requested id is + // taken, and it reads the store to do it — firing ten restores concurrently is ten + // readers racing one counter, which is how two rows end up sharing an id. + let any = false; + let moved = 0; + for (const r of op.rows) { + const made = await addTableRow(op.table, r.values as Record, r.rid); + any = any || !!made; // a refusal already said why, in the server's words + // ⚠ THE ID IS PART OF WHAT IS BEING UNDONE, and `add_row` falls back to `max(id)+1` + // when the one it was asked for is no longer free — which happens if anything created + // a row in the gap. The restore is still the right thing to do, but it is no longer + // the SAME record to anything that named the old id (a cohort, a comment, a filter), + // and a redo would then look for an id that is not there. Said out loud rather than + // discovered later. + if (made && String(made.rid) !== String(r.rid)) moved += 1; + } + if (!any) return; + if (moved) + signal( + TOAST_EVENT, + `${moved} restored record${moved === 1 ? "" : "s"} came back under a new id. ` + + `Something had taken the original while it was gone.` + ); + signal(ROWS_STALE_EVENT); + } else if (op.kind === "rowDelete") { + let any = false; + for (const r of op.rows) any = (await deleteTableRow(op.table, r.rid)) || any; + if (!any) return; + // ⭐ ITEM 2 — the row may still be one of THIS browser's pending copies (add, then + // Ctrl+Z). A deleted row never reappears in a payload, so the prune effect can never + // absorb it and it would sit on screen looking undeleted. See `withdrawPending`. + withdrawPending(op.rows.map((r) => r.rid)); + signal(ROWS_STALE_EVENT); + } else if (op.kind === "choiceRename") { + // Item 15 — the inverse mapping. The host rewrites the values and the saved views that + // name them, exactly as it did on the way out; `directed` already turned the pairs + // around, so this emits what it is given. + emitHostEvent({ + id: eventId("choicerename"), + type: "choice_rename", + key: op.fieldKey, + renames: op.renames, + }); + } + signal(TOAST_EVENT, describe(entry, dir)); + }, + [patchOverlay, withdrawPending] + ); + + /** + * Ctrl+Z / Ctrl+Shift+Z (and Ctrl+Y, which is the same request on Windows). + * + * ⚠ ON `window`, IN CAPTURE, and it steps aside for real text fields. The grid is a canvas — + * glide's key handling only fires while the canvas has focus, so a Ctrl+Z after clicking the + * toolbar would do nothing, which is exactly the "sometimes it works" the owner would report + * next. But an with a cursor in it has its OWN undo that belongs to the browser, and + * stealing that would be worse than not having ours: the search box, the rename field and + * glide's own cell editor are all inputs, so `activeElement` decides. + * + * Nothing to undo says so out loud rather than silently ignoring the key — an undo that + * appears to do nothing is indistinguishable from one that is broken. + */ + useEffect(() => { + const onKey = (event: KeyboardEvent) => { + if (!(event.ctrlKey || event.metaKey) || event.altKey) return; + const key = event.key.toLowerCase(); + if (key !== "z" && key !== "y") return; + const el = document.activeElement as HTMLElement | null; + if (el && (el.tagName === "INPUT" || el.tagName === "TEXTAREA" || el.isContentEditable)) + return; + const forward = key === "y" || event.shiftKey; + event.preventDefault(); + const book = undoBook.current; + const state = stackFor(book, scope); + const { state: next, entry } = forward ? popRedo(state) : popUndo(state); + if (!entry) { + signal(TOAST_EVENT, forward ? "Nothing to redo." : "Nothing to undo."); + return; + } + book[scope] = next; + void applyEntry(entry, forward ? "forward" : "back"); + }; + window.addEventListener("keydown", onKey, true); + return () => window.removeEventListener("keydown", onKey, true); + }, [scope, applyEntry]); + + + /** + * R4 — **BULK BACKSPACE/DELETE IS ONE ACTION.** glide's own delete walks the selection and + * calls `onCellEdited` per cell, which would put forty entries on the stack for one keypress; + * returning `false` takes the whole operation over so it lands as one. + * + * ⚠ PRESETS ARE NEVER ATTEMPTED (the contract says so, and it is also the only honest + * behaviour): `canEditField` is the same verdict the editor and the paste path use, so a + * selection spanning read-only columns clears the editable ones and leaves the rest exactly + * as they were — rather than firing writes the server will refuse one by one. + */ + /** + * ⭐ 2026-08-06 (owner) — DELETE RECORDS, with Ctrl+Z. + * + * Owner, verbatim: *"I should always be able to delete records that I created myself manually + * (not from automation), because right now it only shows 'Add to cohort'… make sure I can use + * 'Delete/Backspace' to delete the record and use Ctrl Z if i want to undo."* + * + * Everything under this was already built and had no door: `DELETE /tables/{key}/rows/{rid}`, + * `deleteTableRow`, and `undoStack`'s `rowDelete` — whose inverse (`rowAdd` under the SAME rid) + * is exactly "put it back". The only missing piece was something that calls them. + * + * ⛔ THE VALUES ARE CAPTURED BEFORE THE DELETE, and this is the line the whole feature rests on. + * `deleteTableRow` answers a boolean; it does not hand the row back. Push the undo entry after + * the round trip and there is nothing left to read, so Ctrl+Z would faithfully restore an EMPTY + * row under the right id — a silent data loss wearing the costume of a working undo. + * + * ⚠ ONE ENTRY FOR THE WHOLE GESTURE (R4's "bulk Backspace = ONE grouped stack entry"), which is + * why `UndoEntry` carries a row LIST now. Ten rows deleted must be one Ctrl+Z, not ten. + * + * ⚠ NO CONFIRM DIALOG, deliberately. The owner named Ctrl+Z as the safety net in the same + * sentence as the delete; a modal on top of a working undo is friction that teaches people to + * dismiss modals. + */ + const deleteRecords = useCallback( + async (pids: number[]): Promise => { + if (!canMutateRecords || !pids.length) return false; + // The row as it stands NOW, straight off the rendered records — the same values the + // reader can see, so a restore puts back what they watched disappear. + const byPid = new Map(); + for (const vr of displayRows) + if (vr.kind === "data") byPid.set(vr.record.pid, vr.record); + const captured: UndoRow[] = []; + /* + * ⛔ A SKIPPED ROW IS COUNTED AND NAMED, never dropped quietly ([[no-unverifiable-aggregates]]). + * `displayRows` is FILTERED and CAPPED, and the selection is not: Select-from-file matches + * on a value and can tick pids the current view does not render — `fileSelect` has a whole + * bucket for exactly that ("not in this view"). Capturing only what is on screen and + * `continue`ing past the rest would delete two rows and report three, which is the silent-cap + * defect in the one place it is least forgivable. + * + * REFUSED WHOLE rather than partially applied: a delete that half-happened leaves the reader + * reconciling a count against a table, and the fix (scroll or clear the filter, then select + * again) is one sentence away. + */ + const offscreen: number[] = []; + for (const pid of pids) { + const rec = byPid.get(pid); + if (!rec) { + offscreen.push(pid); + continue; + } + const values: Record = {}; + for (const f of fields) { + const v = rec[f.key]; + if (v !== undefined && v !== null && v !== "") + values[f.key] = v as UndoValue; + } + captured.push({ rid: pid, values }); + } + if (offscreen.length) { + signal( + TOAST_EVENT, + `Nothing was deleted. ${offscreen.length} of the ${pids.length} selected ` + + `record${pids.length === 1 ? " is" : "s are"} not shown in this view. Clear the ` + + `filter (or scroll them into view) and select again.` + ); + return false; + } + if (!captured.length) return false; + + /* + * ⭐ WARN THEN ALLOW (owner ruling, 2026-08-06) — the arm fires ONLY where something is + * actually at stake, so deleting your own scratch rows stays one keypress. + * + * ⛔ THE PREDICATE IS "WHAT WOULD BE LOST", NOT "WHO CREATED THIS". There is no stored + * creator on a row, and deriving one from a stage column would flip meaning as the + * automation runs. But the question the warning answers is a different and answerable one: + * *has an automation written to this row* — because that is exactly what a re-find cannot + * give back. MEASURED in `run_discover_instagram`: the seen-before test reads the rows + * CURRENTLY IN THE TABLE, so a deleted candidate returns as NEW — `found_count` back to 1, + * `first_found` re-stamped, and its stage reset to Review. Deleting a candidate somebody + * had already judged throws that judgement away. + * + * ⚠ This paragraph used to name a "Declined" stage. WAVE 26 / R6 DELETED THE BOARD'S + * BUILT-IN TERMINALS — the lanes are whatever the user defined now — so the warning names + * the mechanism (the stage resets to Review) and no longer a stage that does not ship. + */ + /** + * ⭐⭐ WAVE-29 T23 (owner item 2b) — **THE QUESTION IS AUTHORSHIP, AND IT HAS AN ANSWER.** + * + * The note above this used to argue there is no stored creator on a row, so the warning + * asked "has a machine written here" instead. There IS one: `automation_engine` stamps + * `created_by` ("Found by") on every candidate row it discovers, and `add_row` stamps + * nothing — so the two authors are distinguishable, and `machineFoundRows` (types.ts) is + * that distinction. Everything the old predicate got wrong followed from asking the + * answerable-but-different question: every preset column is machine-tagged, a hand-added + * row is born holding materialised rollups, and the STAGE cell a human drives was + * force-included — so pressing "+" and then Delete produced a loud warning about losing + * an automation's work on a row the person had just made themselves. + * + * ⚠ The wave-27 note that lived here (`stageField` survives the stage deletion, so a + * legacy stage column stays read-only) is about the READ-ONLY readers — `isMachineWritten` + * / `mayEditField` in types.ts, which are untouched. It never described this predicate; the + * risk test is the one place where including the stage column is the defect rather than the + * protection. + * + * ⛔ AND THE SENTENCE IS BUILT FROM THE COLUMNS THIS TABLE HAS. It used to name first-found, + * times-found and a stage reset unconditionally while all three live in `CANDIDATE_FIELDS` + * — so on a user-named IG Profile database it named the loss of three columns that are not + * there. `reFindConsequences` reads the field list. + */ + const riskyIds = new Set(machineFoundRows(fields, captured)); + const risky = captured.filter((r) => riskyIds.has(r.rid)); + const sig = captured.map((r) => r.rid).join(","); + if (risky.length && delArmed !== sig) { + setDelArmed(sig); + const losses = reFindConsequences(fields); + const one = risky.length === 1; + signal( + TOAST_EVENT, + `${risky.length} of these ${one ? "records was" : "records were"} FOUND by an ` + + `automation. Deleting ${one ? "it" : "them"} is not undone by a re-find` + + (losses.length + ? `: ${losses.join(", ")}, so a card somebody had already moved on comes back ` + + `undecided` + : `, so ${one ? "it" : "they"} would come back as a new record with no history`) + + `. Press Delete again (or click again) to confirm.` + ); + return false; + } + setDelArmed(""); + // SEQUENTIAL, matching the restore path: the store is one document and ten concurrent + // read-modify-writes against it is how a delete silently misses a row. + const gone: UndoRow[] = []; + for (const r of captured) + if (await deleteTableRow(scope, r.rid)) gone.push(r); + if (!gone.length) { + signal(TOAST_EVENT, "Nothing was deleted: the server refused."); + return false; + } + undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), { + kind: "rowDelete", table: scope, rows: gone, + }); + // ⭐ ITEM 2 — same reason as the undo path: a row added and then deleted in one session + // is still a PENDING copy here, and no future payload will ever prune it. + withdrawPending(gone.map((r) => r.rid)); + clearSelection(); + signal(ROWS_STALE_EVENT); + // ⚠ THE CONSEQUENCE IS NAMED IN FULL, and the first version of this sentence undersold it. + // It said only that a matching record "will return", which is true and reassuring and + // leaves out the part that actually costs something. + // + // MEASURED in `run_discover_instagram`: the seen-before test is built from the rows + // CURRENTLY IN THE TABLE (`seen` comes from `existing2`). A deleted profile is therefore + // not "seen again" on the next run — it is NEW. `found_count` resets to 1, `first_found` + // is re-stamped, and the stage goes back to Review, because a new candidate's card starts + // at the human gate. So deleting a candidate somebody had already moved out of Review puts + // it back there undecided — the human judgement is the thing the re-find cannot give back. + // (W26/R6 deleted the board's built-in terminals, so there is no named stage to cite here.) + // + // (The follower SNAPSHOT history is safe either way — it lives in the platform master keyed + // by handle, a different store, which only `purge_handle` touches.) + // Same survival, same reason as `machineKeys` above (item 12 / R3, B's ASK ->C): the flag + // is no longer WRITTEN, and a table that has not been migrated yet still carries it. + const fedByAutomation = fields.some((f) => f.automation?.stageField); + signal( + TOAST_EVENT, + `Deleted ${gone.length} record${gone.length === 1 ? "" : "s"}. Ctrl+Z to undo.` + + (fedByAutomation + ? " If this automation finds one again it comes back as a NEW candidate: first-found" + + " and times-found reset, and its stage back to Review. Undo keeps all of that." + : "") + ); + return true; + }, + [canMutateRecords, displayRows, fields, scope, clearSelection, delArmed, + withdrawPending] + ); + + const onGridDelete = useCallback( + (sel: GridSelection): boolean => { + /* + * ⭐ 2026-08-06 (owner) — A ROW SELECTION + Delete/Backspace DELETES THE RECORDS. + * + * Checking rows and pressing Delete used to CLEAR every editable cell in them, which on the + * owner's Instagram table now clears nothing at all: every column but one is machine-written + * and refuses the write. So the key appeared to do nothing, which is what they reported. + * + * ⚠ ROWS ONLY, AND ONLY WHEN NO **MULTI-CELL** RANGE IS DRAWN. Checking rows is an explicit + * gesture about RECORDS; DRAGGING a range is a gesture about CELLS, and a Delete that + * destroyed records because some rows happened to be checked would feel unrecoverable even + * with an undo behind it. When both are present, the narrower reading (clear the cells) wins. + * + * ⛔ A SINGLE ACTIVE CELL IS NOT A RANGE, and the first version of this got it wrong. + * `sel.current.range` is ALWAYS set when a cell is merely focused — a 1×1 rect — so + * `!sel.current?.range` meant the feature worked only if you had never clicked a cell. + * Caught by driving it live rather than by the gate: click a cell, then check a row, then + * press Delete, and the key silently went back to clearing cells. The gesture the rule is + * actually about is a DRAG, which is width or height greater than one. + */ + const dragged = !!sel.current + && (sel.current.range.width > 1 || sel.current.range.height > 1); + if (canMutateRecords && sel.rows.length > 0 && !dragged) { + const pids: number[] = []; + for (const rowIndex of sel.rows) { + const vr = displayRows[rowIndex]; + if (vr && vr.kind === "data") pids.push(vr.record.pid); + } + // ⛔ `true`, NOT `false`, WHEN THIS BRANCH DOES NOT DO THE WORK — the scar the comment at + // the bottom of this handler already records, pointed at a second case. `false` cancels + // glide's own delete, so bailing with it would leave Delete doing nothing at all on a + // selection this branch declined. `deleteRecords` is async and the keypress cannot wait + // for it, so the branch commits here and reports the outcome through its own toast. + if (!pids.length) return true; + void deleteRecords(pids); + return false; + } + const byPid = new Map>(); + const clear = (rowIndex: number, colIndex: number) => { + const vr = displayRows[rowIndex]; + if (!vr || vr.kind !== "data") return; + const column = visibleCols[colIndex]; + const field = column ? fieldByKey.get(column.id!) : undefined; + if (!field || !canEditField(field)) return; + const at = byPid.get(vr.record.pid) ?? {}; + at[field.key] = ""; + byPid.set(vr.record.pid, at); + }; + for (const rowIndex of sel.rows) + for (let c = 0; c < visibleCols.length; c++) clear(rowIndex, c); + const range = sel.current?.range; + if (range) + for (let y = range.y; y < range.y + range.height; y++) + for (let x = range.x; x < range.x + range.width; x++) clear(y, x); + const writes = [...byPid.entries()].map(([pid, updates]) => ({ pid, updates })); + if (!writes.length) return true; + patchManyAndRecord(writes, "clearing cells"); + // ⚠ `false` ONLY WHEN THIS ACTUALLY DID THE WORK. Returning it unconditionally cancels + // glide's own delete for cases this handler does not cover — a COLUMN selection, which + // glide deletes from `toDelete.columns` and the loops above never look at — so Delete + // would silently clear nothing. Handing the keypress back when there is nothing to + // group is strictly safer than swallowing it: the per-cell path still refuses read-only + // fields (`onCellEdited`'s own `canEditField`), it just does not arrive as one entry. + return false; + }, + [canMutateRecords, deleteRecords, displayRows, visibleCols, fieldByKey, canEditField, + patchManyAndRecord] + ); + + /** + * Owner item 4 / C-ADDROW — the cursor FOLLOWS the appended row, once it exists. + * + * The append is a server round trip, so at click time there is nothing to focus; this waits + * for the re-read to bring the pid back and then lands the active cell on its first column, + * scrolled into view. `newRowPid` is cleared either way — a row the re-read never produced + * (a refused write, a filter that excludes it) must not leave a cursor waiting forever. + */ + useEffect(() => { + if (newRowPid === null) return; + const index = displayPidToIndex.get(newRowPid); + if (index === undefined) return; + setActiveCell(0, index); + gridRef.current?.scrollTo(0, index, "vertical", 0, 0, { vAlign: "center" }); + setNewRowPid(null); + }, [newRowPid, displayPidToIndex, setActiveCell]); + + /** Owner item 23 — the fields in the view's column order, for the Hide-fields panel. Built + * from `order` (already reconciled by useGridColumns) rather than from `config.order` so the + * panel and the grid can never disagree about which fields exist or where they sit. */ + const orderedFields = useMemo( + () => order.map((key) => fieldByKey.get(key)).filter((f): f is Field => !!f), + [order, fieldByKey] + ); + /** Owner item 17 — the row of the ACTIVE cell, i.e. what the last click highlighted. Read off + * `gridSelection` rather than tracked separately so keyboard navigation moves the wash too; + * `useGridSelection` already guards this index against a grid that shrank this render. */ + const activeRow = gridSelection.current?.cell[1]; + + const statusValues = useMemo(() => { + const map: Record = {}; + for (const field of fields) { + // A `user`'s people come from the HOST, never from the rows — an assignee nobody has been + // given yet is still assignable. + if (field.type === "user") { + map[field.key] = payload?.userOptions ?? []; + continue; + } + // ⭐ Owner item 24 — every other choice column goes through ONE rule (`choiceVocabulary`): + // the DECLARED list when the field has one, the values seen in the data when it does not. + // This used to branch on the type, which sent `stock_bucket` — a `select` whose vocabulary + // is computed server-side and declares no `options` — down the declared path to an empty + // list, and an empty supplied list WINS in the filter panel. See the function's own note; + // it is pure so the gate can run it, which nothing inside this file can be. + if (field.type === "select" || field.type === "multiselect" || field.type === "status") + map[field.key] = choiceVocabulary(field, rawRows); + } + return map; + }, [fields, rawRows, payload?.userOptions]); + + const rowPx = ROW_PX[config.rowHeightMode]; + // DISTINCT customers, not painted data rows. Grouping a MULTI field (Cohorts) puts the same + // customer under every group it belongs to, so counting rows would report more records than + // there are customers — a number nobody could reconcile against the book. `pidToIndex` is + // first-wins per pid, so its size IS the distinct count, and it equals the row count for every + // non-multi grouping and for no grouping at all. + // + // ⚠ The FULL pipeline's map, never the display slice's: the toolbar count must state what the + // view MATCHES. What is painted is the "Showing first N" bar's job (owner item 8). + const recordCount = pidToIndex.size; + const shownRecords = displayPidToIndex.size; + /* wave17 GRID — item 2 / owner R5. The whole-table row band is GONE, and with it the + `bandTone` / `bandMask` pair that used to sit here: the winning control's tone, and the + alternating-row mask built from the DATA-ROW ORDINAL so group headers could not flip the + stripe at a boundary. What follows is now three row states, not four. + ⛔ wave-29 R8 (2026-08-11) — R5 also kept an involved-COLUMN cell wash; that is now gone too, + so `COLUMN_TONE_THEME` carries HEADER keys only. This callback and `cells.AUTOMATION_TINT` + are therefore the ONLY writers of a body `bgCell` on this canvas: a sorted or filtered column + can no longer layer anything over a status wash, which is exactly what R8 asked for. */ + const getRowThemeOverride = useCallback( + (row: number): Partial | undefined => { + const visibleRow = displayRows[row]; + if (!visibleRow || visibleRow.kind !== "data") return undefined; + // Owner item 17 — the row holding the ACTIVE cell stays washed after the pointer moves + // on. Ordered AFTER hover on purpose: the pointer is the more immediate signal, and a + // hovered-and-active row reading as merely active would make hover look broken. + if (row === hoverRow) { + if (!config.colorBy) return HOVER_NEUTRAL; + return ( + HOVER_ROW_THEME[String(visibleRow.record[config.colorBy] ?? "").toLowerCase()] ?? + HOVER_NEUTRAL + ); + } + // A colour-by wash already marks this row with meaning the user chose; overlaying the + // active tint on top would blend two hues into a third that means neither. There, glide's + // accent ring is the active marker and this stays out of the way. + if (!config.colorBy) { + if (row === activeRow) return ACTIVE_ROW_NEUTRAL; + return undefined; + } + return STATUS_ROW_THEME[ + String(visibleRow.record[config.colorBy] ?? "").toLowerCase() + ]; + }, + [displayRows, config.colorBy, hoverRow, activeRow] + ); + const onItemHovered = useCallback( + (args: GridMouseEventArgs) => { + const row = args.kind === "cell" ? args.location[1] : undefined; + setHoverRow((previous) => (previous === row ? previous : row)); + // Owner item 19 — the hover-only Expand. Placed from glide's OWN bounds for the primary + // cell of this row, so freeze, horizontal scroll and row-height mode are handled by the + // component that owns them rather than re-derived here. + // + // ⚠ Only ever CLEARED from here for a different row, never for "the pointer left the + // canvas". Moving the pointer ONTO the button leaves the canvas, so clearing on + // out-of-bounds would unmount the control between the mouse arriving and the click + // landing — an affordance that vanishes exactly when you reach for it, and one that + // every assertion still sees because it exists in every state except the one that + // matters. `.cg-grid-box`'s own onMouseLeave is what dismisses it. + const vrow = row !== undefined ? displayRows[row] : undefined; + if (args.kind === "cell" && vrow?.kind === "data" && row !== undefined) { + const pid = vrow.record.pid; + const b = gridRef.current?.getBounds(0, row); + // Clamped to the grid's own box. ⚠ RETARGETED wave-14 item 15: this used to say "with a + // grouping active `freezeColumns` is 0, so the primary column scrolls away". It no + // longer does — the first column is now frozen in EVERY mode. The clamp stays because + // its other half is still live: glide's bounds are VIEWPORT coordinates, so a row + // scrolled under the header or past the bottom still reports a rect outside the grid, + // and a `position: fixed` button with no clamp would paint over the chrome on a row + // nobody can see. The horizontal legs are now defence in depth (a column drag, a box + // narrower than the frozen strip) rather than the everyday case. + const boxRect = gridBoxRef.current?.getBoundingClientRect(); + const at = b ? expandButtonRect(b, boxRect) : null; + // W35-T29 — the star book-ends the same cell the Expand does, from the SAME bounds. It + // has its own minima (smaller mark, left end), so a compact row that cannot carry the + // Expand can still carry this one. + const starAt = b ? starButtonRect(b, boxRect) : null; + setStarHover((prev) => + !starAt + ? null + : prev && prev.pid === pid && prev.x === starAt.x && prev.y === starAt.y + ? prev + : { pid, x: starAt.x, y: starAt.y, size: starAt.size } + ); + // Recomputed EVERY move, not memoised on the pid: a scroll can leave the pointer over + // the same record at a new y, and a button that keeps the pid but not the position + // floats over the wrong row. Referential stability is preserved by comparing values, + // which is the cheap half — `getBounds` is arithmetic over glide's own layout. + setExpandAt((prev) => + !at + ? null + : prev && prev.pid === pid && prev.x === at.x && prev.y === at.y + ? prev + : { pid, x: at.x, y: at.y, size: at.size } + ); + } + // Wave-5 item 6 — hovering a header whose field carries a description floats the text + // under the header. The tip element is pointer-events:none and aria-hidden: it can + // NEVER become the click target, which is the tooltip trap this page has already paid + // for once ([[ui-invisible-to-assertions]]). + if (args.kind === "header") { + const colDef = visibleCols[args.location[0]]; + const field = colDef ? fieldByKey.get(colDef.id!) : undefined; + const note = field ? field.note || field.description : undefined; + // Owner item 16 — a title the fit SHORTENED must still be readable somewhere, and the + // tip is where. `colDef.title !== field.label` IS the truncation test: `fitHeaderTitle` + // returns the label by identity when it fits, so this asks the renderer what it did + // rather than re-running the measurement and hoping the two agree. + const cut = !!field && !!colDef && colDef.title !== field.label; + const text = cut && field ? (note ? `${field.label}: ${note}` : field.label) : note; + const bounds = args.bounds; + if (text && bounds) { + setHeaderTip((prev) => + prev && prev.text === text && prev.x === bounds.x + ? prev + : { text, x: bounds.x, y: bounds.y + bounds.height + 4 } + ); + return; + } + } + setHeaderTip((prev) => (prev === null ? prev : null)); + + // I2 — the cell half. Only text that is genuinely CUT OFF gets a tip: a tooltip on + // every cell just repeats what is already legible and covers the row under it. + // Truncation is MEASURED with glide's own font rather than guessed from a character + // count, which is what makes "e.g. a Note" work and a short currency cell stay quiet. + if (args.kind === "cell" && row !== undefined) { + const vr = displayRows[row]; + const cellCol = visibleCols[args.location[0]]; + // Group headers and footers carry no cell text of their own — a tip over the group + // bar would repeat the label glide has already drawn across it. + if (vr?.kind === "data" && cellCol) { + const field = fieldByKey.get(cellCol.id!); + const text = field ? formatDisplay(field, vr.record[field.key]) : ""; + const bounds = args.bounds; + const tip = bounds ? cellTipText(text, measureCellText(text), bounds.width) : null; + if (tip && bounds) { + const x = tipLeft(bounds.x, window.innerWidth, measureCellText(tip) + 20); + const y = bounds.y + bounds.height + 4; + setCellTip((prev) => + prev && prev.text === tip && prev.x === x && prev.y === y + ? prev + : { text: tip, x, y } + ); + return; + } + } + } + setCellTip((prev) => (prev === null ? prev : null)); + }, + [visibleCols, fieldByKey, displayRows] + ); + const rowHeight = useMemo( + () => + config.groupBy + ? (row: number) => (displayRows[row]?.kind === "group-header" ? 32 : rowPx) + : rowPx, + [config.groupBy, rowPx, displayRows] + ); + + /* ═══ W18-B VOID ═══ (wave 18, owner item 1b) — WHERE THE TABLE ENDS, in canvas pixels. + Consumed by the two `.cg-grid-void` rectangles at the DataEditor mount; see the CSS region + of the same name for why the void is painted in the DOM rather than in the glide theme. + + ⭐ THE PROPERTY THAT MAKES THIS ARITHMETIC AND NOT SCROLL-TRACKING, and the reason there is + no `onVisibleRegionChanged` handler anywhere near it: **a void can only exist in an axis the + content does not overflow.** glide's scroll extent is EXACTLY the content — `scrollWidth = + nonGrowWidth (+ overscrollX)`, `scrollHeight = header + rows (+ overscrollY)`, and we pass + neither overscroll prop (scrolling-data-grid.js:12-24) — so at maximum scroll the last + row/column lands flush on the client edge. Wherever there is something to paint, the scroll + offset in that axis is 0 and cannot become anything else. The rectangles therefore depend on + the columns, the rows and the box, and on nothing that moves while the user drags. + + Every column carries an explicit `width` (`useGridColumns` sets `config.widths[key] ?? + DEFAULT_WIDTH`) and none carries `grow`, so glide's column sizer passes them through + untouched and this sum is the width it actually lays out. */ + const gridVoid = useMemo(() => { + const gutter = scrollbarGutter(); + let colsPx = rowMarkerPx(displayRows.length); + for (const c of visibleCols) colsPx += (c as { width?: number }).width ?? 0; + let rowsPx = HEADER_PX; + if (typeof rowHeight === "number") rowsPx += displayRows.length * rowHeight; + else for (let i = 0; i < displayRows.length; i++) rowsPx += rowHeight(i); + // ⚠ Owner item 4 — THE GHOST ROW IS PART OF THE TABLE, and this line is what makes it + // visible. glide draws its trailing row AFTER the last data row, but `displayRows` (our + // rows) does not contain it, so the void started exactly where the ghost row does and + // painted flat #F6F8FC straight over it. The "+" was still clickable the whole time + // (`.cg-grid-void` is `pointer-events: none`), which is the worst version of this bug: + // the gate went green on an affordance nobody could see. Found by READING THE SCREENSHOT + // ([[ui-invisible-to-assertions]], [[finalize-visual-review-sop]]). + if (canMutateRecords) + rowsPx += typeof rowHeight === "number" ? rowHeight : rowHeight(displayRows.length); + + /* Fit is tested against the client box the OTHER axis's scrollbar leaves behind — the same + `clientWidth`/`clientHeight` glide's own scroll handler reads (infinite-scroller.js: + 110-116), so this branch and glide's cannot disagree about whether a bar is there. The + two `if`s resolve the circularity in the only direction it can run: a bar in one axis can + CREATE one in the other, but two bars can never un-create each other. */ + let vBar = rowsPx > gridSize.height; + let hBar = colsPx > gridSize.width; + if (vBar && !hBar) hBar = colsPx > gridSize.width - gutter; + if (hBar && !vBar) vBar = rowsPx > gridSize.height - gutter; + const clientW = gridSize.width - (vBar ? gutter : 0); + const clientH = gridSize.height - (hBar ? gutter : 0); + + /* `null` = the content reaches that edge, so there is no void and no rectangle. The + comparison is strict: a table ending exactly on the edge has nothing past it. */ + return { + clientW, + clientH, + below: rowsPx < clientH ? rowsPx : null, + right: colsPx < clientW ? colsPx : null, + }; + }, [visibleCols, displayRows, rowHeight, gridSize, canMutateRecords]); + /* ═══ end W18-B VOID (geometry) ═══ */ + + // The record drawer resolves positions against what the MODE paints: the display slice for + // grid/list (the cap is real there), the FULL pipeline for calendar/kanban/map (a month, a + // stack or a pin reaches past the cap by design — a drawer that refused those pids would + // close itself on a card the user can plainly see). + // ⭐ WAVE-27 item 8 (C3) — `swipe` belongs here for calendar/kanban/map's own reason: the deck + // is derived from `modeDataRows` (the FULL pipeline), so a card past the display cap is one + // the user can plainly see, and a drawer that refused its pid would close itself on open. + const fullSetMode = + displayMode === "calendar" || displayMode === "kanban" || displayMode === "map" || + displayMode === "swipe"; + const detailRows = fullSetMode ? visibleRows : displayRows; + const detailIdxMap = fullSetMode ? pidToIndex : displayPidToIndex; + const detailIndex = detailPid !== null ? detailIdxMap.get(detailPid) : undefined; + useEffect(() => { + if (detailPid !== null && detailIndex === undefined) setDetailPid(null); + }, [detailPid, detailIndex]); + const detailRecord = useMemo(() => { + if (detailIndex === undefined) return null; + const row = detailRows[detailIndex]; + if (!row || row.kind !== "data") return null; + return overlayEdits[row.record.pid] + ? { ...row.record, ...overlayEdits[row.record.pid] } + : row.record; + }, [detailIndex, detailRows, overlayEdits]); + const dataPosition = useMemo(() => { + if (detailIndex === undefined) return 0; + return detailRows + .slice(0, detailIndex + 1) + .filter((row) => row.kind === "data").length; + }, [detailIndex, detailRows]); + const neighborExists = useCallback( + (delta: -1 | 1) => { + if (detailIndex === undefined) return false; + for ( + let index = detailIndex + delta; + index >= 0 && index < detailRows.length; + index += delta + ) + if (detailRows[index]?.kind === "data") return true; + return false; + }, + [detailIndex, detailRows] + ); + const go = useCallback( + (delta: -1 | 1) => { + if (detailIndex === undefined) return; + for ( + let index = detailIndex + delta; + index >= 0 && index < detailRows.length; + index += delta + ) { + const row = detailRows[index]; + if (row?.kind !== "data") continue; + setDetailPid(row.record.pid); + if (displayMode === "grid") + gridRef.current?.scrollTo(0, index, "vertical", 0, 0, { vAlign: "center" }); + return; + } + }, + [detailIndex, detailRows, displayMode] + ); + + /** + * Wave-9 I3 — the description "(i)", drawn RIGHT-ALIGNED and vertically centred with the + * field name. + * + * ⚠ This is the header-draw alternative, taken because glide's `overlayIcon` PROVABLY + * cannot do it: `drawHeaderInner` paints an overlay at a hard-coded `drawX + 9` / + * `(height - 18) / 2 + 6` — a badge on the bottom-right corner of the TYPE mark at the far + * LEFT of the header — and no prop moves it. `drawHeader` is glide's supported escape + * hatch: it hands over the ctx, the rect, the menu bounds and the sprite manager, plus a + * `drawContent()` that runs its own rendering first. So glide still draws the header it + * always drew (type mark, title, menu); only the (i) is ours, and `overlayIcon` is no + * longer set on any column. + * + * The geometry is `infoMarkRect()` in overlayPlacement.ts, so "right-aligned and centred" + * is asserted numerically by a gate rather than judged from one screenshot at one width. + */ + const drawGridHeader = useCallback( + (args, drawContent) => { + const field = args.column.id ? fieldByKey.get(args.column.id) : undefined; + // ⚠⚠ WAVE-14 ITEM 2 — THE ONE LINE THAT MAKES AN INVOLVED HEADER BOLD, and it is not a + // font setting anywhere near where you would look for one. + // + // glide's `drawGridHeaders` does, verbatim: + // if (theme !== outerTheme) ctx.font = theme.baseFontFull; // :38-40 + // i.e. the moment a column carries ANY `themeOverride` its header title is painted in the + // CELL font ("13px") instead of the header font ("600 13px"). That is why the owner's + // filtered/sorted/grouped headers had already lost their weight — nothing in our code + // asked for it, and setting `headerFontStyle` on the involved theme alone would have + // changed precisely nothing, because glide never reads it on that path. Re-asserting the + // MERGED theme's header font on the ctx here is what puts it back, for every column, + // uniformly: on a column with no override this is a no-op (glide set the same value at + // :21), and glide's own save/restore around each column stops it leaking to the next. + // + // Built from `headerFontStyle` + `fontFamily` rather than reading `headerFontFull`, + // which is real at runtime but is declared on `FullTheme`, not the public `Theme` the + // callback is typed with — same string, no cast, and it is exactly how + // `mergeAndRealizeTheme` composes it. + // + // Bonus fix, worth knowing: `drawHeaderInner` passes `theme.headerFontFull` to + // `getMiddleCenterBias`, which measures with the CURRENT ctx font but CACHES under the + // string it was handed. Before this line, an overridden column measured its baseline bias + // in the 13px font and filed it under the "600 13px" key, so whichever column drew first + // decided the vertical centring for all of them. + args.ctx.font = `${args.theme.headerFontStyle} ${args.theme.fontFamily}`; + drawContent(); + if (!field) return; + const hasInfo = !!(field.note || field.description); + // ⭐ OWNER ITEM 4 (2026-08-06): *"why is the Field still have the Dot at the header to + // mark its a custom editable field?"* Because the predicate was `source === "overlay"` + // alone — true of columns a person added BESIDE the Odoo ones, and true of literally + // every column in a `ut_*` database, including the ones an automation spawns and fills. + // So on the owner's Instagram table the dot marked all 23 columns as "yours", which is + // both meaningless (it never varies) and wrong (they are not yours to edit). + const isCustom = field.source === "overlay" && !isMachineOwned(field); + // ONE layout for both marks, from the same `headerMarkSizes` order `useGridColumns` used + // to reserve the label's room — so the number of marks drawn and the number reserved for + // cannot disagree. null = the column is too narrow to carry them without covering its own + // label, and then NOTHING is drawn (all-or-nothing; see headerMarkLayout). + const marks = headerMarkLayout( + args.rect, + args.menuBounds.width, + headerMarkSizes(hasInfo, isCustom) + ); + if (!marks) return; + let slot = 0; + if (hasInfo) { + const at = marks[slot++]; + args.spriteManager.drawSprite( + "aiosInfo", + "normal", + args.ctx, + at.x, + at.y, + at.size, + args.theme + ); + } + if (isCustom) { + // Wave-14 item 1 / R11 — the user-created-field marker that REPLACES the yellow header + // wash. A muted dot: quiet enough to ignore while reading, present enough to answer + // "which of these columns are mine?" at a glance. Never a background — the owner killed + // the wash, and a paler wash would have been the same answer in a lower voice. + const at = marks[slot++]; + const ctx = args.ctx; + ctx.save(); + ctx.beginPath(); + ctx.arc(at.x + at.size / 2, at.y + at.size / 2, at.size / 2, 0, Math.PI * 2); + ctx.fillStyle = CUSTOM_FIELD_MARK; + ctx.fill(); + ctx.restore(); + } + }, + [fieldByKey] + ); + + const onCellClicked = useCallback( + (cell: Item, event: CellClickedEventArgs) => { + const row = displayRows[cell[1]]; + if (!row) return; + // Owner item 10: clicking into the cells is "I am working now" — the frame folds its + // navigation rail to the slim strip (a no-op in the embed; the shell listens). + signal(NAV_MINIMIZE_EVENT); + if (row.kind === "group-header") { + event.preventDefault(); + setCollapsed((current) => { + const next = new Set(current); + if (next.has(row.groupKey)) next.delete(row.groupKey); + else next.add(row.groupKey); + return next; + }); + return; + } + const column = visibleCols[cell[0]]; + const field = column ? fieldByKey.get(column.id!) : undefined; + // A picked field (select / assignee) opens its choices where the cell is. It cannot use + // glide's text overlay — a free-text editor on a constrained column is how you end up + // with "Done", "done" and "DONE" as three different values — and glide's own dropdown + // cell lives in a package we have deliberately not added, so this reuses the same + // AnchoredOverlay the column and view menus already use. Wave-5: a RATING cell rides + // the same picker surface (its choices are 1..max stars); both doors respect the + // permissions verdict. + if (row.kind === "data" && field && canEditField(field) + && (isPickType(field.type) || field.type === "rating")) { + event.preventDefault(); + // Item 6 (2026-07-31) — the click also LANDS the active cell here (preventDefault + // stops glide from committing it), so pick → Enter walks on down the column exactly + // like a typed edit does. + setActiveCell(cell[0], cell[1]); + const b = event.bounds; + setPicker({ + pid: row.record.pid, + fieldKey: field.key, + anchor: { + left: b.x, top: b.y, right: b.x + b.width, bottom: b.y + b.height, + width: b.width, height: b.height, + }, + }); + return; + } + // A linked-record cell is a doorway to the target database, not an opaque id list. + // The large modal mounts the same Grid surface over exactly these pids, so its standard + // search, Filters, Sort, Fields, and column menus keep working for every database kind. + if (row.kind === "data" && field?.type === "link" && field.link?.table) { + if (field.link.table.startsWith("ut_")) { + event.preventDefault(); + setActiveCell(cell[0], cell[1]); + setLinkAt({ pid: row.record.pid, fieldKey: field.key }); + return; + } + } + // ⭐ Wave-23 C7 — a JSON cell opens the big viewer. It is the ONLY door: the cell carries + // `allowOverlay:false`, because glide's overlay is a one-line box and one keystroke in the + // wrong place inside a 32 KB document turns a well-formed payload into an unparseable one, + // saved. Opened for EVERY reader (a document you may not edit is still one you must be + // able to read) — the viewer takes `onSave` only when the permission verdict allows it, + // and the host's write wall is the real one either way. + if (row.kind === "data" && field?.type === "json") { + event.preventDefault(); + setActiveCell(cell[0], cell[1]); + setJsonAt({ pid: row.record.pid, fieldKey: field.key }); + return; + } + // Wave-5 item 11 — a URL cell opens its link on click (scheme-guarded: http/https only, + // a bare domain gets https://). Editing stays with glide's overlay (Enter/double-click). + if (row.kind === "data" && field?.type === "url") { + const raw = String( + overlayEdits[row.record.pid]?.[field.key] ?? row.record[field.key] ?? "" + ).trim(); + if (raw) { + const href = /^https?:\/\//i.test(raw) + ? raw + : /^[\w-]+(\.[\w-]+)+/.test(raw) + ? `https://${raw}` + : null; + if (href) { + event.preventDefault(); + window.open(href, "_blank", "noopener"); + return; + } + } + } + // Owner item 6 (2026-07-31) — CLICKING THE CUSTOMER TICKS THE CHECKBOX. The row marker + // is a ~32px strip; the identity cell is the widest, most natural target on the row, so + // a click there toggles the same pid-anchored set the markers write ("more surface area + // to select individual customers into a Cohort"). No preventDefault: glide still commits + // the cell highlight below, so reading across the row keeps working. + if (row.kind === "data" && field && field.key === lockedKey) { + togglePid(row.record.pid); + } + // Owner item 17 — A SINGLE CLICK HIGHLIGHTS. It used to open the record panel from here, + // which meant a user could not select a cell, read across a row, or copy a value without + // a drawer landing over the table. The highlight is glide's own doing: this handler + // returns without `preventDefault`, so the click commits `gridSelection.current`, the + // accent ring lands on the cell and `getRowThemeOverride` washes the row + // (ACTIVE_ROW_NEUTRAL). Nothing is drawn here. + // + // ⚠ SHIPPED WITH ITEM 19, never alone. Deleting this line is what removes the ONLY way to + // open a record; the hover Expand button below is its replacement, and half of this + // change is a table whose records cannot be opened at all. + }, + [displayRows, visibleCols, fieldByKey, canEditField, overlayEdits, lockedKey, togglePid, + setActiveCell] + ); + /** + * Double-click / Enter. KEPT as a door to the record on purpose (owner item 17 names the + * SINGLE click, and it is the single click that was in the way). + * + * Removing it too would leave the record reachable only by pointer — the hover affordance + * cannot be reached from the keyboard at all — so a keyboard user would lose the panel + * outright. glide routes Enter on an EDITABLE cell to its overlay editor before this fires, + * so the two doors do not collide: this is the activation path for the read-only columns, + * which is most of the table. + */ + const onCellActivated = useCallback( + (cell: Item) => { + const row = displayRows[cell[1]]; + const column = visibleCols[cell[0]]; + const field = column ? fieldByKey.get(column.id!) : undefined; + if (row?.kind === "data" && field?.source !== "overlay") + setDetailPid(row.record.pid); + }, + [displayRows, visibleCols, fieldByKey] + ); + const onCellEdited = useCallback( + (cell: Item, value: EditableGridCell) => { + const row = displayRows[cell[1]]; + const column = visibleCols[cell[0]]; + const field = column ? fieldByKey.get(column.id!) : undefined; + if (!row || row.kind !== "data" || !field || !canEditField(field)) return; + // Wave-5 item 11 — a checkbox toggles straight through glide's BooleanCell (no overlay + // editor); the overlay store keeps its '1'-or-empty contract. + if (value.kind === GridCellKind.Boolean && field.type === "checkbox") { + patchAndRecord(row.record.pid, { [field.key]: value.data ? "1" : "" }, "a tick"); + return; + } + if (value.kind === GridCellKind.Uri) { + patchAndRecord(row.record.pid, { [field.key]: value.data ?? "" }, "an edit"); + return; + } + if (value.kind === GridCellKind.Text || value.kind === GridCellKind.Number) { + patchAndRecord(row.record.pid, { [field.key]: value.data }, "an edit"); + } + }, + [displayRows, visibleCols, fieldByKey, patchAndRecord, canEditField] + ); + const validateCell = useCallback( + (cell: Item): boolean => { + const row = displayRows[cell[1]]; + const column = visibleCols[cell[0]]; + const field = column ? fieldByKey.get(column.id!) : undefined; + // ONE verdict (types.mayEditField): stratum + read-only-by-nature + permissions. The + // picked types and rating never take glide's text overlay — their pickers are the door. + return ( + !!row && + row.kind === "data" && + !!field && + canEditField(field) && + !isPickType(field.type) && + field.type !== "rating" && + field.type !== "status" + ); + }, + [displayRows, visibleCols, fieldByKey, canEditField] + ); + const onGridPaste = useCallback( + (target: Item, values: readonly (readonly string[])[]): boolean => { + const column = visibleCols[target[0]]; + const field = column ? fieldByKey.get(column.id!) : undefined; + if (!field) return false; + // ⭐ Wave-15 item 3 (R8) — THE SELECTION DECIDES HOW FAR THE PASTE REACHES. Until now the + // targets were walked down from the anchor for exactly `values.length` rows, so a + // fifty-row selection and a one-cell clipboard wrote ONE row: the selection was painted + // and obeyed by nothing. `pasteRowCount` is the whole rule and it is pure, so the gate + // can drive it — this handler cannot be reached by any node test in the repo. + const rowCount = pasteRowCount(values.length, gridSelection.current?.range, + { col: target[0], row: target[1] }); + const targetPids = Array.from({ length: rowCount }, (_, offset) => { + const row = displayRows[target[1] + offset]; + return row?.kind === "data" ? row.record.pid : null; + }); + const patches = planFieldPaste({ + field, + sourceFieldKey: copyProvenanceRef.current.fieldKey, + editable: canEditField(field), + values, + targetPids, + allowedChoices: statusValues[field.key] ?? choiceOptions(field), + }); + if (!patches) return false; + // R4 — ONE stack entry for the whole paste. Cell by cell would put forty entries on the + // stack for one Ctrl+V, and undoing a paste one cell at a time is not undoing a paste. + patchManyAndRecord( + patches.map((patch) => ({ pid: patch.pid, updates: { [field.key]: patch.value } })), + "a paste" + ); + // We handled the write ourselves. Returning false tells glide not to run its cell + // renderers' generic paste path (Bubble/rating renderers cannot enforce this contract). + return false; + }, + [visibleCols, fieldByKey, canEditField, statusValues, displayRows, patchManyAndRecord, + gridSelection.current] + ); + + const openHeaderMenu = useCallback( + (column: number, bounds: Rectangle) => { + if (embedded) return; + const definition = visibleCols[column]; + if (!definition?.id) return; + setColumnMenu({ + fieldKey: definition.id, + anchor: { + left: bounds.x, + top: bounds.y, + right: bounds.x + bounds.width, + bottom: bounds.y + bounds.height, + width: bounds.width, + height: bounds.height, + }, + }); + }, + [embedded, visibleCols] + ); + const onHeaderClicked = useCallback( + (column: number, event: HeaderClickedEventArgs) => { + if (event.isEdge) return; + event.preventDefault(); + openHeaderMenu(column, event.bounds); + }, + [openHeaderMenu] + ); + const onGridKeyDown = useCallback( + (event: GridKeyEventArgs) => { + if ( + event.key.toLowerCase() === "c" && + (event.ctrlKey || event.metaKey) && + !event.altKey + ) { + const range = gridSelection.current?.range; + const source = range?.width === 1 ? visibleCols[range.x] : undefined; + copyProvenanceRef.current = markGridCopy(source?.id ?? null, Date.now()); + // Do not prevent default: glide still owns serialization and the OS clipboard write. + return; + } + // Owner item 5 (2026-07-31) — EXCEL-GRADE ENTER. When no editor is open (an open overlay + // editor swallows its own keys before the canvas sees them), Enter moves the active cell + // DOWN one record and Shift+Enter moves UP — never opening the record drawer, which is + // what made keyboard runs down a column "really clunky". Glide's own overlay editor + // already commits-and-moves-down on Enter, so typing → Enter → typing flows like Excel; + // this handles the BETWEEN-edits half. Group headers are skipped in the direction of + // travel. The drawer stays reachable by double-click and the hover Expand button. + if (event.key === "Enter" && !event.ctrlKey && !event.metaKey && !event.altKey) { + const cur = gridSelection.current?.cell; + if (cur) { + event.preventDefault(); + event.stopPropagation(); + event.cancel(); + // Owner item 6 (2026-07-31) — Enter on a PICKED cell (select / multi select / + // assignee / rating) opens its picker: the keyboard door the mouse click already + // had. glide's text overlay cannot serve these types (validateCell refuses them), + // so without this the keyboard run down a column dead-ends at every picked field. + // Shift+Enter stays pure navigation, so walking UP past picked cells still works. + const curRow = displayRows[cur[1]]; + const curColumn = visibleCols[cur[0]]; + const curField = curColumn ? fieldByKey.get(curColumn.id!) : undefined; + if ( + !event.shiftKey && + curRow?.kind === "data" && + curField && + canEditField(curField) && + (isPickType(curField.type) || curField.type === "rating") + ) { + const b = gridRef.current?.getBounds(cur[0], cur[1]); + if (b) { + setPicker({ + pid: curRow.record.pid, + fieldKey: curField.key, + anchor: { + left: b.x, top: b.y, right: b.x + b.width, bottom: b.y + b.height, + width: b.width, height: b.height, + }, + }); + return; + } + } + const dir = event.shiftKey ? -1 : 1; + let row = cur[1] + dir; + while (row >= 0 && row < displayRows.length && displayRows[row]?.kind !== "data") + row += dir; + if (row >= 0 && row < displayRows.length) { + setActiveCell(cur[0], row); + gridRef.current?.scrollTo(cur[0], row, "vertical", 0, 0); + } + return; + } + } + const contextMenu = + event.key === "ContextMenu" || (event.key === "F10" && event.shiftKey); + // DataEditor exposes public controlled selection without the row marker, + // while GridKeyEventArgs.location comes from its internal grid and still + // includes that marker at column zero. + const eventColumn = + event.location && event.location[0] > 0 + ? event.location[0] - 1 + : undefined; + const column = gridSelection.current?.cell[0] ?? eventColumn; + if (!contextMenu || column === undefined || !event.bounds) return; + const gridBounds = gridBoxRef.current?.getBoundingClientRect(); + const headerTop = gridBounds?.top ?? event.bounds.y; + event.preventDefault(); + event.stopPropagation(); + event.cancel(); + openHeaderMenu(column, { + x: event.bounds.x, + y: headerTop, + width: event.bounds.width, + height: 36, + }); + }, + [gridSelection.current, openHeaderMenu, displayRows, setActiveCell, + visibleCols, fieldByKey, canEditField] + ); + + /** + * ⭐⭐ W35-T23 (R2) — ONE BODY FOR BOTH SURFACES, and the branch that used to sit here is why. + * + * It had a `if (queryBinding) { ...refuse; return; }` prologue that never touched `views`, so a + * Query surface could not change its own spec at all. R2 makes it live, and the ONLY difference + * left is which transport the router picks — which is `routeQueryViewMutation`'s whole job, so + * asking the question twice (once here, once inside it) was the duplication that made the two + * paths drift. + * + * ⚠ THE STATE UPDATE MUST HAPPEN FOR BOTH. Without it the autosave effect compares `config` + * against a `views` entry that never moved, `sameConfig` stays false, and the effect re-fires + * every render — a POST per frame for the life of the surface. + * + * ⚠ A view whose id is NOT the artefact's is still refused by the router (a Query workspace + * holds exactly one view), so Duplicate cannot smuggle a second one in. + */ + const persistView = useCallback((view: SavedView) => { + setViews((current) => { + const found = current.some((item) => item.id === view.id); + return found + ? current.map((item) => (item.id === view.id ? view : item)) + : [...current, view]; + }); + // ⛔⛔ WAVE 35 QA — THE SAME RE-IDENTIFICATION THE AUTOSAVE NEEDS, AND THIS IS THE CALL SITE + // THE TOOLBAR ACTUALLY USES. `persistView` is what Rows / Sort / Group / Hide fields go + // through, so fixing only the autosave left every one of them still refused: measured on the + // DEPLOYED build after the first fix — row height still raised *"…cannot take a second view"* + // and `edited` stayed `false`. There are FOUR callers of the router in this file and the + // identity belongs to the WRITE, not to one of them. + // ⚠ Only the emitted subject moves; `setViews` above keeps the source view's own id. + const subject = queryBinding ? { ...view, id: queryBinding.artifactId } : view; + const routed = routeQueryViewMutation(queryBinding, scope, { + id: eventId(queryBinding ? "query-view-update" : "view"), + type: "view_upsert", + view: subject as unknown as Record, + }, { query: mutateQueryWorkspace, native: emitHostEvent }); + if (routed.channel === "refused") + signal(TOAST_EVENT, routed.refusal?.message ?? "That view change could not be saved."); + else if (routed.channel === "query") + void routed.result.then((result) => { + if (!result.ok) signal(TOAST_EVENT, result.message); + }); + }, [queryBinding, scope]); + + /** + * Item 12 (C-LOCK) — set or clear a view's cohort lock, from the rail's menu. + * + * Works on ANY view, not only the active one, which is why it goes through `persistView` + * rather than `updateConfig`: the rail's menu opens on whichever row you clicked. When the + * target IS the active view, the live `config` must move too, or the table keeps showing the + * old row set until the next select and the lock reads as ignored. + * + * Clearing DELETES the key rather than storing null — the no-churn rule every other optional + * config member follows, and the shape `_clean_display`'s sibling validator expects. + */ + const onViewCohortLock = useCallback( + (viewId: string, cohortId: string | null) => { + const target = views.find((v) => v.id === viewId); + if (!target) return; + const nextConfig = { ...target.config }; + if (cohortId) nextConfig.cohortLock = cohortId; + else delete (nextConfig as Record).cohortLock; + persistView({ ...target, config: nextConfig }); + if (viewId === activeViewId) { + setConfig((live) => { + const next = { ...live }; + if (cohortId) next.cohortLock = cohortId; + else delete (next as Record).cohortLock; + return next; + }); + } + }, + [views, activeViewId, persistView] + ); + + const selectView = useCallback( + (id: string) => { + if (isQueryPreview) return; + const current = views.find((view) => view.id === activeViewId); + if (current && !sameConfig(current.config, config)) + persistView({ ...current, config }); + /** + * ⭐⭐ W35-T30 (C5) — "Starred records" IS SELECTABLE AND IS NOT IN `views`, DELIBERATELY. + * + * ⛔ IT IS THE SERVER'S PROJECTION, NOT A WORKSPACE VIEW. Half this component keys off + * `views`: the autosave, the echo reconcile, `writeLocal`, `uniqueDisplayName`. Merging a + * server-owned object into that state would put it into the autosave's comparison and this + * browser would start writing a view it does not own — the shape D-170 refuses on a + * read-through grid, arriving from the client side instead. So it joins the RAIL's list + * (see `railViews`) and is resolved here by name, which is the same split the comment on + * `applyViewOrder` makes for view ORDER: a rendering fact about one surface. + */ + const next = views.find((view) => view.id === id) + ?? (id === recordStars.stars.view?.id ? recordStars.stars.view : undefined) + /** + * ⭐⭐ W36-T04/T05 — THE THIRD PROJECTION, and without it a script View could be SEEN in + * the rail and never OPENED. Script views join `railViews` and are deliberately absent + * from `views` (they are not workspace views), so the first clause misses them and the + * guard below swallowed the click. Found by review, not by any of this ticket's own + * assertions: every one of them is pure-function, static-render or a source scan, and + * not one drives THIS callback [[reachable-is-not-the-same-as-built]]. + * ⛔ APPENDED AS A THIRD `??`, never folded into the chain: `verify_grid_ux.py`'s + * record-star scan pins the clause above VERBATIM, and rewriting it reds a claim that is + * still true (the sixth time this wave a gate pinned a spelling in nobody's fence). + * ⚠ Setting `config` is the half that matters as much as the selection: `displayMode` + * reads `config.display.mode`, so without this the grid would keep painting UNDERNEATH + * the script panel — both surfaces at once, which is what `setActiveViewId` alone did. + */ + ?? scriptProjections.find((view) => view.id === id); + if (!next) return; + setActiveViewId(id); + setConfig(normalizeConfig(next.config, fields)); + setCollapsed(new Set()); + setSearch(""); + setDetailPid(null); + setDisplayCap(DISPLAY_PAGE); + // Owner item 3 (2026-07-31): tell the host WHERE THE USER IS, so a fresh browser (no + // localStorage copy) resumes on this view instead of the system default. Presentation + // state — the host stores the id and the read side re-validates it. + emitHostEvent({ id: eventId("view"), type: "view_select", viewId: id }); + // Owner item 10: opening a view is "I am working now" — the frame folds its nav rail. + signal(NAV_MINIMIZE_EVENT); + }, + [views, activeViewId, config, persistView, fields, isQueryPreview, recordStars.stars.view, + scriptProjections] + ); + + /** + * A-S4-3 (item 25) — a notification's click-through. The SHELL routes to the table and fires + * this; the GRID owns view selection, so neither learns the other's state. + * + * ⛔ IGNORE, NEVER THROW, when the view is not ours: an alert can outlive the view it watches + * (deleted, or a share revoked), and a reader who can no longer see it must simply land on the + * table. `selectView` already returns early on an unknown id; the topic check stops one grid + * reacting to another's alert when both are mounted. + */ + useEffect(() => { + const onOpen = (e: Event) => { + const detail = (e as CustomEvent).detail; + if (!detail || detail.topic !== scope) return; + if (!views.some((v) => v.id === detail.viewId)) return; + selectView(detail.viewId); + }; + window.addEventListener(VIEW_OPEN_EVENT, onOpen); + return () => window.removeEventListener(VIEW_OPEN_EVENT, onOpen); + }, [scope, views, selectView]); + const createView = useCallback( + (name: string, mode: DisplayMode, permissions: ViewPermissions) => { + if (queryBinding) { + const routed = routeQueryViewMutation(queryBinding, scope, { + id: eventId("query-view-create"), type: "view_create", + }, { query: mutateQueryWorkspace, native: emitHostEvent }); + if (routed.channel === "refused") + signal(TOAST_EVENT, routed.refusal?.message ?? "This Query binding cannot create a source view."); + return; + } + const acceptedName = uniqueDisplayName(name, views.map((view) => view.name)); + /** + * ⭐⭐ WAVE 27 · OWNER ITEM 9 / RULING R4 — **A NEW VIEW IS BLANK. ALL OF IT.** + * + * ⛔ THIS REVERSES THE WAVE-26 COMMENT THAT STOOD HERE, so the reversal is stated rather + * than quietly applied. That comment dropped `cohortLock` from the spread and defended + * keeping the rest: *"their inheritance is a FEATURE (build a view, branch off it) + * precisely because it is visible"*. The owner disagrees, in as many words — R4: + * **"New views ALWAYS start blank — no filters/sorts inherited from anything."** The + * branch-off use it defended gets its own door later (Duplicate view); it is not what the + * "+" button means, and the wave-26 fix was the right diagnosis of the wrong scope — one + * member of the spread was invisible, but ALL of them arrived unasked. + * + * ⛔ AND `defaultViewConfig` IS THE BLANK, not a literal assembled here. It is the same + * function the grid's own initial state and the cohort door already use, so "blank" has + * ONE definition in this file ([[one-evaluator-per-question]]); a second literal beside it + * would be a place for the two to disagree the day a `ViewConfig` member is added — and + * the one that got added last wave is exactly what caused this bug. + * + * WHAT STOPS CARRYING, enumerated because a reader deserves the list and not just the + * ruling: `filters` + `filterConj`, `sorts`, `groupBy`, `colorBy`, `rowHeightMode`, + * `order`/`visible` (hidden columns), `widths`, `memberPids`, `frozenCount` and + * `cohortLock`. `order`/`visible` come back from the FIELD list's own default visibility, + * which is why `fields` replaces `config` in the dependency list below. + * + * ⚠ THE DISPLAY REFS GO TOO, and that is the one deliberate loss. W13's carry rule kept + * the calendar's date field / kanban's stack field / map's lat-lon across a mode switch so + * Grid→Map→Grid→Map did not re-ask; a NEW view is not a mode switch, and inheriting the + * previous view's stack field is inheritance of exactly the kind R4 names. A ref-less + * kanban asks which field to stack by, which is the honest state for a view born empty. + * `cleanDisplay` still collapses a ref-less GRID to absent, so picking "Grid" produces the + * byte-identical shape every pre-wave-9 view has (no churn). + */ + const nextConfig: ViewConfig = { + ...defaultViewConfig(fields), + display: cleanDisplay({ mode }), + }; + const view: SavedView = { + id: nextViewId(), + name: acceptedName, + kind: "custom", + config: nextConfig, + // I17 (C4) — sent EXPLICITLY. Absent on create means 'personal' host-side, so a user + // who chose "Collaborative" would silently get the opposite. `createdBy` is NOT sent: + // the host stamps it and ignores whatever the browser claims. + permissions, + }; + persistView(view); + setActiveViewId(view.id); + // The LIVE config has to move too, not just the stored one. Without this the view is + // created as a Calendar and the user keeps staring at the Grid until they switch views + // and back — the mode would be real in the store and invisible on screen. + setConfig(nextConfig); + setSaveState("saved"); + }, + // `fields`, not `config` (R4): the blank is derived from the COLUMNS, and reading the live + // config here at all is what item 9 deletes. + [fields, persistView, views, queryBinding, scope] + ); + const renameView = useCallback( + (id: string, name: string) => { + const view = views.find((item) => item.id === id); + if (view) { + const acceptedName = uniqueDisplayName( + name, + views.filter((item) => item.id !== id).map((item) => item.name) + ); + persistView({ ...view, name: acceptedName }); + } + }, + [views, persistView] + ); + /** + * I12 (contract C3) — freeze/unfreeze a view's DISPLAY MODE. + * + * The client re-checks the actor before emitting even though the menu entry is already + * gated: an event can be replayed, and "hidden in the client" has never been a permission. + * The host checks it again and that check is the wall — this one only keeps the client from + * showing a change that will not survive the round trip. + */ + /** + * ⭐⭐ WAVE 32 · T24 (owner item 17, ruling R5, contract C4) — MARK / UNMARK IMPORTANT. + * + * ⛔ NO PERMISSION TEST OF ITS OWN, unlike `toggleViewLock` above, and that is the ruling + * rather than an omission: R5's mark is a legibility flag ("keep this number in front of me"), + * not a lock over anybody's rows. `persistView` already refuses a view this caller may not + * write, which is the wall that matters. + * + * ⚠ `important` is written EXPLICITLY as a boolean, never by deleting the key. The server's + * allowlist reads `cfg.get('important') is True`, so an unmark has to ARRIVE as `false`; a + * client that dropped the key on unmark would leave the stored `true` untouched and produce a + * mark that can be set and never cleared. + */ + /** + * ⭐⭐ WAVE 33 · T13 (owner item 3) — THE MARK MUST MOVE THE LIVE CONFIG TOO, and without the + * last two lines it UNDOES ITSELF within a tick. + * + * ⛔ THE MECHANISM, reproduced before it was fixed. `persistView` updates `views`; the LIVE + * `config` state is a separate copy of the active view's config. Mark the ACTIVE view and the + * two disagree by exactly one key — at which point the autosave effect above + * (`sameConfig(active.config, config)` is a whole-object `JSON.stringify` compare) sees a + * difference, fires, and writes `{ ...active, config }` — THE LIVE CONFIG, which never learned + * about `important`. The server's allowlist then stores `'important': cfg.get('important') is + * True` = **False**, and the mark is gone. The badge paints for one render and vanishes. + * + * ⛔ WHY IT LOOKED LIKE AN "IG PRESET DATABASE" PROBLEM, which is the guess in item 3 and the + * thing four separate theories were checked against: this only bites the ACTIVE view, and an IG + * database's Overview is PINNED to the top of the rail (`aios_grid.IG_OVERVIEW_ID`, re-pinned + * above the system view) and is therefore the view you are standing on when you mark it. Mark + * any OTHER view from the rail menu and it sticks, because the live config is not its config. + * Nothing about presets, locks, windowing or system views is involved. + * + * ⭐ THE FIX IS ITS OWN SIBLING'S, VERBATIM: `onViewCohortLock` below already moves both halves + * and its comment already states this rule ("the live `config` must move too"). This callback + * was written without that line, which is the whole defect. + */ + const toggleViewImportant = useCallback( + (id: string, important: boolean) => { + const view = views.find((item) => item.id === id); + if (!view) return; + persistView({ ...view, config: { ...view.config, important } }); + if (id === activeViewId) setConfig((live) => ({ ...live, important })); + }, + [views, persistView, activeViewId] + ); + const toggleViewLock = useCallback( + (id: string, locked: boolean) => { + const view = views.find((item) => item.id === id); + if (!view || !mayToggleViewLock(view, viewer)) return; + persistView({ ...view, locked }); + }, + [views, persistView, viewer] + ); + // The list description. Template lists ship with prose seeded by the host, but it is + // the USER's text once they touch it — including clearing it. An empty string is + // persisted as an empty string (never coerced back to the seed), because the host + // resolves a saved view OVER its template, so "" is how you delete a description. + const setViewNote = useCallback( + (id: string, note: string) => { + const view = views.find((item) => item.id === id); + if (view) persistView({ ...view, note: note.slice(0, 2000) }); + }, + [views, persistView] + ); + const duplicateView = useCallback( + (id: string) => { + const source = views.find((view) => view.id === id); + if (!source) return; + const copy: SavedView = { + ...source, + id: nextViewId(), + name: uniqueDisplayName( + `${source.name} copy`, + views.map((view) => view.name) + ), + kind: "custom", + locked: false, + // I17 (C4) — a COPY IS THE DUPLICATOR'S OWN, PERSONAL view. Ruled, not inherited: + // - inheriting a 'users' grant would silently re-share the copy with a list the + // person making it never chose; + // - Duplicate is also the escape hatch for someone who may NOT edit the original, + // and 'personal' + the host's fresh createdBy stamp is exactly "mine to work in". + permissions: { edit: "personal" }, + // Never echo the SOURCE's creator: the host stamps and ignores what the browser + // sends, but sending someone else's name is a laundering attempt on its face. + createdBy: undefined, + config: { ...source.config }, + }; + persistView(copy); + setActiveViewId(copy.id); + setConfig(copy.config); + }, + [views, persistView] + ); + const deleteView = useCallback( + (id: string) => { + if (queryBinding) { + const routed = routeQueryViewMutation(queryBinding, scope, { + id: eventId("query-view-delete"), type: "view_delete", viewId: id, + }, { query: mutateQueryWorkspace, native: emitHostEvent }); + if (routed.channel === "refused") { + signal(TOAST_EVENT, routed.refusal?.message ?? "This Query binding cannot delete a source view."); + return; + } + if (routed.channel !== "query") return; + void routed.result.then((result) => { + if (!result.ok) { + signal(TOAST_EVENT, result.message); + return; + } + setViews([]); + setActiveViewId(""); + }); + return; + } + const view = views.find((item) => item.id === id); + // ⚠ This gate used to read `view.locked`, and the menu entry above it did too. C3 + // redefines `locked` as "the DISPLAY MODE is frozen" on ANY view, so leaving the gate + // here would make a user-frozen Kanban undeletable in the client while the host would + // delete it happily (app.py refuses only `all-customers`, by id). The MENU is just the + // door — this is the gate, and both had to move. + // C4 rides alongside: you may not delete a view you may not edit. + if (!view || isUndeletableView(view) || !mayEditView(view, viewer)) return; + // 2026-08-04 — STAMP BEFORE THE EMIT, the writeLocal-before-emit order every other + // optimistic path here uses. The live adopt below re-reads `payload.workspace.views` + // on every echo, and the queue sends ONE batch at a time — so an echo answering an + // EARLIER batch still lists this view, and without the tombstone the row would come + // back and (since that merge never removes) stay back. See liveWorkspace.ts. + viewTombstonesRef.current = stampTombstone(viewTombstonesRef.current, id, Date.now()); + setViews((current) => current.filter((item) => item.id !== id)); + routeQueryViewMutation(undefined, scope, { + id: eventId("view-delete"), type: "view_delete", viewId: id, + }, { query: mutateQueryWorkspace, native: emitHostEvent }); + if (activeViewId === id) { + const all = views.find((item) => item.id === ALL_VIEW_ID) ?? allRecordsView(fields, scope); + setActiveViewId(all.id); + setConfig(all.config); + } + }, + [views, activeViewId, fields, viewer, scope, queryBinding] + ); + + /** + * Wave-6 item 7 — PER-COHORT view state. Each cohort keeps its own view under the stable id + * `cohort:`; a cohort with no saved view opens CLEAN (defaultViewConfig — no sort, + * no filters), which is exactly what "first created" means. The view is added LOCALLY on + * first open and emitted only when the user actually edits it (the autosave effect already + * compares configs), so "absent = clean" stays true in the store — opening every cohort + * once must not write a store full of empty views. + */ + useEffect(() => { + if (!cohortMode || !workspaceReady || !activeCohortId) return; + const vid = `cohort:${activeCohortId}`; + if (activeViewId === vid) return; + const outgoing = views.find((view) => view.id === activeViewId); + if (outgoing && !sameConfig(outgoing.config, config)) + persistView({ ...outgoing, config }); + const existing = views.find((view) => view.id === vid); + const view: SavedView = existing ?? { + id: vid, + name: lists.find((l) => l.id === activeCohortId)?.name ?? "Cohort", + kind: "custom", + config: defaultViewConfig(fields), + }; + if (!existing) setViews((current) => [...current, view]); + setActiveViewId(vid); + setConfig(normalizeConfig(view.config, fields)); + setCollapsed(new Set()); + setSearch(""); + }, [cohortMode, workspaceReady, activeCohortId, activeViewId, views, config, + fields, lists, persistView]); + + /** + * "Add to list" (owner item 6). Runs the SAME engine over the named view's config to learn + * which customers it matches, then hands those pids to the host. + * + * ⚠ Deliberately re-runs the pipeline for THAT view rather than using what is on screen: the + * menu is available on every view, not only the active one, and "add the rows I can see" would + * quietly mean something different depending on which view happened to be open. + * + * ⚠ Refuses on a windowed table. There the client holds ONE PAGE, so "the customers this view + * matches" is a question it cannot answer — it would add the 200 rows it happens to hold and + * report success. Silence would be worse than the refusal (CG-3, no-unverifiable-aggregates). + * + * ⚠ Refuses for the SAME reason when this view has a measure condition with no answer yet + * (CG-8). A pending condition matches nothing, so the cohort would be built from a filter that + * is currently narrower than the one the user is reading — and a cohort is a FIXED set, so + * that wrong membership would persist long after the answer arrived. + */ + /** + * ⭐ WAVE 27 · OWNER ITEM 21 / RULING R14 — HOW MANY RECORDS EACH ALERTED VIEW MATCHES. + * + * ⛔ ONE EVALUATOR, AND IT IS `runPipeline` RATHER THAN `matchFilterTree`. The question is + * "how many records does this VIEW show", and a view is more than its condition list: a + * `cohortLock` narrows before anything else, `memberPids` are pinned in regardless, and a + * RANK leaf ("top 10") is only answerable against the domain the other conditions leave. + * `matchFilterTree` answers a different question — "does this ROW match this tree" — and + * using it here would report the whole book for a locked view and nonsense for a ranked one. + * `addToList` below already reaches for the pipeline for the same reason + * ([[one-evaluator-per-question]]); this is the same call with the same inputs. + * + * ⛔ THREE CASES WHERE THERE IS NO HONEST NUMBER, and each is ABSENT rather than zero: + * · a WINDOWED table — the client engine is skipped by design (CG-3), so a count here + * would be the size of one PAGE wearing the label of a scope; + * · an unresolved MEASURE condition — the server has not answered it yet, so the view + * matches nothing *yet*, and rendering `0` would state a fact nobody has established; + * · a view the rail no longer holds — an alert outlives its view. + * A missing key means no badge (the prop's own contract), which is the correct rendering of + * "not known" and is distinguishable from `0`, which is a real and interesting answer. + * + * ⚠ MEMOISED ON THE ALERTED SET, not on `views`: this walks the whole book once per alerted + * view, and on a table with no alerts it does nothing at all. + */ + /** + * ⭐⭐ WAVE 32 · T24 (R5/C4) — THE BADGE'S SOURCE SET GAINS THE MARKED VIEWS. + * + * ⛔ THE SAME MACHINE, NOT A SECOND ONE. Wave 27 already computes a live matching count for + * every ALERTED view and paints it as a red pill; R5 asks for exactly that number on a view + * the user marked. Building a parallel counter would give one rail two ideas of "how many + * records match this view", and they would drift the first time the pipeline changed. + * ⛔ AND IT ADDS NO QUERY — R8's whole subject is that `/nav` is too slow. This folds over + * `computedRows`, which the grid already holds; the memo below still walks the book once per + * counted view, and on a table with neither an alert nor a mark it does nothing at all. + */ + const importantIds = useMemo( + () => views.filter((v) => v.config?.important === true).map((v) => v.id), + [views] + ); + // ⚠ DEDUPED: a view that is BOTH alerted and marked must be counted once, or the memo walks + // the whole book twice for one number. + const countedIds = useMemo( + () => Array.from(new Set([...alerted, ...importantIds])), + [alerted, importantIds] + ); + const alertedKey = countedIds.join(","); + const alertCounts = useMemo(() => { + const out: Record = {}; + if (serverWindowed || !countedIds.length) return out; + for (const id of countedIds) { + const view = views.find((v) => v.id === id); + if (!view) continue; + if (pendingMeasures(view.config.filters, measureSets) > 0) continue; + const { pidToIndex } = runPipeline({ + rawRows: computedRows, + fields, + filters: view.config.filters, + search: "", + sorts: [], + groupBy: null, + collapsed: new Set(), + memberPids: view.config.memberPids, + filterConj: view.config.filterConj ?? "and", + measureSets, + cohortSets, + cohortLock: view.config.cohortLock, + today, + }); + // DISTINCT records, which is what `pidToIndex` is — a grouped pipeline can list one + // record under several headings and `visibleRows.length` would then count it twice. + out[id] = pidToIndex.size; + } + return out; + // `alertedKey` is the scalar identity of the list; `alerted` itself is a fresh array on + // every fetch even when the answer has not changed. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alertedKey, views, computedRows, fields, measureSets, cohortSets, today, serverWindowed]); + + /** + * ⭐⭐ WAVE 33 · T14 (D-205) — WHY THERE IS NO NUMBER, said out loud. + * + * ⛔ THE COMPLAINT THIS ANSWERS. `alertCounts` above returns `{}` for a server-windowed grid + * and skips any view with an unresolved measure — both correct, because the client holds ONE + * WINDOW and a count computed from it would be the size of a page wearing the label of a + * scope. But it returned them SILENTLY, so the badge simply was not there, on exactly the + * big Odoo grids the owner has been chasing. An absent badge and "nothing matches" are + * indistinguishable to a reader. + * + * ⭐ THIS IS R6'S SECOND SENTENCE, WHICH IS THE HALF THAT GETS DROPPED: *"if there is lag or + * it can't be done, you need to explicitly tell me why and recommend a fix."* A limit that + * genuinely cannot be removed must be REPORTED with its cause — a silent truncation is the + * violation, not the limit. So this memo produces the SENTENCE for every counted view that + * did not get a number, and the rail renders it. + * + * ⛔ IT DOES NOT INVENT A COUNT, and must not. Counting a windowed grid honestly means a + * server-side `count(*)` per view — a new query per view on the read path, which is exactly + * what R8 refused for this badge ("it adds no query"). The recommended fix rides in the + * sentence instead of being silently attempted. + * + * ⚠ Keyed on the SAME `countedIds` and the SAME conditions as the memo above, in the same + * order — two lists that decide "is there a number here" by different rules would drift, and + * the drift would show as a row wearing both a count and an excuse. + */ + /* + * ⛔⛔ `importantTotal` IS DELETED (W35-T27, owner item 9 / R4). + * + * W33-T16 built it to answer *"the database should have the sum number of all the views with + * mark important numbers"*, and W34-T20 stripped its label. Owner item 9 removes the count + * from every surface EXCEPT the view itself, so the rail badge it fed is gone and this memo + * had no reader. Deleted rather than left computing a value nobody renders + * [[artifact-with-no-importer]]. + * + * ⚠ `importantIds` SURVIVES — it also feeds the `alerted ∪ important` union just above, which + * is what decides which views get counted at all. This deletion is the SUM, not the set. + */ + + const countNotes = useMemo(() => { + const out: Record = {}; + if (!countedIds.length) return out; + for (const id of countedIds) { + const view = views.find((v) => v.id === id); + if (!view) continue; + if (serverWindowed) { + out[id] = + "No count: this database is read through its source in windows, so the browser holds " + + "one page rather than the whole table. A number from it would be the size of that " + + "page, not of this view. Counting it honestly needs a server-side count per view."; + } else if (pendingMeasures(view.config.filters, measureSets) > 0) { + out[id] = + "No count yet: this view filters on a measure the server has not answered for these " + + "records. The number appears once that answer arrives."; + } + } + return out; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [alertedKey, views, measureSets, serverWindowed]); + + const addToList = useCallback( + (viewId: string, cohortId: string, name: string) => { + if (serverWindowed) return; + const view = views.find((v) => v.id === viewId); + if (!view) return; + if (pendingMeasures(view.config.filters, measureSets) > 0) return; + const { visibleRows: matched } = runPipeline({ + rawRows: computedRows, + fields, + filters: view.config.filters, + search: "", + sorts: view.config.sorts, + groupBy: null, + collapsed: new Set(), + memberPids: view.config.memberPids, + filterConj: view.config.filterConj ?? "and", + measureSets, + cohortSets, + today, + }); + const pids = matched + .filter((r): r is { kind: "data"; record: Row } => r.kind === "data") + .map((r) => r.record.pid); + emitHostEvent({ + id: eventId("addlist"), + type: "add_to_list", + viewId, + cohortId, + name, + pids, + }); + }, + [views, computedRows, fields, serverWindowed, measureSets, cohortSets, today] + ); + + // ---------------------------------------------------------------- I11c (C4) + // Folders for the Views rail. The MODEL (echo reconcile, tombstones, dangling + // refs) lives in folders.ts and is gated by verify_folders.py; this is only the + // wiring: stamps for what this browser just did, and the five events. + const [folderStamps, setFolderStamps] = useState({}); + /** + * Folders THIS browser just created, held until the host echo returns them. + * + * ⚠ Without these, `reconcileFolders` was being called with the host list as + * BOTH arguments, which makes its optimistic-create branch unreachable: a + * folder absent from `host` is equally absent from `local`. The function was + * right and the wiring was wrong, so "+ New folder" painted nothing until the + * round trip landed — seconds, on a cold container, with the user clicking + * again. The gate passed because it called reconcileFolders with two DIFFERENT + * arrays; it now also calls it the way this component does. + */ + const [localFolders, setLocalFolders] = useState([]); + const workspaceFolders = payload?.workspace?.folders; + const folders = useMemo( + () => reconcileFolders(workspaceFolders, localFolders, folderStamps, Date.now()), + [workspaceFolders, localFolders, folderStamps] + ); + const folderIdOf = useCallback( + (viewId: string) => + resolveFolderId( + viewId, + views.find((v) => v.id === viewId)?.folderId ?? null, + folders, + folderStamps, + Date.now() + ), + [views, folders, folderStamps] + ); + const stampFolder = useCallback((patch: (prev: FolderStamps) => FolderStamps) => { + setFolderStamps((prev) => pruneFolderStamps(patch(prev), Date.now())); + }, []); + + /* wave17 R1 / C-LOCKV — the COHORT FOLDER surface is gone. `folders['cohorts']` merged + into `folders['views']` in the host's one-shot migration and `cohortFolders` is no longer + emitted at all, so `reconcileFolders` here would have been reconciling an absent list + against nothing forever. A locked view files into an ORDINARY folder now, which is what R1 + means by "native folders". */ + + /** + * C4 as AMENDED — the folder-level bulk add's PREVIEW. Deliberately re-runs the + * engine per contained view, exactly as `addToList` does, and carries the SAME + * two refusals as named skips rather than dropping them silently: a union that + * quietly omits two views is a wrong cohort that looks right, and a cohort is a + * FIXED set, so it stays wrong ([[no-unverifiable-aggregates]]). + */ + const folderAddPreview = useCallback( + (folderId: string) => { + const pids = new Set(); + const skipped: { name: string; why: string }[] = []; + let counted = 0; + for (const view of views) { + if (folderIdOf(view.id) !== folderId) continue; + if (serverWindowed) { + skipped.push({ name: view.name, why: "this table loads one page at a time" }); + continue; + } + if (pendingMeasures(view.config.filters, measureSets) > 0) { + // C-NAME (item 9) — user-facing, so it says "metric" like every other surface. + skipped.push({ name: view.name, why: "a metric condition has no answer yet" }); + continue; + } + counted += 1; + const { visibleRows: matched } = runPipeline({ + rawRows: computedRows, + fields, + filters: view.config.filters, + search: "", + sorts: view.config.sorts, + groupBy: null, + collapsed: new Set(), + memberPids: view.config.memberPids, + filterConj: view.config.filterConj ?? "and", + measureSets, + cohortSets, + today, + }); + for (const r of matched) if (r.kind === "data") pids.add(r.record.pid); + } + return { pids: [...pids], counted, skipped }; + }, + [views, folderIdOf, serverWindowed, computedRows, fields, measureSets, cohortSets, today] + ); + + const folderAddToList = useCallback( + (folderId: string, cohortId: string, name: string) => { + const { pids } = folderAddPreview(folderId); + if (!pids.length) return; + // ONE emit with the deduped union, through the SAME guarded event a single + // view's "Add to cohort" uses — so the host validates both identically. + emitHostEvent({ + id: eventId("addlist"), + type: "add_to_list", + viewId: "", + cohortId, + name, + pids, + }); + }, + [folderAddPreview] + ); + + /** + * Wave-7 item W2 (contract C2) — export "the current view", CLIENT-side. The same + * engine run addToList uses (that is the point: the file says exactly what the count + * says), over the same pool the named surface scopes to. + * + * ⚠ Same two refusals as addToList, for the same reasons: a server-windowed table + * holds ONE PAGE (the client cannot answer "the view's matches"), and a view with a + * PENDING measure condition currently matches nothing — an export taken then would be + * an empty/narrower file wearing the view's name. Both windows self-resolve; the + * refusal is silent no-op for one round trip. + */ + const exportRows = useCallback( + (name: string, cfg: ViewConfig, poolRows: Row[], format: ExportFormat) => { + if (serverWindowed) return; + if (pendingMeasures(cfg.filters, measureSets) > 0) return; + const { visibleRows: matched } = runPipeline({ + rawRows: poolRows, + fields, + filters: cfg.filters, + search: "", + sorts: cfg.sorts, + groupBy: null, + collapsed: new Set(), + memberPids: cfg.memberPids, + filterConj: cfg.filterConj ?? "and", + measureSets, + cohortSets, + today, + }); + const records = matched + .filter((r): r is { kind: "data"; record: Row } => r.kind === "data") + .map((r) => + overlayEdits[r.record.pid] + ? { ...r.record, ...overlayEdits[r.record.pid] } + : r.record + ); + // Columns = the view's VISIBLE fields in display order (the reconciled order: + // every key present, config order first — the same shape useGridColumns paints). + const inOrder = new Set(cfg.order); + const keys = [ + ...cfg.order, + ...fields.map((f) => f.key).filter((k) => !inOrder.has(k)), + ]; + const vis = new Set(cfg.visible); + const cols = keys + .filter((k) => vis.has(k)) + .map((k) => fieldByKey.get(k)) + .filter((f): f is Field => !!f); + runExport(format, name, today, cols, records); + }, + [serverWindowed, fields, fieldByKey, measureSets, cohortSets, today, overlayEdits] + ); + + /** + * ⭐ owner item 3 (2026-08-03) — THE SHEET THE TIME-SERIES VIEW IS SHOWING, published by the + * panel. A ref rather than state on purpose: nothing renders from it, and setting state on + * every sheet rebuild would re-render the whole grid to feed a menu nobody has opened yet. + */ + const tsSheetRef = useRef(null); + const onTsSheet = useCallback((s: TsSheet) => { tsSheetRef.current = s; }, []); + /** The same channel for a SUMMARY calendar — the other mode whose content is not its rows. + * `null` is CalendarView saying "records mode, export the rows" (see its note). */ + const calSheetRef = useRef<{ fields: Field[]; rows: Row[] } | null>(null); + const onCalSheet = useCallback( + (s: { fields: Field[]; rows: Row[] } | null) => { calSheetRef.current = s; }, + [] + ); + + /** + * W2 — the view menu's Export (Customer page): the named view over the whole pool. + * + * ⭐ owner item 3 (2026-08-03) — AND IT EXPORTS WHAT THE VIEW SHOWS. Every mode used to + * export the same thing: the matched customer rows. For grid / list / kanban / calendar / + * map / chart that is right — those modes ARRANGE rows, so the rows are what they show, and + * a calendar's dates and a kanban's lanes are columns already in the file. + * + * TWO modes are not arrangements of rows, and both were wrong in the direction that matters: + * + * · `timeseries` — metric ROWS over period COLUMNS. Exporting it handed you a customer list + * that shares none of its numbers. It exports the SHEET. + * · `calendar` IN SUMMARY MODE — metric values per DAY (C-DISP item 4). Same problem, and + * the owner named this one by hand. A calendar in RECORDS mode is genuinely an + * arrangement of rows, so it keeps the row export; `CalendarView` says which it is by + * publishing a sheet or publishing null. + * + * ⚠ ONLY FOR THE ACTIVE VIEW, and this is a real limit, not an oversight. Both sheets are + * built by the mounted view — the time series from a server round trip, the calendar from the + * month on screen. A view sitting unopened in the rail has neither, and this component cannot + * conjure one without fetching it. So that case SAYS SO and downloads nothing: the + * alternative is silently handing over the customer rows under that view's name, which is the + * exact substitution this branch exists to stop. + */ + const exportViewData = useCallback( + (viewId: string, format: ExportFormat) => { + const view = views.find((v) => v.id === viewId); + if (!view) return; + const cfg = normalizeConfig(view.config, fields); + const spec = cleanDisplay(cfg.display); + const isActive = viewId === activeViewId; + const refuse = (what: string) => + signal( + TOAST_EVENT, + `Open "${view.name}" first: ${what} is exported from what it draws on screen.` + ); + + if (spec?.mode === "timeseries") { + const sheet = isActive ? tsSheetRef.current : null; + if (!sheet) return refuse("a time series"); + if (sheet.empty) { + signal(TOAST_EVENT, + `"${view.name}" has no metrics on its sheet yet. Add one, then export.`); + return; + } + if (format === "csv") { + // The gated builder (verify_timeseries), kept as THE csv path so the file the owner + // downloads is the one the gate proves — footnotes, grouped thousands and all. + triggerDownload( + exportFilename(view.name, today, format), + new Blob(["" + buildTsCsv(sheet)], { type: "text/csv;charset=utf-8" }) + ); + } else { + const { fields: tf, rows: tr } = tsSheetToTable(sheet); + runExport(format, view.name, today, tf, tr); + } + return; + } + + /* ═══ W18-C CATALOG ═══ A catalog is the THIRD mode that is not an arrangement of rows, + and it is the furthest from one: its pages are authored content, and its product codes + are a fraction of the pool in an order the user chose. Handing over the matched rows + under a catalogue's name would be the same substitution the two branches around this + one exist to stop. It exports as a PDF, through the browser's own print dialog (R10), + so this branch refuses the file and names the door that works. */ + if (spec?.mode === "catalog") { + signal( + TOAST_EVENT, + `"${view.name}" is a catalog. Open it and use Print to save it as a PDF. ` + + `A spreadsheet of its products is not what it draws.` + ); + return; + } + /* ═══ end W18-C CATALOG ═══ */ + + if (spec?.mode === "calendar" && spec.calendarMode === "summary") { + const sheet = isActive ? calSheetRef.current : null; + if (!sheet) return refuse("a calendar summary"); + runExport(format, view.name, today, sheet.fields, sheet.rows); + return; + } + + exportRows(view.name, cfg, computedRows, format); + }, + [views, fields, computedRows, exportRows, activeViewId, today] + ); + + /** + * The selection bar's "Add to locked view": exactly the CHECKED customers, not a view's + * matches. + * Same guarded `add_to_list` event — the host intersects the pids with the caller's pool + * either way, so a hand-picked set and a view's match set ride one validation path. + */ + const addSelectionToList = useCallback( + (cohortId: string, name: string) => { + const pids = [...selectedPids]; + if (!pids.length || serverWindowed) return; + emitHostEvent({ + id: eventId("addlist"), + type: "add_to_list", + viewId: activeViewId, + cohortId, + name, + pids, + }); + setSelAddOpen(false); + setSelListName(""); + clearSelection(); + }, + [selectedPids, serverWindowed, activeViewId, clearSelection] + ); + + // Item 3c: the local write is the RENDERED truth (the def half of the no-blip contract) — + // stamped so a lagged echo cannot claw it back at the next remount. Item 9c: `scope` rides + // the EVENT at create time only; the def itself carries `scope: 'cohort'` so the echo is + // byte-stable ('global' stays absent — the host normalizes them to one shape). + const scopeChoice = payload?.workspace?.scopeChoice === true; + /** + * Item 12 (C-LOCK) — the Filter banner's copy for a cohort-locked view. + * + * `undefined` when the view is not locked. When it IS locked but the set is not in `lists`, + * this returns an EMPTY object: the banner must still appear (the reader is looking at a + * narrowed table and deserves to know why), but with no name and no count, because a set we + * were given no membership for is a set we must not describe. RECORD's banner reads that + * absence and says "locked to a set you cannot see". + * + * The count is the cohort's OWN size, not the number of rows on screen — the conditions + * narrow within the lock, so the two differ and the banner is stating the lock. + */ + /** + * ⭐ wave17 R1 / C-LOCKV — the lock in force, which is now simply the ACTIVE VIEW's. + * + * Wave 15 had two sources: a saved `config.cohortLock` and an ephemeral pick from the Cohorts + * rail that outranked it while held. The second existed only because a projected cohort was + * not a saved view, so opening one could not be allowed to write anything. Under R1 it IS a + * saved view, so selecting it applies its own stored lock through the ordinary view path — + * one source, and it survives a reload, which the transient one never did. + */ + const effectiveCohortLock = config.cohortLock; + /* wave17 R1 / C-LOCKV — `cohortRows` and the whole `cohortRail` projection are GONE. They + turned `lists` into rail rows for a section that no longer exists; the host projects each + cohort into `views` instead, so the rail renders them through the same path as every other + view and there is nothing left to project. `lists` still arrives and is still load-bearing + — it is the MEMBERSHIP channel that feeds `cohortSets`, which is what the lock resolves + against. Its absence would blank every locked view, so it is not "unused". */ + const cohortLockChip = useMemo(() => { + const id = effectiveCohortLock; + if (!id) return undefined; + const set = lists.find((l) => l.id === id); + if (!set) return {}; + return { name: set.name, count: set.pids?.length ?? 0 }; + }, [effectiveCohortLock, lists]); + // (Setting/clearing the lock lives in `onViewCohortLock` above — the rail's menu operates on + // whichever view you clicked, not necessarily the active one, so it goes through + // `persistView` and only touches the live `config` when the two are the same view.) + /** + * Item 5 / contract C-LAYOUT — this user's record-detail field order, for RECORD's + * `RecordDetail` (they asked for exactly these two props in the wave mailbox). + * + * Read straight off the workspace stratum the host echoes, and emitted back as the + * `record_layout` event HOST landed. Deliberately NOT reconciled or optimistically merged + * here: the event returns False host-side (an autosave hot path like `view_upsert`), so + * there is no rerun to race, and `RecordDetail` holds the live order while a drag is in + * flight. Nothing else in the grid reads it — the order is the MODAL's, never the grid's + * `config.order`. + */ + const recordLayout = payload?.workspace?.recordLayout?.order; + const onRecordLayout = useCallback((order: string[]) => { + emitHostEvent({ id: eventId("rlayout"), type: "record_layout", order }); + }, []); + const saveField = useCallback( + (field: Field, scope?: FieldScope) => { + const requestId = eventId("field"); + const accepted: Field = { + ...field, + label: uniqueDisplayName( + field.label, + fields.filter((item) => item.key !== field.key).map((item) => item.label) + ), + editRequestId: requestId, + }; + delete accepted.labelCorrectedFrom; + delete accepted.labelCorrectionId; + stampFieldEdit(accepted.key); + setFields((current) => + current.some((item) => item.key === accepted.key) + ? current.map((item) => (item.key === accepted.key ? accepted : item)) + : [...current, accepted] + ); + emitHostEvent({ + id: requestId, + type: "field_upsert", + field: accepted, + ...(scope ? { scope } : {}), + }); + }, + [stampFieldEdit, fields] + ); + const createField = useCallback( + ( + label: string, + type: FieldType, + anchorKey: string | null, + side: "left" | "right" | "end", + options?: string[], + measureSpec?: { key: string; window: WindowSpec }, + extra?: FieldBuildExtra + ) => { + /** + * ⚠ RENAMED FROM `scope` (wave 21, item 7). It used to SHADOW the component's own + * `scope` prop — the surface this grid is drawing — with a completely different + * thing: a FIELD's storage stratum (`'cohort'` or nothing). Two unrelated meanings + * under one word, in a function that now has to speak to a per-table endpoint. + * Caught by `tsc` only because `FieldScope` and `SurfaceScope` happen to be + * incompatible unions; had either been a bare `string`, the C2 call below would have + * POSTed to `/tables/undefined/fields` and read as a server bug. + */ + const fieldScope = scopeChoice ? extra?.scope : undefined; + const scoped = fieldScope === "cohort" ? ({ scope: "cohort" } as const) : {}; + // Owner item 7 — a FORMULA-MEASURE column. `measure_` prefixed, source:'odoo' (read-only + // at both ends), derived (values arrive from the host per render), filterable:false (the + // replacement is the measure CONDITION with the same measure+window — the governed path). + // The first render after creation shows blank cells for exactly one round trip: the host + // persists the field, then computes the values into the next payload. + if (measureSpec) { + const field: Field = { + key: `measure_${slugify(label)}_${Math.random().toString(36).slice(2, 7)}`, + label, + type, + source: "odoo", + default: true, + custom: true, + derived: true, + filterable: false, + agg: ["currency", "int"].includes(type) ? "sum" : undefined, + measure: measureSpec, + ...scoped, + }; + saveField(field, fieldScope); + insertColumn(field.key, anchorKey, side); + return; + } + const field = { ...buildOverlayField(label, type, options, extra), ...scoped }; + /** + * ⭐ WAVE 21 item 7 (contract C2) — AN AUTOMATION COLUMN ON A USER DATABASE GOES TO + * THE DEFINITION, not to this user's overlay stratum. + * + * The automation editor's column picker reads `user_tables` (the definition); every + * grid-created column went to `_table_workspace` (the per-user overlay). So the + * picker was empty by construction: the user made the column, then could not find it + * in the automation they made it for. C2 routes exactly this one kind through + * `POST /tables/{key}/fields`, and the ROUTE is the wall — `_field_or_refuse` allows + * only the creator or an admin, which is the same answer the grid would have got. + * + * ⚠ NARROW ON PURPOSE, twice over: `type === "automation"` AND a `ut_` scope. The + * connector surfaces have no such endpoint (their schema is their source's), and + * every other kind keeps the overlay path this wave — moving all of them changes who + * can SEE a column, which is a behaviour change three surfaces would feel and is + * booked as debt rather than smuggled in here. + * + * ⚠ AND IT IS ASYNC, where every other create is optimistic. There is no local + * insert first: the server re-slugs the key and can refuse outright (it 400s until + * `UT_FIELD_TYPES` learns `automation` — C2's other half, A's), and a column shown + * under a key the store never took is one that vanishes on the next read with no + * explanation. The wait is one round trip; the alternative is a lie. + */ + /** + * ⭐⭐ 2026-08-09 — THE RELATIONAL PAIR JOINS THAT ROUTE, and for a stronger reason than + * `automation` had. The note above says every other kind "keeps the overlay path this + * wave", weighing it as a question of who can SEE a column. For `link` and `rollup` it is + * not a visibility trade-off at all: their cells are computed SERVER-side by + * `compute_relation_cells`, which walks `user_tables` definitions. A rollup parked in a + * per-user overlay is invisible to the only code that could ever fill it, so it is not a + * narrower feature — it is a column that cannot work, in any account, ever. + * + * ⛔ AND THE BAG TRAVELS. `_clean_field` returns None for a bagless `link`/`rollup`, so + * sending `{key,label,type}` here would trade a silent blank column for a loud 400. The + * bag is on `extra`; `field` already carries it (see `FieldBuildExtra`). + */ + /** + * ⭐⭐ 2026-08-10 — `formula` JOINS THE SAME ROUTE, on the visibility argument the note + * above weighs and the relational note declines to use. + * + * For `link`/`rollup` visibility was not the point (their cells are computed by something + * that reads the definition). For a formula it IS the whole point, and the trade-off falls + * the other way from wave 21's read of it: a `ut_*` database is a SHARED database — the + * owner builds a column on "IG profile" and expects the four people looking at that + * database to see it. In the overlay stratum only its creator ever does. Measured on + * nurilab: `Trimmed reel views`, the trimmed-average column the whole rollup chain exists + * to produce, was visible to exactly one account. + * + * ⛔ THE VALUES STILL DO NOT MOVE, and this must not be sold as more than it is. A formula + * is computed in this browser (`computedRows`) from the row's other cells and is stored + * nowhere; what became shared is the EXPRESSION. An automation, a rollup and an export + * still cannot read this column. Sharing the definition is not materialising the number. + * + * ⚠ `agg: "sum"` rides along so the totals row behaves identically to the overlay path + * (`buildOverlayField` stamps it) — a column that stops totalling because of which door + * created it is [[gate-answers-the-wrong-question]] wearing a create route. + */ + /** + * ⭐⭐ WAVE-29 T22 (owner item 2a) — **EVERY grid-created column on a `ut_*` database goes + * to the DEFINITION.** The three notes above widened this set one kind at a time + * (`automation`, then `link`/`rollup`, then `formula`), each ending by asking whoever needed + * a fifth to come back here and decide. This is that decision, and it goes to ALL of them + * rather than to `select` alone, because the defect the owner reported is not about select: + * + * ⛔ **ON A `ut_` SCOPE THERE IS NO PER-USER CELL STRATUM LEFT TO WRITE TO.** Every accepted + * cell edit is routed to `user_tables.patch_cells` — the shared DEFINITION rows + * (`grid_events.py:1979-1984`) — while both `ut_` row doors project rows through the + * definition's own field keys (`routes_tables.scoped_pool:176`, `user_tables.add_row:678`). + * So a column living only in `
_table_workspace` accepts a value, stores it under a + * key the projection does not know, and reads back BLANK. **The write is not lost; the read + * cannot see it** — which is why the server reports the write as accepted and the cell is + * empty after any refetch. That is item 2a, and it applied to `text`, `int`, `date`, `user`, + * `image` and `checkbox` exactly as much as to `select`. The owner reported the one they + * happened to build. + * + * ⛔ **`created_time` is the ONE exclusion, and it is the SERVER's, not a preference:** + * `UT_FIELD_TYPES` does not contain it, so this POST would 400 (measured — CREATABLE_TYPES + * minus UT_FIELD_TYPES is exactly `{created_time}`). It keeps the overlay path, where it is + * harmless: it is `derived`, so it has no cell of its own to lose. + * + * ⚠ **The body is a PROJECTION of the field the constructor already built**, not a + * per-kind list of keys. The old spelling enumerated what each kind needed, which is how + * `options` came to be missing for the kind that needed it most — a select POSTed without + * its options is degraded to `text` on read (`aios_grid.py:664-668`). `buildOverlayField` + * decides the shape; this sends what it decided, so a new kind cannot arrive half-described. + * (`_clean_field` ignores what it does not know, so `custom`/`derived`/`multi` staying + * client-side costs nothing; `max` on a `rating` is the one key it drops — booked, not lost.) + */ + if (isUserTable && type !== "created_time") { + void addTableField(scope, { + key: field.key, + label: field.label, + type, + ...(field.options?.length ? { options: field.options } : {}), + ...(field.colorCodeOptions !== undefined + ? { colorCodeOptions: field.colorCodeOptions } + : {}), + ...(field.optionColors ? { optionColors: field.optionColors } : {}), + ...(extra?.link ? { link: extra.link } : {}), + ...(extra?.rollup ? { rollup: extra.rollup } : {}), + ...(type === "formula" && extra?.formula ? { formula: extra.formula } : {}), + ...(field.agg ? { agg: field.agg } : {}), + }).then((made) => { + if (!made) return; // the bridge has already said why + setFields((current) => + current.some((f) => f.key === made.key) ? current : [...current, made] + ); + insertColumn(made.key, anchorKey, side); + /** + * ⛔⛔ 2026-08-10 — THE WORKSPACE RE-READ USED TO FIRE HERE AND IT ATE THE LINE ABOVE. + * + * `insertColumn` adds the new key to the view's `visible` set and autosaves that config; + * `signal(WORKSPACE_STALE_EVENT)` refetches `/workspace`, whose views carry the SERVER's + * config — the one written a moment before the insert. The refetch won, every time, so + * a column created into the shared definition was **stored correctly and rendered + * nowhere**: present in the payload, present in `order` (which is re-derived from the + * field list), and absent from `visible`. MEASURED on live nurilab across all four + * views after migrating `Trimmed reel views`; it has been true for `link`, `rollup` and + * `automation` since that route was widened on 2026-08-09. + * + * ⛔ AND THE COMMENT IT REPLACES WAS THE REASON NOBODY LOOKED. It claimed the signal is + * "what makes the column survive a reload rather than living only in this browser's + * state" — but the SERVER already stored the field; the 201 is what makes it survive. + * The signal only refreshed a payload whose field list `setFields` had just updated by + * hand, and `addTableField` already drops the rows cache. It bought nothing and cost + * the one piece of state the create had just written. + * + * ⚠ The delete and reconfigure paths keep their signals: those change VALUES other + * columns fold (a dropped link takes its reciprocal and every rollup over it), and + * neither writes view config in the same breath, so neither has this race. + */ + }); + return; + } + saveField(field, fieldScope); + insertColumn(field.key, anchorKey, side); + }, + [insertColumn, saveField, scopeChoice, isUserTable, scope] + ); + + /** + * ⭐⭐ 2026-08-07 (D-79's last half) — turn a text column into THE profile column. + * + * The write goes to the DEFINITION (`PATCH /tables/{key}/fields/{fkey}`), never to this + * user's overlay stratum, for the reason wave 25's amendment C3-A1 records: the automation + * engine reads `t['rows']`/`t['fields']`, so a flag parked in one person's overlay is a flag + * the enrich step can never see. Same door, same reasoning, as the automation column above. + * + * ⚠ `profile: null` CLEARS it — `_clean_field` treats an explicit null as "take the flag off" + * rather than "leave it alone", which is what lets a column be un-marked without deleting it. + */ + const setProfileFlag = useCallback( + (key: string, on: boolean) => { + if (!isUserTable) return; + void patchTableField(scope, key, { + profile: on ? { source: "instagram" } : null, + }).then((made) => { + if (!made) return; // the bridge has already said why + setFields((current) => current.map((f) => (f.key === made.key ? made : f))); + signal(WORKSPACE_STALE_EVENT); + }); + }, + [isUserTable, scope] + ); + + /** + * ⭐⭐ 2026-08-09 (owner) — RECONFIGURE a `link`/`rollup` column, in the SHARED definition. + * + * Owner: *"even when i click edit field for rollup, it doesn't actually show me the + * configuration"* and *"No rollup field should be uneditable."* Both land here: the Edit pane + * now renders the bag, and this is where its Save goes. + * + * ⛔ NOT `saveField`, and not `retypeField`. Those write the per-user overlay stratum, which is + * precisely the defect this change exists to close — `compute_relation_cells` reads + * `user_tables`, so a bag parked in one person's workspace can never be computed by anything. + * ⚠ ASYNC with no optimistic insert, like the automation create: the server re-cleans the bag + * and can refuse the whole field (`_clean_rollup` returns None for, say, a `limit` with no + * `sortBy`), and showing a configuration the store never took is the lie the wait avoids. + * The cells arrive on the next read — a rollup is recomputed server-side, never in the browser. + */ + const setFieldConfig = useCallback( + (key: string, patch: { + label?: string; link?: unknown; rollup?: unknown; formula?: string; agg?: string; + }) => { + if (!isUserTable) return; + void patchTableField(scope, key, patch as Record).then((made) => { + if (!made) return; // the bridge has already said why + setFields((current) => current.map((f) => (f.key === made.key ? made : f))); + // The definition changed, so the wire's field list did too — and a rollup's VALUES are + // recomputed host-side on this write, so the ROWS are a beat stale as well. The bridge + // drops its rows cache for this table (it owns that map); these two ask for a re-read. + signal(WORKSPACE_STALE_EVENT); + signal(ROWS_STALE_EVENT); + }); + }, + [isUserTable, scope] + ); + + /** + * Owner item 9 — "Change field": the clicked column starts showing another field, in place. + * The new field takes the old one's slot in `order`; the old field is HIDDEN, not lost — it + * stays in Fields and can be swapped back. The locked primary column is the row identity and + * cannot be changed away (ColumnMenu never offers it the control). + */ + const changeField = useCallback( + (oldKey: string, newKey: string) => { + if (oldKey === lockedKey || oldKey === newKey) return; + if (!fieldByKey.has(newKey)) return; + // The RECONCILED order (every key present, locked first), so the splice is well-defined + // even for a fresh view whose config.order is still the default. + const withoutNew = order.filter((k) => k !== newKey); + const at = withoutNew.indexOf(oldKey); + if (at < 0) return; + withoutNew.splice(at, 0, newKey); + const nextVisible = new Set(visible); + nextVisible.delete(oldKey); + nextVisible.add(newKey); + updateConfig({ ...config, order: withoutNew, visible: [...nextVisible] }); + }, + [config, updateConfig, order, visible, fieldByKey, lockedKey] + ); + + /** + * Delete a USER-CREATED column outright (owner gap closed 2026-07-27). Only the created + * strata qualify — `custom_` overlay fields and `measure_` formula columns, both marked + * `custom` — so a base field can never leave the schema from here (Hide is its only exit). + * The definition leaves local state, the ACTIVE view's config is scrubbed (order / visible / + * widths / sorts / group / color), and the host removes it from the workspace store — other + * views self-heal on their next autosave, the rule every stale colId already rides. + * Two emits leave in one burst (field_delete + the config autosave); the event-log value + * slot exists for exactly this. + */ + const deleteField = useCallback( + (key: string) => { + const f = fieldByKey.get(key); + if (!f?.custom || key === lockedKey) return; + setFields((current) => current.filter((item) => item.key !== key)); + const nextWidths = { ...(config.widths ?? {}) }; + delete nextWidths[key]; + updateConfig({ + ...config, + order: order.filter((k) => k !== key), + visible: [...visible].filter((k) => k !== key), + widths: nextWidths, + sorts: (config.sorts ?? []).filter((s) => s.colId !== key), + groupBy: config.groupBy === key ? null : config.groupBy, + colorBy: config.colorBy === key ? null : config.colorBy, + }); + stampFieldDelete(key); + emitHostEvent({ id: eventId("fielddel"), type: "field_delete", key }); + }, + [config, updateConfig, order, visible, fieldByKey, lockedKey, stampFieldDelete] + ); + + /** + * ⭐⭐ 2026-08-10 — DELETE A COLUMN FROM A USER DATABASE'S SHARED DEFINITION. + * + * `deleteField` above writes the per-user overlay: it emits `field_delete`, which + * `grid_events` gates on the `custom_`/`measure_` prefix and applies to + * `
_table_workspace`. A definition column has a `custom_` key too, so that event is + * ACCEPTED and scrubs a bucket the definition never reads — the column disappears for one + * paint and is back on the next fetch. That is why this is a different function rather than a + * branch: two stores, two truths, and the wrong one succeeds quietly. + * + * ⚠ SERVER FIRST, THEN THE SCREEN. `_field_or_refuse` can say no (a pre-set column, a + * non-creator, the last remaining column), and the bridge has already shown its sentence — + * removing the column optimistically would leave the user looking at a grid that disagrees + * with the store until they reload. + * ⚠ The VIEW config is scrubbed on the same terms as the overlay delete; a stale `colId` in + * another view self-heals on its next autosave, the rule every other delete rides. + */ + const deleteDefinitionField = useCallback( + (key: string) => { + if (!isUserTable || key === lockedKey) return; + void deleteTableField(scope, key).then((ok) => { + if (!ok) return; // the bridge has already said why + setFields((current) => current.filter((item) => item.key !== key)); + const nextWidths = { ...(config.widths ?? {}) }; + delete nextWidths[key]; + updateConfig({ + ...config, + order: order.filter((k) => k !== key), + visible: [...visible].filter((k) => k !== key), + widths: nextWidths, + sorts: (config.sorts ?? []).filter((s) => s.colId !== key), + groupBy: config.groupBy === key ? null : config.groupBy, + colorBy: config.colorBy === key ? null : config.colorBy, + }); + signal(WORKSPACE_STALE_EVENT); + signal(ROWS_STALE_EVENT); + }); + }, + [isUserTable, scope, lockedKey, config, updateConfig, order, visible] + ); + + /** + * Change-field's "New field" half (wave-2 item 5): build the overlay field, then swap it into + * the clicked column's slot — one motion, two emits (field_upsert + the config autosave), + * which is exactly the burst the event-log value slot exists for. + */ + const createAndSwapField = useCallback( + ( + oldKey: string, + label: string, + type: FieldType, + options?: string[], + extra?: FieldBuildExtra + ) => { + const scope = scopeChoice ? extra?.scope : undefined; + const field = { + ...buildOverlayField(label, type, options, extra), + ...(scope === "cohort" ? ({ scope: "cohort" } as const) : {}), + }; + saveField(field, scope); + changeField(oldKey, field.key); + }, + [saveField, changeField, scopeChoice] + ); + + /** + * Wave-6 item 2 — Change field on a CREATED (`custom_`) field retypes THAT field in place: + * `field_upsert` with the SAME key and the new type/options; the host rebuilds the def and + * keeps createdBy/scope (this optimistic def carries them too, so the echo is byte-stable). + * Values are never converted — cells re-render per the new type, unreadable values show + * blank (Airtable behavior, disclosed in the pane). A display `format` survives only within + * its own family (number→number); anything else would be junk the host drops anyway. + */ + const retypeField = useCallback( + ( + key: string, + type: FieldType, + options?: string[], + // `label` (owner item 8): rename+retype leave the Edit pane as ONE upsert — two + // sequential emits would each read the stale def and revert the other. + extra?: FieldBuildExtra + ) => { + const old = fieldByKey.get(key); + /** + * ⭐⭐ WAVE-29 T27 — the same widening as the pane above, and it is load-bearing rather + * than cosmetic: supplying `onRetype` while this function still bailed would render a + * Field-type picker that silently does nothing, which is worse than the missing control + * the owner reported. + * + * ⭐ AND IT IS ALSO T24's SECOND HALF. Option colours have a validator now, but the EDIT + * path for them is this function: on a `ut_*` database the field lives in the shared + * DEFINITION, so `saveField` (this user's overlay stratum) would fork the column into a + * private copy of itself and the colours would never reach the door that stores them. + */ + if (!old || !isUserSchemaField(old, isUserTable)) return; + if (type === "formula" || type === "created_time") return; // a different stratum + const numberFam = (t: FieldType) => t === "int" || t === "currency"; + const keepFormat = + old.format && ((numberFam(old.type) && numberFam(type)) || old.type === type); + const next: Field = { + key, + label: extra?.label?.trim() || old.label, + type, + source: "overlay", + default: old.default, + custom: true, + agg: ["currency", "int"].includes(type) ? "sum" : undefined, + ...(old.note ? { note: old.note } : {}), + ...(old.description ? { description: old.description } : {}), + ...(old.scope ? { scope: old.scope } : {}), + ...(old.createdBy ? { createdBy: old.createdBy } : {}), + ...(old.permissions ? { permissions: old.permissions } : {}), + ...(keepFormat ? { format: old.format } : {}), + ...((type === "select" || type === "multiselect") && options?.length + ? { options } + : {}), + ...((type === "select" || type === "multiselect") + ? { + colorCodeOptions: extra?.colorCodeOptions !== false, + ...(extra?.optionColors && Object.keys(extra.optionColors).length + ? { optionColors: extra.optionColors } + : {}), + } + : {}), + ...(type === "multiselect" ? { multi: true } : {}), + ...(type === "rating" ? { max: extra?.max ?? 5 } : {}), + }; + if (isUserTable && !old.custom) { + /* THE DEFINITION DOOR. `patch_field` merges the body through the same `_clean_field` the + create used, so the type, the options, the colours and a rating's max all land in the + one validator — and the SERVER's echo replaces the local field, exactly as the create + path does, because it re-cleans what it was given and may refuse. */ + void patchTableField(scope, key, { + label: next.label, + type, + ...(next.options ? { options: next.options } : {}), + ...(next.optionColors ? { optionColors: next.optionColors } : {}), + ...(next.colorCodeOptions !== undefined + ? { colorCodeOptions: next.colorCodeOptions } + : {}), + ...(next.max !== undefined ? { max: next.max } : {}), + ...(next.agg ? { agg: next.agg } : {}), + ...(next.format ? { format: next.format } : {}), + }).then((made) => { + if (!made) return; // the bridge has already said why + setFields((current) => current.map((f) => (f.key === made.key ? made : f))); + signal(WORKSPACE_STALE_EVENT); + signal(ROWS_STALE_EVENT); + }); + } else { + saveField(next); + } + /* ⭐ Owner item 15 / C-RENAME — the VALUES follow the definition. + BESIDE the upsert, never instead of it, and AFTER it: the def is written optimistically + here, while the rename is a host-side migration over overlay values and saved views. The + order matters if the host processes the batch in order — the list must already offer + "Navy" before any cell is moved onto it. */ + const renames = extra?.renames?.filter((r) => r.from && r.to && r.from !== r.to) ?? []; + if (renames.length) { + emitHostEvent({ id: eventId("choicerename"), type: "choice_rename", key, renames }); + // R4 — and it is undoable: Ctrl+Z emits the mapping turned around. The declared LIST is + // restored by the same inverse (the host rewrites values and views back), so undo does + // not need to re-send the definition. + undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), { + kind: "choiceRename", fieldKey: key, renames, + }); + } + }, + // ⚠ `isUserTable` is in the list because T27 made this function BRANCH on it. A callback that + // closed over a stale `isUserTable` would route a definition retype to the overlay on the + // first render after a scope change — the exact defect this ticket removes, reintroduced by + // a memo rather than by a predicate. + [fieldByKey, saveField, scope, isUserTable] + ); + + /** + * Wave-5 item 1 — Duplicate field. The CLIENT generates the destination key (same stratum + * prefix as the source — the ~20:20 contract amendment), emits `field_duplicate`, and slots + * an optimistic clone right of its source SYNCHRONOUSLY. The host validates the prefix + * pair, stamps createdBy (the clone's creator is the duplicator — the local copy drops the + * source's), and copies overlay VALUES for `custom_` sources; those values arrive with the + * next payload, so the clone's cells are blank for exactly one round trip. + */ + const duplicateField = useCallback( + (source: Field) => { + const prefix = source.key.startsWith("measure_") ? "measure_" : "custom_"; + const label = uniqueDisplayName( + `${source.label} copy`, + fields.map((field) => field.label) + ); + const key = `${prefix}${slugify(label)}_${Math.random().toString(36).slice(2, 7)}`; + const requestId = eventId("fielddup"); + const clone: Field = { ...source, key, label, editRequestId: requestId }; + delete clone.createdBy; + delete clone.permissions; + delete clone.labelCorrectedFrom; + delete clone.labelCorrectionId; + stampFieldEdit(key); + setFields((current) => [...current, clone]); + emitHostEvent({ + id: requestId, + type: "field_duplicate", + sourceKey: source.key, + key, + label, + // Item 9c: a duplicate INHERITS its source's scope (cohort clone stays cohort-only; + // a global source stays global = absent). Only meaningful on the cohort page. + ...(scopeChoice && source.scope === "cohort" ? { scope: "cohort" as const } : {}), + }); + insertColumn(key, source.key, "right"); + }, + [insertColumn, scopeChoice, stampFieldEdit, fields] + ); + + /** + * Wave-2 item 8c — change the PERIOD of a measure-carrying column (user-created `measure_*` + * and the pre-set measure fields alike). The header auto-renames to + * ` · ` ONLY while it still reads as the auto-name for the CURRENT + * window — a user's own title is never overwritten. Values arrive recomputed from the host on + * the next payload; the ordinary `field_upsert` is the whole protocol. + */ + const changeMeasurePeriod = useCallback( + (key: string, window: WindowSpec) => { + const f = fieldByKey.get(key); + if (!f?.measure) return; + const m = measures.find((item) => item.key === f.measure?.key); + const oldAuto = m ? `${m.label} · ${windowLabel(f.measure.window)}` : null; + const label = + m && oldAuto && f.label === oldAuto + ? `${m.label} · ${windowLabel(window)}` + : f.label; + saveField({ ...f, label, measure: { ...f.measure, window } }); + }, + [fieldByKey, measures, saveField] + ); + + /* wave17 R1 — `selectCohort`, `renameCohort`, `deleteCohort` and `exportCohortData` are + gone. Every one of them was called ONLY by `CohortSidebar`, and every one now has a + better-governed twin: the Views rail renames and deletes a locked view through the + ordinary view path, and the HOST routes those to `cohort_mod.rename` / `.delete` on a + projected id (C-LOCKV guards b and c). One name, one lifecycle, one set of events. */ + + /** + * Cohort mode — the selection bar's "Remove from cohort" (contract event `cohort_remove`). + * The pids are intersected with the cohort's membership client-side as a courtesy; the host + * re-validates ownership and pool anyway (a pid from the browser is untrusted input). + */ + const removeSelectionFromCohort = useCallback(() => { + if (!activeCohortId) return; + const pids = [...selectedPids].filter((p) => cohortMemberSet.has(p)); + if (!pids.length) return; + emitHostEvent({ + id: eventId("cohortrm"), + type: "cohort_remove", + cohortId: activeCohortId, + pids, + }); + clearSelection(); + }, [activeCohortId, selectedPids, cohortMemberSet, clearSelection]); + + + /** + * ⭐ Wave-20 owner item 9 — **REMOVE FROM A COHORT WITHOUT BEING INSIDE IT.** + * + * "Remove from cohort" existed only under `cohortMode` (you had to open the locked view + * first), while "Add to cohort" worked from any view. So the two halves of the same idea + * lived on different screens: you could put a customer in a cohort from wherever you found + * them, and then had to go looking for the cohort to take them out again. + * + * SAME EVENT, same host validation as the cohort-mode button — `cohort_remove` with the pids + * intersected client-side as a courtesy. What is new is only WHICH cohort: the picker names + * it, instead of it being implied by the page you are standing on. + */ + const removeSelectionFromList = useCallback( + (cohortId: string) => { + const members = cohortSets[cohortId]; + if (!members) return; + const pids = [...selectedPids].filter((p) => members.has(p)); + if (!pids.length) return; + emitHostEvent({ id: eventId("cohortrm"), type: "cohort_remove", cohortId, pids }); + setSelRemoveOpen(false); + clearSelection(); + }, + [cohortSets, selectedPids, clearSelection] + ); + + /** + * The cohorts the checked rows can actually be removed FROM: those holding at least one of + * them, and only where this viewer may edit the projected view. + * + * ⚠ A cohort holding NONE of the checked rows is not offered. Airtable's rule and the one R8 + * states for the ghost row: an affordance that can only refuse is a fake affordance — and + * here it would be worse than that, because "Remove from Q3 plan" that removes nothing looks + * exactly like a write that failed. + * + * The edit test is the COURTESY half (`mayEditView`, like every other client-side permission + * check on this surface); the host re-validates ownership and pool on the event regardless. + * A cohort with no projected view is still offered — the host owns that verdict, and hiding + * it here would silently drop sets the reader can see in their own rail. + */ + const removableLists = useMemo(() => { + if (cohortMode || selectedPids.size === 0) return []; + const out: { id: string; name: string; hits: number }[] = []; + for (const l of lists) { + const members = cohortSets[l.id]; + if (!members) continue; + let hits = 0; + for (const pid of selectedPids) if (members.has(pid)) hits += 1; + if (!hits) continue; + const projected = views.find((v) => v.id === l.id); + if (projected && !mayEditView(projected, viewer)) continue; + out.push({ id: l.id, name: l.name, hits }); + } + return out; + }, [cohortMode, selectedPids, lists, cohortSets, views, viewer]); + + /** Cohort mode — confirm the "+ Add customers" picker (contract event `cohort_add`). */ + const addCustomersToCohort = useCallback(() => { + if (!activeCohortId || addCustPicked.size === 0) return; + emitHostEvent({ + id: eventId("cohortadd"), + type: "cohort_add", + cohortId: activeCohortId, + pids: [...addCustPicked], + }); + setAddCustPicked(new Set()); + setAddCustQuery(""); + setAddCustOpen(false); + }, [activeCohortId, addCustPicked]); + + /** + * The picker's candidates: the POOL minus the cohort's current members, narrowed by the + * search. The pool's own order is kept (it leads with the biggest customers, which is the + * useful default for "who am I adding"). + */ + const addCandidates = useMemo(() => { + if (!addCustOpen) return []; + const q = addCustQuery.trim().toLowerCase(); + const out: { pid: number; name: string }[] = []; + for (const r of rawRows) { + if (cohortMemberSet.has(r.pid)) continue; + const name = String(r[lockedKey] ?? ""); + if (q && !name.toLowerCase().includes(q)) continue; + out.push({ pid: r.pid, name }); + } + return out; + }, [addCustOpen, addCustQuery, rawRows, cohortMemberSet, lockedKey]); + + /** + * Wave-6 item 11c — PIN. "Pin up to this field" freezes every visible column through the + * clicked one; when the field sits past the clamp (8), it is REORDERED into the frozen + * prefix first (the contract's wording). Pinning the identity column reads as "just the + * identity" = the legacy 1, stored as ABSENT. + * + * ⚠ CORRECTED wave-14 item 15. This used to read "Grouped mode paints frozenCount 0 (spans + * break under freezing)". **Spans do not break under freezing** — glide splits them + * (`getSpanBounds` → `[frozenRect, contentRect]`) and draws the row's contents from the frozen + * half. What IS true, and is the real cost of the pin, is that those contents are then clipped + * to the frozen strip's width; `fitGroupLabel` is what makes that clip honest. The clamp is + * gone: `freezeColumns` is `frozenN` in every mode. + */ + const visibleKeys = useMemo( + () => order.filter((key) => visible.has(key)), + [order, visible] + ); + const frozenN = frozenCountOf(config); + const pinFieldTo = useCallback( + (key: string) => { + const index = visibleKeys.indexOf(key); + if (index < 0) return; + if (index < MAX_FROZEN) { + updateConfig({ ...config, frozenCount: clampFrozenCount(index + 1) }); + return; + } + const moved = visibleKeys.filter((k) => k !== key); + moved.splice(MAX_FROZEN - 1, 0, key); + let cursor = 0; + const nextOrder = order.map((k) => (visible.has(k) ? moved[cursor++] : k)); + updateConfig({ ...config, order: nextOrder, frozenCount: MAX_FROZEN }); + }, + [visibleKeys, order, visible, config, updateConfig] + ); + const unpinFields = useCallback( + () => updateConfig({ ...config, frozenCount: undefined }), + [config, updateConfig] + ); + + // Wave-6 item 10 — display-mode plumbing. W13 (contract C4 as AMENDED 2026-07-28): + // `config.display` is the ONE home for the field picks, so leaving a mode must NOT + // wipe them — returning to grid keeps the refs in the draft (mode 'grid' + refs; + // cleanDisplay's carry), which is what lets the next Kanban/Calendar restore the + // chosen field instead of feeding the select its default. A view that never picked + // stays byte-identical (ref-less grid normalizes to absent, the legacy rule). + const setDisplayMode = useCallback( + (m: DisplayMode) => { + // I12 (C3) — a locked view's MODE is frozen. The switcher is already hidden for one, + // so reaching here means something other than the switcher asked; refuse rather than + // trust the UI. (The host refuses too — this only stops the local state diverging from + // what the host will store, which would look like a working change that never saved.) + // (Resolved from `views` here rather than the `activeView` binding below — this + // callback is declared above it.) + const av = views.find((v) => v.id === activeViewId); + if (av && isModeFrozen(av)) return; + const spec = cleanDisplay(config.display); + if (m === "grid") { + updateConfig({ + ...config, + display: spec ? cleanDisplay({ ...spec, mode: "grid" }) : undefined, + }); + return; + } + updateConfig({ ...config, display: { ...(spec ?? {}), mode: m } }); + }, + [config, updateConfig, views, activeViewId] + ); + // C4 (amended): the pick is written INTO display and the selects are FED from the + // view def (`display.`), never from component state that dies on unmount. + const setDisplayField = useCallback( + (k: "dateField" | "stackField" | "colorField" | "sizeField", key: string) => { + const spec = cleanDisplay(config.display); + if (!spec) return; + // Wave-8 I3/I5: colour/size are OPTIONAL encodings, so "" is a real choice + // (none) and must delete the ref rather than store an empty key that no + // field can ever match. + const next = { ...spec, [k]: key }; + if (!key) delete (next as Record)[k]; + updateConfig({ ...config, display: next }); + }, + [config, updateConfig] + ); + /** + * Item 3 (C-DISP) — the kanban card clamp. + * + * ⚠ `undefined` DELETES the key rather than storing `true`. Absent means clamped, so a + * stored `true` would be the default wearing a second name — and the host's `_clean_display` + * accepts the literal `false` only, so a `true` would be dropped on save and the toggle + * would look like it does not persist. Delete-to-default keeps both ends agreeing. + */ + const setKanbanClamp = useCallback( + (next: false | undefined) => { + const spec = cleanDisplay(config.display); + if (!spec) return; + const display = { ...spec }; + if (next === false) display.kanbanClamp = false; + else delete (display as Record).kanbanClamp; + updateConfig({ ...config, display }); + }, + [config, updateConfig] + ); + /** + * ⭐ WAVE-27 item 8 (owner ruling R2, contract C3) — the swipe BINDING. + * + * ⚠ `undefined` DELETES the key, for `setKanbanClamp`'s reason one step further on. A swipe + * binding is a single three-part thing (field + two options); both engines' `_clean_display` + * DROP a half-binding rather than storing it, so writing one back would look like a save that + * silently did not persist. Absent IS the unconfigured state. + */ + const setSwipeSpec = useCallback( + (next: SwipeSpec | undefined) => { + const spec = cleanDisplay(config.display); + if (!spec) return; + const display = { ...spec }; + if (next) display.swipe = next; + else delete (display as Record).swipe; + updateConfig({ ...config, display }); + }, + [config, updateConfig] + ); + /** + * ⭐⭐ WAVE-29 C4 / T29 (owner R7) — the FORM spec's writer, shaped exactly like `setSwipeSpec` + * above it: `null` DELETES the key rather than storing an empty object, because "this view has + * no form" and "this view has a form with nothing in it" are different states and only one of + * them should survive a reload. `_clean_display` drops an empty bag anyway, so storing one + * would make clearing a form look like it did not save. + */ + const setFormSpec = useCallback( + (next: FormSpec | null) => { + const spec = cleanDisplay(config.display); + if (!spec) return; + const display = { ...spec }; + if (next) display.form = next; + else delete (display as Record).form; + updateConfig({ ...config, display }); + }, + [config, updateConfig] + ); + /** + * Item 4 (C-DISP) — the per-day totals. An EMPTY list deletes the key rather than storing + * `[]`: the always-on Records count is what an absent list means, so `[]` would be that + * state wearing a second name — and `_clean_display` drops an empty array anyway, which + * would make un-picking the last metric look like it did not save. + */ + const setCalendarMetrics = useCallback( + (next: { id: string; field: string; agg: string }[]) => { + const spec = cleanDisplay(config.display); + if (!spec) return; + const display = { ...spec }; + const capped = next.slice(0, MAX_CALENDAR_METRICS); + if (capped.length) display.calendarMetrics = capped; + else delete (display as Record).calendarMetrics; + updateConfig({ ...config, display }); + }, + [config, updateConfig] + ); + /** Item 4 (C-DISP) — records vs summaries. `records` is the default, so it deletes the key. */ + const setCalendarMode = useCallback( + (next: "records" | "summary") => { + const spec = cleanDisplay(config.display); + if (!spec) return; + const display = { ...spec }; + if (next === "summary") display.calendarMode = "summary"; + else delete (display as Record).calendarMode; + updateConfig({ ...config, display }); + }, + [config, updateConfig] + ); + // The calendar's date field: the view's pick when it is still a real date-family field, + // else `last_order`, else the first date-family field. The kanban's stack field: status or + // single-select ONLY (a multiselect card in two columns at once is a count lie — contract). + const dateFieldChoices = useMemo( + () => fields.filter((f) => isDateFamilyType(f.type)), + [fields] + ); + const stackFieldChoices = useMemo( + () => fields.filter((f) => f.type === "status" || f.type === "select"), + [fields] + ); + const pickBy = (want: string | undefined, choices: Field[], preferred?: string): Field | undefined => { + const wanted = want ? choices.find((f) => f.key === want) : undefined; + if (wanted) return wanted; + const pref = preferred ? choices.find((f) => f.key === preferred) : undefined; + return pref ?? choices[0]; + }; + // W13 (C4 as amended): the VIEW DEF's display feeds the pickers — the refs survive + // every mode switch via cleanDisplay's grid carry. pickBy already falls back to the + // default when the stored key no longer names an offerable field (a dropped ref). + // I3/I5 — the map's encodings. Deliberately NOT resolved through `pickBy`: + // that helper falls back to the first choice, which is right for the calendar + // (a calendar must have a date) and wrong here, where "no encoding" is a + // legitimate, and the DEFAULT, state. An unknown stored key resolves to + // undefined = no encoding, which is also the dropped-ref behaviour. + // I19c (C2) — the chart list lives in `config.display.charts`, so it rides the + // existing view autosave + viewEcho reconcile and needs no new event type. + const charts = useMemo( + () => cleanCharts(displaySpec?.charts) ?? [], + [displaySpec?.charts] + ); + const setCharts = useCallback( + (next: ChartSpec[]) => { + const spec = cleanDisplay(config.display); + if (!spec) return; + const display = { ...spec, charts: next }; + if (!next.length) delete (display as Record).charts; + updateConfig({ ...config, display }); + }, + [config, updateConfig] + ); + const colorFieldChoices = useMemo( + () => fields.filter((f) => f.type === "status" || f.type === "select"), + [fields] + ); + const sizeFieldChoices = useMemo( + () => fields.filter((f) => isNumericFieldType(f.type)), + [fields] + ); + /** + * ⭐⭐ W37-T28 / CONTRACT C3 (ruling R5) — THE PER-VIEW CATALOG SETTINGS and THE COORDINATE FIELD. + * + * ⛔ `display.catalog`, SINGULAR, is NOT `display.catalogs`. The plural is the wave-18 list of + * print artifacts; this is THIS VIEW's choice of which column is the picture and which is the + * title (R5: per view, so two catalog views on one database may differ). One letter apart, both + * live, neither validator reads the other's key. + * ⚠ Both refs resolve THROUGH `fields`, so a column that was deleted degrades to `undefined` and + * D's renderer falls back to its own default rather than joining on a key nothing matches. + */ + const catalogSpec = displaySpec?.catalog; + /** + * ⭐ D-25: pass a STABLE, UNIQUE key and never a display label. The catalog stores the identity + * STRING inside the page (`pages[].products`), so binding it to a user-editable name would make + * every item on every page vanish the moment somebody renamed a record. `code` where the table + * has one, otherwise nothing — and absent means D joins on `code`, which is today's behaviour. + */ + const catalogIdentityField = useMemo( + () => fields.find((f) => f.key === CATALOG_CODE_FIELD), + [fields] + ); + /** + * ⭐⭐ W37-T28 (item 15) — THE MAP'S COORDINATE COLUMN, chosen per view. + * + * ⛔ ONE field holding `"lat,lon"`, never a PAIR of props (D-4). Absent keeps the hardcoded + * `lat`/`lon` column pair, which is what every map view saved before this wave carries and is + * the whole reason this is not a migration. + * ⚠ The choices are deliberately WIDE (D-5: "any field whose cell can hold `lat,lon` text"), and + * that costs nothing — a cell that does not parse yields no pin and lands in the existing + * "N matching records have no location" chip rather than in an error. + */ + const coordFieldChoices = useMemo( + () => fields.filter((f) => f.type === "text" || f.type === "url" || f.type === "email"), + [fields] + ); + const mapCoordField = displaySpec?.coordField + ? coordFieldChoices.find((f) => f.key === displaySpec.coordField) + : undefined; + /** + * ⛔ ITS OWN CALLBACK, NOT A FIFTH KEY ON `onPickField` (D-5, and D tried the union first). + * `setDisplayField` is typed to the narrow four-key union, so widening the prop makes this + * handler unassignable under `strictFunctionTypes` — in MY file, which D may not edit. One extra + * prop is cheaper than a red `tsc` for four lanes. + */ + const onPickCoordField = useCallback( + (key: string) => { + const spec = cleanDisplay(config.display); + if (!spec) return; + const next: DisplaySpec = { ...spec }; + if (key) next.coordField = key; + else delete next.coordField; + updateConfig({ ...config, display: next }); + }, + [config, updateConfig] + ); + /** + * ⛔⛔ AN ADAPTER, AND IT EXISTS BECAUSE THE STORED SHAPE IS DECLARED TWICE (reported as + * `NOTE C-23`). `types.ts::CatalogViewSpec` mirrors `aios_grid._clean_catalog_spec`, which emits + * `sections` and `items` ONLY WHEN NON-EMPTY — an empty "New Section" reads back with no `items` + * key at all, which is asserted and NC'd on both engines. `CatalogView.tsx` declares its OWN + * copy with both REQUIRED, so a spec straight out of the store does not satisfy it. + * ⚠ D's RUNTIME code is already correct (`page?.sections ?? []`); it is the TYPE that is stricter + * than the store can produce. So this fills the absent lists at the boundary rather than + * pretending the stored shape has them. + * ⭐ THE REAL FIX IS ONE DECLARATION, in `types.ts`, imported by the component — the rule this + * repo already wrote for `FormSpec` after the same mistake ("a stored shape belongs to the type + * module; the COMPONENT that edits it belongs to the component"). Raised with D and A; until it + * lands, an adapter that says why beats a mount that does not compile. + */ + const catalogSpecForView = useMemo( + () => + catalogSpec && { + ...catalogSpec, + pages: (catalogSpec.pages ?? []).map((page) => ({ + ...page, + sections: (page.sections ?? []).map((sec) => ({ ...sec, items: sec.items ?? [] })), + })), + }, + [catalogSpec] + ); + /** R5 — the per-view catalog picks, persisted the way every other display key rides. */ + const onCatalogSpec = useCallback( + (next: { imageField?: string; titleField?: string }) => { + const spec = cleanDisplay(config.display) ?? { mode: "catalog" as const }; + updateConfig({ ...config, display: { ...spec, catalog: { ...spec.catalog, ...next } } }); + }, + [config, updateConfig] + ); + const mapColorField = displaySpec?.colorField + ? colorFieldChoices.find((f) => f.key === displaySpec.colorField) + : undefined; + const mapSizeField = displaySpec?.sizeField + ? sizeFieldChoices.find((f) => f.key === displaySpec.sizeField) + : undefined; + const calendarField = pickBy(displaySpec?.dateField, dateFieldChoices, "last_order"); + const kanbanField = pickBy(displaySpec?.stackField, stackFieldChoices); + /* ⭐ WAVE-27 item 8 (C3) — the BOUND field, resolved by key and NOT through `pickBy`. `pickBy` + falls back to the first choice when the stored key is unknown, which is right for a kanban + (a stack field is a preference) and wrong here: SwipeView SHOWS a rotted binding rather than + guessing at one, so it must receive the loss instead of a silent substitute — the wave-7 trap + [[wrong-parent-not-broken-control]] and the component's own header note both name. */ + const swipeField = displaySpec?.swipe + ? fieldByKey.get(displaySpec.swipe.fieldKey) + : undefined; + // Calendar/kanban project the FULL pipeline result (a month/stack is its own bound), as + // DISTINCT data rows — grouping can repeat a pid — with overlay edits layered so a dragged + // card restacks and an edited title repaints without waiting on any echo (item 3c). + const modeDataRows = useMemo(() => { + if ( + displayMode !== "calendar" && + displayMode !== "kanban" && + displayMode !== "map" && + displayMode !== "chart" && + // C-TS close-out stitch: the panel's pids come from these rows; without this line the + // time-series view asked its question about ZERO customers (caught by the mounted + // judge — the node battery is blind here). + displayMode !== "timeseries" && + /* ═══ W18-C CATALOG ═══ The designer's "add the products this view shows" door reads + these rows — the same rows the toolbar counts, so the two can never disagree about + which products the filter kept. The catalog's PAINT does not: it joins its stored + codes against the whole pool, so a filter narrows what you can ADD and never what a + finished page prints. ═══ end W18-C CATALOG ═══ */ + displayMode !== "catalog" && + /* ⭐ WAVE-27 item 8 (C3) — the swipe deck is these rows filtered to the undecided ones. + Without this line the deck is derived from `[]`, so the view paints its "nothing left + to decide" empty state over a table full of undecided records — the C-TS stitch above, + repeated. */ + displayMode !== "swipe" + ) + return []; + const seen = new Set(); + const out: Row[] = []; + for (const vr of visibleRows) { + if (vr.kind !== "data" || seen.has(vr.record.pid)) continue; + seen.add(vr.record.pid); + const edits = overlayEdits[vr.record.pid]; + out.push(edits ? { ...vr.record, ...edits } : vr.record); + } + return out; + }, [displayMode, visibleRows, overlayEdits]); + // Owner item 1 — the CALL-SITE half of the kanban memo. `KanbanView` is `memo`'d now, but a + // memo whose props are rebuilt every render never bails, and these two were: `cardKeys` is a + // fresh array from `.filter().slice()`, `onMove` a fresh closure. With them stable, a + // `detailPid` change (clicking a card) re-renders the modal and skips the board entirely — + // which is the whole of "laggy record-open". Everything else KanbanView takes was already + // stable: `modeDataRows`/`fieldByKey` are useMemos, `kanbanField` is a reference INTO the + // `stackFieldChoices` useMemo, and `onOpen={setDetailPid}` is a setState identity. + const kanbanCardKeys = useMemo( + () => + kanbanField + ? visibleKeys.filter((k) => k !== lockedKey && k !== kanbanField.key).slice(0, 3) + : [], + [visibleKeys, lockedKey, kanbanField] + ); + const onKanbanMove = useCallback( + (pid: number, value: string) => { + if (!kanbanField) return; + patchAndRecord(pid, { [kanbanField.key]: value }, "a card move"); + }, + [kanbanField, patchAndRecord] + ); + /* ⭐ WAVE-27 item 8 (C3) — a swipe writes through the NORMAL cell door, the same + `patchAndRecord` a card move uses. That is the whole reason the deck needs no permission + logic, no undo entry and no echo handling of its own: it inherits all three. */ + const onSwipeDecide = useCallback( + (pid: number, value: string) => { + if (!swipeField) return; + patchAndRecord(pid, { [swipeField.key]: value }, "a swipe"); + }, + [swipeField, patchAndRecord] + ); + /** + * ⭐ WAVE-26 ITEM 12 (owner ruling R8) — the kanban's "Add option" door. + * + * It opens the ORDINARY ColumnMenu on the stack field, straight into its Edit-field pane. So + * the option is written into the SHARED field definition by the same `onRetype` upsert the + * header menu uses — never a per-view list — and every guarantee that editor already carries + * (permission wall, rename-by-row-identity, option colours, one upsert for name+choices) comes + * along without being re-implemented. R8's "do not invent a second options editor", literally. + * + * ⛔ THE PERMISSION QUESTION IS NOT `canEditField`. Adding an option changes the column's + * DEFINITION; `mayEditField` answers who may type a VALUE into it, and conflating the two is + * the confusion [[schema-role-is-not-a-value-wall]] exists to prevent. The right predicate is + * the one that decides whether `onRetype` is supplied at all — a created overlay field — so it + * is written ONCE here and read at both doors. + * + * `null` (not "absent") when the field cannot take one: a status column is computed by the + * source system, and an Odoo select's vocabulary lives in Odoo. + * + * ⭐ THE PREDICATE ADMITS AN AUTOMATION'S `stage_` COLUMN, and that is INTENDED rather than + * incidental — worth stating because it was arrived at by matching `onRetype`'s condition, and + * a coincidence and a decision look identical in code. It is exactly what R6 and R8 compose + * to: R6 deleted the board's built-in terminals, so the lanes a review stage offers are now + * only the ones a user defined — and R8 is the control that lets them define one. Landed + * without this the pair is worse than either alone (SESSION A's A-7 says the same thing from + * the engine side: `humanMoves` had to learn to admit user-added options, or the move door + * would refuse the lane the product just invited you to create). + * ⚠ CARRIED CONSEQUENCE, pre-existing and not introduced here: the pane this opens is the + * whole Edit-field window, so it also offers the TYPE picker — one wrong click retypes a stage + * column. The header menu has offered exactly that on the same column since wave 6; this door + * adds a second way in, not a new hazard. Written down rather than left to be rediscovered. + */ + /* ⭐ WAVE-29 T27: the same stratum predicate as the Edit-field pane, for the same reason — this + door OPENS that pane, so gating the two differently means offering an add-option control that + leads to a pane with no editor in it. On a `ut_*` kanban (every automation board) the old + `custom` test was false for every column, so the lane header offered nothing. */ + const kanbanCanAddOption = + !!kanbanField && kanbanField.type === "select" && + isUserSchemaField(kanbanField, isUserTable); + const onKanbanAddOption = useCallback( + (anchor: AnchorRect) => { + if (!kanbanField) return; + setColumnMenu({ fieldKey: kanbanField.key, anchor, pane: "edit" }); + }, + [kanbanField] + ); + const onListToggleGroup = useCallback((groupKey: string) => { + setCollapsed((current) => { + const next = new Set(current); + if (next.has(groupKey)) next.delete(groupKey); + else next.add(groupKey); + return next; + }); + }, []); + /** + * Item 7 (C-TS) — the pid set the time series asks about: exactly the rows the current + * filter kept, so the panel and the toolbar count can never disagree about "who". + * + * `modeDataRows` is already the de-duplicated data rows for a non-grid mode, so this is a + * projection of it and NOT a second pipeline. The server intersects with the caller's book + * anyway — this narrows the question, it can never widen the answer. + */ + const timeseriesPids = useMemo( + () => (displayMode === "timeseries" ? modeDataRows.map((r) => r.pid) : []), + [displayMode, modeDataRows] + ); + /* ═══ W18-C CATALOG ═══ (owner item 4, contract C6) + The catalogs this view holds, and the door that persists an edit to them. + + `displaySpec.catalogs` is ALREADY validated — `cleanDisplay` ran `cleanCatalogs` over it on + read — so there is no second normalisation here and no chance of the two disagreeing. + + ⚠ The write goes through `updateConfig` exactly like `onTimeseriesDisplay` below, which + means a catalog edit is a VIEW-CONFIG save. That is the point: a catalogue is authored + content and has to survive a reload, and the display spec is the only per-view store the + grid has. It also means the 500-code / 40-page / 12-catalog caps are enforced twice on the + round trip (here on read, and by the host's `_clean_catalogs` on write) — the designer + states its budget in the toolbar so the cap is never the user's first news of it. */ + const catalogs = useMemo( + () => (displayMode === "catalog" ? (displaySpec?.catalogs ?? []) : []), + [displayMode, displaySpec] + ); + /** The codes the current filter kept — the designer's "add what the view shows" shortcut. + * Derived from `modeDataRows`, so it counts what the toolbar counts. */ + const catalogFilteredCodes = useMemo( + () => + displayMode === "catalog" + ? modeDataRows + .map((r) => r[CATALOG_CODE_FIELD]) + .filter((c): c is string => typeof c === "string" && !!c) + : [], + [displayMode, modeDataRows] + ); + const onCatalogs = useCallback( + (next: CatalogSpec[]) => { + const spec = cleanDisplay(config.display) ?? { mode: "catalog" as const }; + updateConfig({ ...config, display: { ...spec, catalogs: next } }); + }, + [config, updateConfig] + ); + /* ═══ end W18-C CATALOG ═══ */ + /** Persist a bucket/span/metric pick onto the view, the way every other display key rides. */ + const onTimeseriesDisplay = useCallback( + (next: Partial) => { + const spec = cleanDisplay(config.display) ?? { mode: "timeseries" as const }; + updateConfig({ ...config, display: { ...spec, ...next } }); + }, + [config, updateConfig] + ); + const kanbanCanMove = + !!kanbanField && kanbanField.type === "select" && canEditField(kanbanField); + const kanbanReason = !kanbanField + ? null + : kanbanField.type === "status" + ? `Stacked by ${kanbanField.label}. That field is read-only, computed from the source system.` + : !canEditField(kanbanField) + ? "You do not have permission to edit this field, so cards cannot be moved." + : null; + /* ⭐ WAVE-27 item 8 (C3) — the kanban pair, said again for the deck. Same wall, same words + shaped for the gesture: a swipe writes a VALUE, so `canEditField` is the right predicate. */ + const swipeCanWrite = !!swipeField && swipeField.type === "select" && canEditField(swipeField); + const swipeReason = !swipeField + ? null + : swipeField.type === "status" + ? `Bound to ${swipeField.label}. That field is read-only, computed from the source system.` + : !canEditField(swipeField) + ? "You do not have permission to edit this field, so records cannot be decided." + : null; + + if (loading || !workspaceReady) { + /* wave17 GRID — item 3 / owner R6: one small bare rotating icon, NO WORDS. `aria-label` + is not a word on screen and stays: `.lp-spin` is an empty span, so without it the wait + is announced to a screen reader as nothing at all. (C-SPIN, SHELL defines the class.) + `--lg` because this is the case C-SPIN sizes it for — the whole surface is this mark and + nothing else, where the 14px version "reads as dust". */ + return ( +
+ +
+ ); + } + + const activeView = views.find((view) => view.id === activeViewId); + // Cohort mode's toolbar control (rendered by Toolbar via the `cohortAction` slot; the popover + // it opens is with the other overlays at the bottom). Disabled without an active cohort — + // zero cohorts is the host page's near-empty state, not this button's error to explain. + const cohortAction = cohortMode ? ( + + ) : undefined; + // Wave-7 item W9 — the Fields panel's permanent delete rides the SAME wall as the + // column menu's Delete: created strata only (`custom`), never the locked identity + // column. One predicate, two doors. + // + // ⚠ WAVE-29 T22 NARROWED WHAT THIS SET CONTAINS, and the narrowing is CORRECT rather than an + // oversight — said out loud because it looks like a regression. A grid-created column on a + // `ut_*` database now lives in the shared DEFINITION and carries no `custom` flag, so it drops + // out of this set. It has to: this door emits `field_delete`, which scrubs the per-user + // workspace overlay — a bucket the definition never reads — so the column would vanish for one + // paint and be back on the next fetch (`deleteDefinitionField`'s note says the same thing from + // the other side). ⛔ The consequence is real and is NOT silently widened here: a definition + // column can only be deleted through the column menu, which offers it for `link`/`rollup`/ + // `formula` alone (D-114's deliberate narrowness — every other kind holds real values). Booked + // as pending work rather than fixed by handing a type-blind delete to a panel. + const deletableKeys = new Set( + fields.filter((f) => f.custom && f.key !== lockedKey).map((f) => f.key) + ); + const menuField = columnMenu ? fieldByKey.get(columnMenu.fieldKey) : undefined; + // Wave-5 item 3 — what the CURRENT VIEW does with the menu's field, so the conditional + // "Don't sort/filter/group" entries render exactly when they apply. + const menuSortedDir = menuField + ? config.sorts.find((s) => s.colId === menuField.key)?.dir ?? null + : null; + const menuIsFiltered = menuField + ? treeNamesField(config.filters, menuField.key, measureCols) + : false; + // Item 11c — is the menu's field the current frozen boundary (its Pin would be a no-op)? + const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1; + const menuPinnedTo = menuVisIndex >= 0 && frozenN > 1 && menuVisIndex + 1 === frozenN; + // Item 10 — the toolbar's mode switcher (grid-only on windowed tables, see displayMode). + const modeControl = serverWindowed || embedded || isQueryPreview ? undefined : ( + 0 && onPickCoordField`, so there is no + arrangement that paints a control which does nothing + (`web_map::coord-picker-has-a-handler` asserts it by name). */ + coordField={mapCoordField} + coordChoices={coordFieldChoices} + onPickCoordField={onPickCoordField} + // Items 3 + 4 (C-DISP). `kanbanClamp` is absent-means-clamped, so the boolean handed + // down is `!== false` rather than a truthiness test — the one place that distinction + // decides whether the user's opt-out survives a reload. + clamped={displaySpec?.kanbanClamp !== false} + onClamp={setKanbanClamp} + calendarMode={displaySpec?.calendarMode ?? "records"} + onCalendarMode={setCalendarMode} + calendarMetrics={displaySpec?.calendarMetrics} + // The numeric family, exactly what C-DISP makes eligible — and the same memo the map's + // bubble-size picker feeds from, so "what can be totalled" has one definition. + metricChoices={sizeFieldChoices} + onCalendarMetrics={setCalendarMetrics} + /> + ); + // ⭐ Wave-23 C7 — the open document, resolved exactly like the picker's value below it: + // the OPTIMISTIC edit wins over the raw record, so a save the server has not echoed yet is + // what re-opening the cell shows (the echo-suppression law — reading the raw row here would + // make a just-saved document appear to revert). + const jsonField = jsonAt ? fieldByKey.get(jsonAt.fieldKey) : undefined; + const jsonRow = jsonAt ? rawRows.find((r) => r.pid === jsonAt.pid) : undefined; + const jsonValue = String( + (jsonAt && overlayEdits[jsonAt.pid]?.[jsonAt.fieldKey]) + ?? (jsonField && jsonRow ? jsonRow[jsonField.key] : "") + ?? "" + ); + const linkField = linkAt ? fieldByKey.get(linkAt.fieldKey) : undefined; + const linkRow = linkAt ? rawRows.find((row) => row.pid === linkAt.pid) : undefined; + const linkValue = String( + (linkAt && overlayEdits[linkAt.pid]?.[linkAt.fieldKey]) + ?? (linkField && linkRow ? linkRow[linkField.key] : "") + ?? "" + ); + const linkedRecordIds = [...new Set( + linkValue.split(",").map((part) => Number(part.trim())).filter( + (pid) => Number.isInteger(pid) && pid > 0 + ) + )]; + const pickerField = picker ? fieldByKey.get(picker.fieldKey) : undefined; + // A `user` field's choices come from the HOST's real user list, a `select`'s from its own + // definition — so an assignee is always someone who can log in, and a status is always one of + // the choices the column was created with. + const pickerChoices = !pickerField + ? [] + : pickerField.type === "user" + ? payload?.userOptions ?? [] + : choiceOptions(pickerField); + const pickerRow = picker ? rawRows.find((r) => r.pid === picker.pid) : undefined; + const pickerValue = String( + (picker && overlayEdits[picker.pid]?.[picker.fieldKey]) + ?? (pickerField && pickerRow ? pickerRow[pickerField.key] : "") + ?? "" + ); + // A multiselect cell is a SET: choices TOGGLE and the picker stays open for the next pick + // (item 5). Single select/user keep pick-and-close. + const pickerMulti = pickerField?.type === "multiselect"; + const pickerParts = pickerMulti ? splitMulti(pickerValue) : []; + /** Item 6 — which choice receives focus when the picker opens: the current value where it + * still exists in the list, else the first choice. Keyboard-only editing starts HERE. */ + const pickerFocusChoice = !pickerChoices.length + ? null + : pickerMulti + ? pickerParts.find((p) => pickerChoices.includes(p)) ?? pickerChoices[0] + : pickerChoices.includes(pickerValue) + ? pickerValue + : pickerChoices[0]; + + return ( + // `data-today` is the TENANT'S day, exactly as the payload delivered it. It is here so the + // date every relative condition resolves against is OBSERVABLE rather than inferred: a live + // QA that reads its own clock instead compares two engines on "now" and fails whenever a run + // straddles midnight — which is the flake harness/windows.py takes `today` as a parameter to + // avoid, reintroduced one layer up. It cost a false failure (57 vs 55, both correct, one + // computed either side of a date change) to notice. + // `data-measure-rules` / `data-measure-sets` are BUG-1's handshake, published the same way + // and for the same reason as `data-today`: a measure condition stuck on "Calculating…" + // renders identically whether the rule carries no id, the host resolved nothing, or the two + // sides name the rule differently — and only the first of those is ours. A console.log + // cannot serve this: the grid runs in an iframe that is CROSS-ORIGIN on the Space, and every + // Streamlit rerun replaces it. An attribute survives the remount and is one `get_attribute` + // away from any harness, local or deployed. +
+ {/* ⭐ wave17 R1 / C-LOCKV — the COHORT SIDEBAR is gone, and with it the last surface that + treated a cohort as its own kind of object. `CohortSidebar` was the retired `#/cohort` + page's left panel: a second rail, with its own rename, delete, export and folder + machinery, over `workspace.lists`. Under R1 a cohort IS a saved view, so the Views rail + below renders every one of them and the second rail has nothing to switch between. + ⚠ `lists` is NOT gone with it — it stays the membership channel that feeds `cohortSets` + (C-LOCKV point 2), which is what the lock resolves against. */} + {/* ⛔ THE QUERY PROVENANCE BLOCK WAS HERE AND MOVED TO `QueryPage` (owner item 3, 2026-08-15). + It was rendered as the FIRST CHILD of `.cg-shell`, which is a horizontal flex row + (index.css:233) holding the views rail and `.cg-main` — and it had no stylesheet rule of + its own anywhere in the tree. So R9's citations painted as an unstyled column of raw + `JSON.stringify` beside the grid, shoving the table sideways: the provenance requirement + met, and the surface it was on made unreadable. Chrome belongs to the page that owns the + artefact; the grid draws the artefact. `queryCitationLabel` still formats it, from + `QueryPage`. */} + {!hideViews && ( +
+ ); +} + +export default memo(CustomerGrid);