fsanyoto commited on
Commit
7f0579e
Β·
verified Β·
1 Parent(s): 8003122

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,6 +1,12 @@
1
  {
2
- "current": "v44 (a7877aa)",
3
  "releases": [
 
 
 
 
 
 
4
  {
5
  "version": "v44",
6
  "sha": "a7877aa",
 
1
  {
2
+ "current": "v45 (0b2b501)",
3
  "releases": [
4
+ {
5
+ "version": "v45",
6
+ "sha": "0b2b501",
7
+ "date": "2026-08-23",
8
+ "subject": "release v45"
9
+ },
10
  {
11
  "version": "v44",
12
  "sha": "a7877aa",
VERSION CHANGED
@@ -1 +1 @@
1
- v44 (a7877aa)
 
1
+ v45 (0b2b501)
web/src/connectors/brandMarks.tsx CHANGED
@@ -198,6 +198,11 @@ export function BrandMark({
198
  // ⚠ Hooks run for EVERY name, including the two house glyphs, because a hook
199
  // cannot sit behind a conditional return β€” same reason `useId` is unconditional.
200
  const [failed, setFailed] = useState(false);
 
 
 
 
 
201
  if (OFFICIAL.has(name) && !failed)
202
  return (
203
  <img
@@ -212,9 +217,6 @@ export function BrandMark({
212
  onError={() => setFailed(true)}
213
  />
214
  );
215
- // Per-instance, for the reason spelled out at the `<defs>` below. Hooks run for every
216
- // name, not only Instagram, because a hook cannot sit behind a conditional return.
217
- const gradId = `aios-brand-ig-${useId()}`;
218
  if (name === "instagram")
219
  return (
220
  <svg {...f} fill="none">
 
198
  // ⚠ Hooks run for EVERY name, including the two house glyphs, because a hook
199
  // cannot sit behind a conditional return β€” same reason `useId` is unconditional.
200
  const [failed, setFailed] = useState(false);
201
+ // Per-instance, for the reason spelled out at the `<defs>` below. It must also run BEFORE
202
+ // the official-image return: `failed` flips on `onError`, and a hook first reached on that
203
+ // re-render is one more hook than the previous render β€” React #310 on the exact fallback
204
+ // path this component exists to provide.
205
+ const gradId = `aios-brand-ig-${useId()}`;
206
  if (OFFICIAL.has(name) && !failed)
207
  return (
208
  <img
 
217
  onError={() => setFailed(true)}
218
  />
219
  );
 
 
 
220
  if (name === "instagram")
221
  return (
222
  <svg {...f} fill="none">
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -8,12 +8,12 @@ import {
8
  useState,
9
  } from "react";
10
  import { DataEditor, GridCellKind } from "@glideapps/glide-data-grid";
11
- import type {
12
- CellClickedEventArgs,
13
- DataEditorRef,
14
- EditableGridCell,
15
- GridCell,
16
- GridColumn,
17
  GridKeyEventArgs,
18
  GridMouseEventArgs,
19
  GridSelection,
@@ -124,7 +124,7 @@ import type { GridFolder, ViewPermissions } from "./types";
124
  import type { FieldStamps } from "./optimism";
125
  import { evalFormula, orderFormulas, parseFormula } from "./formulaEngine";
126
  import type { FormulaAst } from "./formulaEngine";
127
- import { CalendarView, KanbanView, ListView, MapSettings, ModeSwitch } from "./viewModes";
128
  import { SwipeView } from "./SwipeView";
129
  import type { SwipeSpec } from "./SwipeView";
130
  // The chart engine lives in `viz/` since EXIT wave 2 (W2-5/Y3) β€” the grid is now
@@ -132,8 +132,8 @@ import type { SwipeSpec } from "./SwipeView";
132
  import { DashboardView } from "../viz/DashboardView";
133
  import { cleanCharts } from "../viz/chartData";
134
  import type { ChartSpec } from "../viz/chartData";
135
- import { MapView } from "./MapView";
136
- import { googleDirectionsUrl, isPlottable } from "./mapProjection";
137
  /* ═══ W18-C CATALOG ═══ (owner item 4, contract C6) */
138
  import { CatalogView } from "./CatalogView";
139
  import { CATALOG_CODE_FIELD } from "./catalogData";
@@ -213,25 +213,25 @@ import { windowLabel } from "./windows";
213
  */
214
  export const GRID_CHAT_OPEN_EVENT = "aios:grid-chat-open";
215
 
216
- const ROW_PX: Record<RowHeightMode, number> = { short: 28, medium: 34, tall: 48 };
217
-
218
- /**
219
- * W39-T22 β€” a transport action, not a stored field. Coordinates already travel on every
220
- * customer row for MapView, but deliberately are not columns. Keeping this synthetic column out
221
- * of the workspace means a person cannot hide, reorder, rename, or accidentally persist it as
222
- * though it were customer data.
223
- */
224
- const DIRECTIONS_COLUMN_ID = "__directions__";
225
- const DIRECTIONS_COLUMN: GridColumn = { id: DIRECTIONS_COLUMN_ID, title: "Go", width: 46 };
226
-
227
- /** `null` means there is no honest Google destination, including a blank-address row. */
228
- function directionsFor(row: Row): { lat: number; lon: number } | null {
229
- const lat = row.lat;
230
- const lon = row.lon;
231
- return typeof lat === "number" && typeof lon === "number" && isPlottable(lat, lon)
232
- ? { lat, lon }
233
- : null;
234
- }
235
 
236
  /**
237
  * Wave-14 R7 β€” **a button label is ONE LINE.** Every button row in this file is a flex row with
@@ -1646,8 +1646,8 @@ function CustomerGridSurface({
1646
  [config.groupBy, isQueryPreview, queryBinding]
1647
  );
1648
 
1649
- const {
1650
- visibleCols,
1651
  fieldByKey,
1652
  order,
1653
  visible,
@@ -1656,19 +1656,19 @@ function CustomerGridSurface({
1656
  onColumnMoved,
1657
  onColumnProposeMove,
1658
  setColumnVisible,
1659
- insertColumn,
1660
- } = useGridColumns(fields, config, updateConfig);
1661
- const revealRouteField = useCallback((key: string) => {
1662
- setColumnVisible(key, true);
1663
- signal(WORKSPACE_STALE_EVENT);
1664
- signal(ROWS_STALE_EVENT);
1665
- }, [setColumnVisible]);
1666
- // W39-T22 β€” the transit link is an action, so it is appended to what Glide paints without
1667
- // joining the saved-view column contract. `onCellClicked` below is the one tap door.
1668
- const gridColumns = useMemo(
1669
- () => scope === "customer" ? [...visibleCols, DIRECTIONS_COLUMN] : visibleCols,
1670
- [scope, visibleCols]
1671
- );
1672
 
1673
  /**
1674
  * ⭐ Wave-20 owner item 4 (ruling R8, contract C-ADDROW) β€” **THE GHOST ROW.**
@@ -1938,12 +1938,12 @@ function CustomerGridSurface({
1938
  // Wave-6 item 10 β€” how this view displays. A WINDOWED table is always the grid: list/
1939
  // calendar/kanban compute over the whole matched set, and one page is not it (CG-3's rule,
1940
  // the same reason grouping is off there).
1941
- const displaySpec = cleanDisplay(config.display);
1942
- const displayMode: DisplayMode = serverWindowed ? "grid" : displaySpec?.mode ?? "grid";
1943
- const [routePanelOpen, setRoutePanelOpen] = useState(false);
1944
- useEffect(() => {
1945
- if (displayMode !== "map") setRoutePanelOpen(false);
1946
- }, [displayMode]);
1947
 
1948
  // ═══════════════════════════════════════════════════════════════════════════════════════
1949
  // ⭐⭐ WAVE 30 Β· W30-T42 (contract C2) β€” PAGING A READ-THROUGH GRID.
@@ -2689,8 +2689,8 @@ function CustomerGridSurface({
2689
  return () => window.clearInterval(id);
2690
  }, [pendingMeasureKeys]);
2691
  const activePendingKeys = pendingGaveUp ? NO_PENDING_KEYS : pendingMeasureKeys;
2692
- const baseGetCellContent = useGetCellContent(
2693
- displayRows,
2694
  visibleCols,
2695
  fieldByKey,
2696
  overlayEdits,
@@ -2700,44 +2700,44 @@ function CustomerGridSurface({
2700
  groupLabelSpace,
2701
  measureGroupText,
2702
  activePendingKeys,
2703
- pulse
2704
- );
2705
- const getCellContent = useCallback(
2706
- (cell: Item): GridCell => {
2707
- if (scope !== "customer" || cell[0] !== visibleCols.length) return baseGetCellContent(cell);
2708
- const row = displayRows[cell[1]];
2709
- const at = row?.kind === "data" ? directionsFor(row.record) : null;
2710
- // A blank cell is deliberately not a disabled-looking `Go`: there is no destination to
2711
- // offer, and a row whose address is blank must not acquire a plausible link to nowhere.
2712
- if (!at) {
2713
- return {
2714
- kind: GridCellKind.Text,
2715
- data: "",
2716
- displayData: "",
2717
- allowOverlay: false,
2718
- readonly: true,
2719
- };
2720
- }
2721
- return {
2722
- kind: GridCellKind.Uri,
2723
- data: googleDirectionsUrl(at.lat, at.lon),
2724
- displayData: "Go",
2725
- allowOverlay: false,
2726
- readonly: true,
2727
- hoverEffect: true,
2728
- };
2729
- },
2730
- [baseGetCellContent, displayRows, scope, visibleCols.length]
2731
- );
2732
  const { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid,
2733
  setActiveCell, clearSelection } =
2734
- useGridSelection(
2735
- displayRows,
2736
- displayPidToIndex,
2737
- gridColumns.length,
2738
- embeddedSelectable ? embeddedSelectedIds : [],
2739
- !embedded ? `aios:grid-selection:${scope}` : undefined
2740
- );
2741
  const embeddedSelectedKey = embeddedSelectedIds.join(",");
2742
  const selectedPidKey = [...selectedPids].join(",");
2743
  useEffect(() => {
@@ -3775,7 +3775,7 @@ function CustomerGridSurface({
3775
  // Owner item 10: clicking into the cells is "I am working now" β€” the frame folds its
3776
  // navigation rail to the slim strip (a no-op in the embed; the shell listens).
3777
  signal(NAV_MINIMIZE_EVENT);
3778
- if (row.kind === "group-header") {
3779
  event.preventDefault();
3780
  setCollapsed((current) => {
3781
  const next = new Set(current);
@@ -3783,19 +3783,19 @@ function CustomerGridSurface({
3783
  else next.add(row.groupKey);
3784
  return next;
3785
  });
3786
- return;
3787
- }
3788
- // W39-T22 β€” Glide paints links on its canvas, so this handler is the ACTUAL touch door.
3789
- // The Go cell is virtual and always trails the persisted columns; no address-derived string
3790
- // is used here because coordinates, not an address search, are what the map plotted.
3791
- if (scope === "customer" && row.kind === "data" && cell[0] === visibleCols.length) {
3792
- const at = directionsFor(row.record);
3793
- if (!at) return;
3794
- event.preventDefault();
3795
- window.open(googleDirectionsUrl(at.lat, at.lon), "_blank", "noopener,noreferrer");
3796
- return;
3797
- }
3798
- const column = visibleCols[cell[0]];
3799
  const field = column ? fieldByKey.get(column.id!) : undefined;
3800
  // A picked field (select / assignee) opens its choices where the cell is. It cannot use
3801
  // glide's text overlay β€” a free-text editor on a constrained column is how you end up
@@ -3859,7 +3859,7 @@ function CustomerGridSurface({
3859
  : null;
3860
  if (href) {
3861
  event.preventDefault();
3862
- window.open(href, "_blank", "noopener,noreferrer");
3863
  return;
3864
  }
3865
  }
@@ -3883,8 +3883,8 @@ function CustomerGridSurface({
3883
  // open a record; the hover Expand button below is its replacement, and half of this
3884
  // change is a table whose records cannot be opened at all.
3885
  },
3886
- [displayRows, visibleCols, scope, fieldByKey, canEditField, overlayEdits, lockedKey, togglePid,
3887
- setActiveCell]
3888
  );
3889
  /**
3890
  * Double-click / Enter. KEPT as a door to the record on purpose (owner item 17 names the
@@ -3986,7 +3986,7 @@ function CustomerGridSurface({
3986
  gridSelection.current]
3987
  );
3988
 
3989
- const openHeaderMenu = useCallback(
3990
  (column: number, bounds: Rectangle) => {
3991
  if (embedded) return;
3992
  const definition = visibleCols[column];
@@ -4003,27 +4003,27 @@ function CustomerGridSurface({
4003
  },
4004
  });
4005
  },
4006
- [embedded, visibleCols]
4007
- );
4008
- const onGridColumnResize = useCallback(
4009
- (column: GridColumn, newSize: number) => {
4010
- if (column.id === DIRECTIONS_COLUMN_ID) return;
4011
- onColumnResize(column, newSize);
4012
- },
4013
- [onColumnResize]
4014
- );
4015
- const onGridColumnMoved = useCallback(
4016
- (from: number, to: number) => {
4017
- if (from >= visibleCols.length || to >= visibleCols.length) return;
4018
- onColumnMoved(from, to);
4019
- },
4020
- [onColumnMoved, visibleCols.length]
4021
- );
4022
- const onGridColumnProposeMove = useCallback(
4023
- (from: number, to: number) =>
4024
- from < visibleCols.length && to < visibleCols.length && onColumnProposeMove(from, to),
4025
- [onColumnProposeMove, visibleCols.length]
4026
- );
4027
  const onHeaderClicked = useCallback(
4028
  (column: number, event: HeaderClickedEventArgs) => {
4029
  if (event.isEdge) return;
@@ -4377,19 +4377,27 @@ function CustomerGridSurface({
4377
  // ⚠ `railViews` and `openScriptView` join for W37-T41's mint-on-pick branch above.
4378
  [fields, persistView, views, queryBinding, scope, railViews, openScriptView]
4379
  );
4380
- const renameView = useCallback(
4381
- (id: string, name: string) => {
4382
- const view = views.find((item) => item.id === id);
4383
- if (view && mayEditView(view, viewer)) {
4384
- const acceptedName = uniqueDisplayName(
4385
- name,
4386
- views.filter((item) => item.id !== id).map((item) => item.name)
4387
- );
4388
- if (acceptedName !== view.name) persistView({ ...view, name: acceptedName });
4389
- }
4390
- },
4391
- [views, persistView, viewer]
4392
- );
 
 
 
 
 
 
 
 
4393
  /**
4394
  * I12 (contract C3) β€” freeze/unfreeze a view's DISPLAY MODE.
4395
  *
@@ -6366,19 +6374,15 @@ function CustomerGridSurface({
6366
  : false;
6367
  // Item 11c β€” is the menu's field the current frozen boundary (its Pin would be a no-op)?
6368
  const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
6369
- const menuPinnedTo = menuVisIndex >= 0 && frozenN > 1 && menuVisIndex + 1 === frozenN;
6370
- const renameActiveView = useCallback(
6371
- (name: string) => renameView(activeViewId, name),
6372
- [activeViewId, renameView]
6373
- );
6374
- // Item 10 β€” the toolbar's mode switcher (grid-only on windowed tables, see displayMode).
6375
  const modeControl = serverWindowed || embedded || isQueryPreview ? undefined : (
6376
  <ModeSwitch
6377
- mode={displayMode}
6378
- onMode={setDisplayMode}
6379
- viewName={activeView?.name}
6380
- canRenameView={!!activeView && mayEditView(activeView, viewer)}
6381
- onRenameView={renameActiveView}
6382
  // I12 (C3) β€” a locked view's mode is frozen, so the switcher stops being a switcher.
6383
  // The field pickers beside it stay: C3 freezes the MODE, and a kanban's stack field is
6384
  // config, not mode ("filters/sorts/columns stay editable").
@@ -6406,9 +6410,9 @@ function CustomerGridSurface({
6406
  calendarMetrics={displaySpec?.calendarMetrics}
6407
  // The numeric family, exactly what C-DISP makes eligible β€” and the same memo the map's
6408
  // bubble-size picker feeds from, so "what can be totalled" has one definition.
6409
- metricChoices={sizeFieldChoices}
6410
- onCalendarMetrics={setCalendarMetrics}
6411
- />
6412
  );
6413
  // ⭐ Wave-23 C7 β€” the open document, resolved exactly like the picker's value below it:
6414
  // the OPTIMISTIC edit wins over the raw record, so a save the server has not echoed yet is
@@ -6748,11 +6752,11 @@ function CustomerGridSurface({
6748
  }}
6749
  folderAddPreview={serverWindowed ? undefined : folderAddPreview}
6750
  onFolderAddToList={serverWindowed ? undefined : folderAddToList}
6751
- viewer={viewer}
6752
- onToggleLock={toggleViewLock}
6753
- onToggleImportant={toggleViewImportant}
6754
- />
6755
- )}
6756
  {/* ⭐⭐ W37-T29 / CONTRACT C6 (rulings R2, R10) β€” E's CHAT, DOCKED AND OPENABLE.
6757
  β›” THE LAUNCHER SHIPS IN THE SAME CHANGE AS THE MOUNT, and that is not tidiness: a panel
6758
  whose only entry point is a prop nobody flips is mounted-but-never-offered, which is the
@@ -6867,22 +6871,22 @@ function CustomerGridSurface({
6867
  pool={payload?.workspace?.pool}
6868
  serverWindowed={serverWindowed}
6869
  cohortAction={cohortAction}
6870
- filterSeed={filterSeed}
6871
- modeControl={modeControl}
6872
- />
6873
- {displayMode === "map" && (
6874
- <MapSettings
6875
- coordField={mapCoordField}
6876
- coordChoices={coordFieldChoices}
6877
- onPickCoordField={onPickCoordField}
6878
- sizeField={mapSizeField}
6879
- sizeChoices={sizeFieldChoices}
6880
- onPickSizeField={(key) => setDisplayField("sizeField", key)}
6881
- routeOpen={routePanelOpen}
6882
- selectedCount={selectedPids.size}
6883
- onToggleRoute={() => setRoutePanelOpen((open) => !open)}
6884
- />
6885
- )}
6886
  {/* Wave-10 item 9: the view's description used to render HERE, as a full-width band
6887
  between the toolbar and the grid β€” it cost every row a strip of vertical space and
6888
  read as an alert rather than as a label. It now sits under the view's name in the
@@ -6909,7 +6913,7 @@ function CustomerGridSurface({
6909
  <>
6910
  <DataEditor
6911
  ref={gridRef}
6912
- columns={gridColumns}
6913
  getCellContent={getCellContent}
6914
  rows={displayRows.length}
6915
  theme={lightTheme}
@@ -6970,9 +6974,9 @@ function CustomerGridSurface({
6970
  // `false` from here takes the write over from glide's per-cell path.
6971
  onDelete={onGridDelete}
6972
  validateCell={validateCell}
6973
- onColumnResize={onGridColumnResize}
6974
- onColumnMoved={onGridColumnMoved}
6975
- onColumnProposeMove={onGridColumnProposeMove}
6976
  drawHeader={drawGridHeader}
6977
  onHeaderMenuClick={(column, bounds) => openHeaderMenu(column, bounds)}
6978
  onHeaderClicked={onHeaderClicked}
@@ -7380,17 +7384,17 @@ function CustomerGridSurface({
7380
  The write goes through `patchAndRecord`, the SAME door a typed edit takes, so the
7381
  undo stack and the permission check are the ones that already exist rather than a
7382
  second writer sitting outside every guard. */
7383
- onGeocoded={(writes) => {
7384
  for (const w of writes)
7385
  if (mapCoordField && canEditField(mapCoordField))
7386
  patchAndRecord(w.pid, { [mapCoordField.key]: w.value }, "a lookup");
7387
- }}
7388
- onRouteFieldCreated={revealRouteField}
7389
- routePanelOpen={routePanelOpen}
7390
- selectedPids={selectedPids}
7391
- onSelectPids={selectPids}
7392
- onOpen={setDetailPid}
7393
- />
7394
  )}
7395
  {/* Owner item 19 β€” the hover-only Expand. The COUNTERPART to item 17: the single
7396
  click that used to open a record now highlights it, and this is where opening
@@ -7819,7 +7823,7 @@ function CustomerGridSurface({
7819
  : undefined
7820
  }
7821
  onDelete={
7822
- menuField.custom && !menuField.shared && menuField.key !== lockedKey
7823
  ? () => {
7824
  deleteField(menuField.key);
7825
  setColumnMenu(null);
@@ -7852,7 +7856,7 @@ function CustomerGridSurface({
7852
  }
7853
  onDuplicate={
7854
  // Creatable strata only β€” a base Odoo field offers no Duplicate (contract).
7855
- menuField.custom && !menuField.shared ? () => duplicateField(menuField) : undefined
7856
  }
7857
  onFormat={(format) => saveField({ ...menuField, format })}
7858
  /**
@@ -7905,21 +7909,21 @@ function CustomerGridSurface({
7905
  }
7906
  onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
7907
  onClearGroup={() => updateConfig({ ...config, groupBy: null })}
7908
- userOptions={payload?.userOptions ?? []}
7909
  measures={measures}
7910
- /* ⭐⭐ W39-T17 β€” field shares use the registry's canonical topic key, not the
7911
- per-user browser storage key carried by `workspace.storageKey`. These are the same
7912
- server-issued names used by the field-grant wall and the shared overlay write path.
7913
- Query previews have no durable field share target, so the action is omitted. */
7914
- tableKey={
7915
- isQueryPreview
7916
- ? undefined
7917
- : scope === "product"
7918
- ? "product_data"
7919
- : scope?.startsWith("ut_")
7920
- ? scope
7921
- : "customer_data"
7922
- }
7923
  />
7924
  )}
7925
 
 
8
  useState,
9
  } from "react";
10
  import { DataEditor, GridCellKind } from "@glideapps/glide-data-grid";
11
+ import type {
12
+ CellClickedEventArgs,
13
+ DataEditorRef,
14
+ EditableGridCell,
15
+ GridCell,
16
+ GridColumn,
17
  GridKeyEventArgs,
18
  GridMouseEventArgs,
19
  GridSelection,
 
124
  import type { FieldStamps } from "./optimism";
125
  import { evalFormula, orderFormulas, parseFormula } from "./formulaEngine";
126
  import type { FormulaAst } from "./formulaEngine";
127
+ import { CalendarView, KanbanView, ListView, MapSettings, ModeSwitch } from "./viewModes";
128
  import { SwipeView } from "./SwipeView";
129
  import type { SwipeSpec } from "./SwipeView";
130
  // The chart engine lives in `viz/` since EXIT wave 2 (W2-5/Y3) β€” the grid is now
 
132
  import { DashboardView } from "../viz/DashboardView";
133
  import { cleanCharts } from "../viz/chartData";
134
  import type { ChartSpec } from "../viz/chartData";
135
+ import { MapView } from "./MapView";
136
+ import { googleDirectionsUrl, isPlottable } from "./mapProjection";
137
  /* ═══ W18-C CATALOG ═══ (owner item 4, contract C6) */
138
  import { CatalogView } from "./CatalogView";
139
  import { CATALOG_CODE_FIELD } from "./catalogData";
 
213
  */
214
  export const GRID_CHAT_OPEN_EVENT = "aios:grid-chat-open";
215
 
216
+ const ROW_PX: Record<RowHeightMode, number> = { short: 28, medium: 34, tall: 48 };
217
+
218
+ /**
219
+ * W39-T22 β€” a transport action, not a stored field. Coordinates already travel on every
220
+ * customer row for MapView, but deliberately are not columns. Keeping this synthetic column out
221
+ * of the workspace means a person cannot hide, reorder, rename, or accidentally persist it as
222
+ * though it were customer data.
223
+ */
224
+ const DIRECTIONS_COLUMN_ID = "__directions__";
225
+ const DIRECTIONS_COLUMN: GridColumn = { id: DIRECTIONS_COLUMN_ID, title: "Go", width: 46 };
226
+
227
+ /** `null` means there is no honest Google destination, including a blank-address row. */
228
+ function directionsFor(row: Row): { lat: number; lon: number } | null {
229
+ const lat = row.lat;
230
+ const lon = row.lon;
231
+ return typeof lat === "number" && typeof lon === "number" && isPlottable(lat, lon)
232
+ ? { lat, lon }
233
+ : null;
234
+ }
235
 
236
  /**
237
  * Wave-14 R7 β€” **a button label is ONE LINE.** Every button row in this file is a flex row with
 
1646
  [config.groupBy, isQueryPreview, queryBinding]
1647
  );
1648
 
1649
+ const {
1650
+ visibleCols,
1651
  fieldByKey,
1652
  order,
1653
  visible,
 
1656
  onColumnMoved,
1657
  onColumnProposeMove,
1658
  setColumnVisible,
1659
+ insertColumn,
1660
+ } = useGridColumns(fields, config, updateConfig);
1661
+ const revealRouteField = useCallback((key: string) => {
1662
+ setColumnVisible(key, true);
1663
+ signal(WORKSPACE_STALE_EVENT);
1664
+ signal(ROWS_STALE_EVENT);
1665
+ }, [setColumnVisible]);
1666
+ // W39-T22 β€” the transit link is an action, so it is appended to what Glide paints without
1667
+ // joining the saved-view column contract. `onCellClicked` below is the one tap door.
1668
+ const gridColumns = useMemo(
1669
+ () => scope === "customer" ? [...visibleCols, DIRECTIONS_COLUMN] : visibleCols,
1670
+ [scope, visibleCols]
1671
+ );
1672
 
1673
  /**
1674
  * ⭐ Wave-20 owner item 4 (ruling R8, contract C-ADDROW) β€” **THE GHOST ROW.**
 
1938
  // Wave-6 item 10 β€” how this view displays. A WINDOWED table is always the grid: list/
1939
  // calendar/kanban compute over the whole matched set, and one page is not it (CG-3's rule,
1940
  // the same reason grouping is off there).
1941
+ const displaySpec = cleanDisplay(config.display);
1942
+ const displayMode: DisplayMode = serverWindowed ? "grid" : displaySpec?.mode ?? "grid";
1943
+ const [routePanelOpen, setRoutePanelOpen] = useState(false);
1944
+ useEffect(() => {
1945
+ if (displayMode !== "map") setRoutePanelOpen(false);
1946
+ }, [displayMode]);
1947
 
1948
  // ═══════════════════════════════════════════════════════════════════════════════════════
1949
  // ⭐⭐ WAVE 30 Β· W30-T42 (contract C2) β€” PAGING A READ-THROUGH GRID.
 
2689
  return () => window.clearInterval(id);
2690
  }, [pendingMeasureKeys]);
2691
  const activePendingKeys = pendingGaveUp ? NO_PENDING_KEYS : pendingMeasureKeys;
2692
+ const baseGetCellContent = useGetCellContent(
2693
+ displayRows,
2694
  visibleCols,
2695
  fieldByKey,
2696
  overlayEdits,
 
2700
  groupLabelSpace,
2701
  measureGroupText,
2702
  activePendingKeys,
2703
+ pulse
2704
+ );
2705
+ const getCellContent = useCallback(
2706
+ (cell: Item): GridCell => {
2707
+ if (scope !== "customer" || cell[0] !== visibleCols.length) return baseGetCellContent(cell);
2708
+ const row = displayRows[cell[1]];
2709
+ const at = row?.kind === "data" ? directionsFor(row.record) : null;
2710
+ // A blank cell is deliberately not a disabled-looking `Go`: there is no destination to
2711
+ // offer, and a row whose address is blank must not acquire a plausible link to nowhere.
2712
+ if (!at) {
2713
+ return {
2714
+ kind: GridCellKind.Text,
2715
+ data: "",
2716
+ displayData: "",
2717
+ allowOverlay: false,
2718
+ readonly: true,
2719
+ };
2720
+ }
2721
+ return {
2722
+ kind: GridCellKind.Uri,
2723
+ data: googleDirectionsUrl(at.lat, at.lon),
2724
+ displayData: "Go",
2725
+ allowOverlay: false,
2726
+ readonly: true,
2727
+ hoverEffect: true,
2728
+ };
2729
+ },
2730
+ [baseGetCellContent, displayRows, scope, visibleCols.length]
2731
+ );
2732
  const { gridSelection, selectedPids, onGridSelectionChange, selectPids, togglePid,
2733
  setActiveCell, clearSelection } =
2734
+ useGridSelection(
2735
+ displayRows,
2736
+ displayPidToIndex,
2737
+ gridColumns.length,
2738
+ embeddedSelectable ? embeddedSelectedIds : [],
2739
+ !embedded ? `aios:grid-selection:${scope}` : undefined
2740
+ );
2741
  const embeddedSelectedKey = embeddedSelectedIds.join(",");
2742
  const selectedPidKey = [...selectedPids].join(",");
2743
  useEffect(() => {
 
3775
  // Owner item 10: clicking into the cells is "I am working now" β€” the frame folds its
3776
  // navigation rail to the slim strip (a no-op in the embed; the shell listens).
3777
  signal(NAV_MINIMIZE_EVENT);
3778
+ if (row.kind === "group-header") {
3779
  event.preventDefault();
3780
  setCollapsed((current) => {
3781
  const next = new Set(current);
 
3783
  else next.add(row.groupKey);
3784
  return next;
3785
  });
3786
+ return;
3787
+ }
3788
+ // W39-T22 β€” Glide paints links on its canvas, so this handler is the ACTUAL touch door.
3789
+ // The Go cell is virtual and always trails the persisted columns; no address-derived string
3790
+ // is used here because coordinates, not an address search, are what the map plotted.
3791
+ if (scope === "customer" && row.kind === "data" && cell[0] === visibleCols.length) {
3792
+ const at = directionsFor(row.record);
3793
+ if (!at) return;
3794
+ event.preventDefault();
3795
+ window.open(googleDirectionsUrl(at.lat, at.lon), "_blank", "noopener,noreferrer");
3796
+ return;
3797
+ }
3798
+ const column = visibleCols[cell[0]];
3799
  const field = column ? fieldByKey.get(column.id!) : undefined;
3800
  // A picked field (select / assignee) opens its choices where the cell is. It cannot use
3801
  // glide's text overlay β€” a free-text editor on a constrained column is how you end up
 
3859
  : null;
3860
  if (href) {
3861
  event.preventDefault();
3862
+ window.open(href, "_blank", "noopener,noreferrer");
3863
  return;
3864
  }
3865
  }
 
3883
  // open a record; the hover Expand button below is its replacement, and half of this
3884
  // change is a table whose records cannot be opened at all.
3885
  },
3886
+ [displayRows, visibleCols, scope, fieldByKey, canEditField, overlayEdits, lockedKey, togglePid,
3887
+ setActiveCell]
3888
  );
3889
  /**
3890
  * Double-click / Enter. KEPT as a door to the record on purpose (owner item 17 names the
 
3986
  gridSelection.current]
3987
  );
3988
 
3989
+ const openHeaderMenu = useCallback(
3990
  (column: number, bounds: Rectangle) => {
3991
  if (embedded) return;
3992
  const definition = visibleCols[column];
 
4003
  },
4004
  });
4005
  },
4006
+ [embedded, visibleCols]
4007
+ );
4008
+ const onGridColumnResize = useCallback(
4009
+ (column: GridColumn, newSize: number) => {
4010
+ if (column.id === DIRECTIONS_COLUMN_ID) return;
4011
+ onColumnResize(column, newSize);
4012
+ },
4013
+ [onColumnResize]
4014
+ );
4015
+ const onGridColumnMoved = useCallback(
4016
+ (from: number, to: number) => {
4017
+ if (from >= visibleCols.length || to >= visibleCols.length) return;
4018
+ onColumnMoved(from, to);
4019
+ },
4020
+ [onColumnMoved, visibleCols.length]
4021
+ );
4022
+ const onGridColumnProposeMove = useCallback(
4023
+ (from: number, to: number) =>
4024
+ from < visibleCols.length && to < visibleCols.length && onColumnProposeMove(from, to),
4025
+ [onColumnProposeMove, visibleCols.length]
4026
+ );
4027
  const onHeaderClicked = useCallback(
4028
  (column: number, event: HeaderClickedEventArgs) => {
4029
  if (event.isEdge) return;
 
4377
  // ⚠ `railViews` and `openScriptView` join for W37-T41's mint-on-pick branch above.
4378
  [fields, persistView, views, queryBinding, scope, railViews, openScriptView]
4379
  );
4380
+ const renameView = useCallback(
4381
+ (id: string, name: string) => {
4382
+ const view = views.find((item) => item.id === id);
4383
+ if (view && mayEditView(view, viewer)) {
4384
+ const acceptedName = uniqueDisplayName(
4385
+ name,
4386
+ views.filter((item) => item.id !== id).map((item) => item.name)
4387
+ );
4388
+ if (acceptedName !== view.name) persistView({ ...view, name: acceptedName });
4389
+ }
4390
+ },
4391
+ [views, persistView, viewer]
4392
+ );
4393
+ // β›” This hook must stay ABOVE the `loading` early-return far below: a hook after that
4394
+ // return first runs only once loading flips off, so the same mounted instance renders one
4395
+ // MORE hook than its loading render and React #310 takes down the whole surface. That is
4396
+ // exactly how v44 crashed Odoo customers for every mount that began on the spinner.
4397
+ const renameActiveView = useCallback(
4398
+ (name: string) => renameView(activeViewId, name),
4399
+ [activeViewId, renameView]
4400
+ );
4401
  /**
4402
  * I12 (contract C3) β€” freeze/unfreeze a view's DISPLAY MODE.
4403
  *
 
6374
  : false;
6375
  // Item 11c β€” is the menu's field the current frozen boundary (its Pin would be a no-op)?
6376
  const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
6377
+ const menuPinnedTo = menuVisIndex >= 0 && frozenN > 1 && menuVisIndex + 1 === frozenN;
6378
+ // Item 10 β€” the toolbar's mode switcher (grid-only on windowed tables, see displayMode).
 
 
 
 
6379
  const modeControl = serverWindowed || embedded || isQueryPreview ? undefined : (
6380
  <ModeSwitch
6381
+ mode={displayMode}
6382
+ onMode={setDisplayMode}
6383
+ viewName={activeView?.name}
6384
+ canRenameView={!!activeView && mayEditView(activeView, viewer)}
6385
+ onRenameView={renameActiveView}
6386
  // I12 (C3) β€” a locked view's mode is frozen, so the switcher stops being a switcher.
6387
  // The field pickers beside it stay: C3 freezes the MODE, and a kanban's stack field is
6388
  // config, not mode ("filters/sorts/columns stay editable").
 
6410
  calendarMetrics={displaySpec?.calendarMetrics}
6411
  // The numeric family, exactly what C-DISP makes eligible β€” and the same memo the map's
6412
  // bubble-size picker feeds from, so "what can be totalled" has one definition.
6413
+ metricChoices={sizeFieldChoices}
6414
+ onCalendarMetrics={setCalendarMetrics}
6415
+ />
6416
  );
6417
  // ⭐ Wave-23 C7 β€” the open document, resolved exactly like the picker's value below it:
6418
  // the OPTIMISTIC edit wins over the raw record, so a save the server has not echoed yet is
 
6752
  }}
6753
  folderAddPreview={serverWindowed ? undefined : folderAddPreview}
6754
  onFolderAddToList={serverWindowed ? undefined : folderAddToList}
6755
+ viewer={viewer}
6756
+ onToggleLock={toggleViewLock}
6757
+ onToggleImportant={toggleViewImportant}
6758
+ />
6759
+ )}
6760
  {/* ⭐⭐ W37-T29 / CONTRACT C6 (rulings R2, R10) β€” E's CHAT, DOCKED AND OPENABLE.
6761
  β›” THE LAUNCHER SHIPS IN THE SAME CHANGE AS THE MOUNT, and that is not tidiness: a panel
6762
  whose only entry point is a prop nobody flips is mounted-but-never-offered, which is the
 
6871
  pool={payload?.workspace?.pool}
6872
  serverWindowed={serverWindowed}
6873
  cohortAction={cohortAction}
6874
+ filterSeed={filterSeed}
6875
+ modeControl={modeControl}
6876
+ />
6877
+ {displayMode === "map" && (
6878
+ <MapSettings
6879
+ coordField={mapCoordField}
6880
+ coordChoices={coordFieldChoices}
6881
+ onPickCoordField={onPickCoordField}
6882
+ sizeField={mapSizeField}
6883
+ sizeChoices={sizeFieldChoices}
6884
+ onPickSizeField={(key) => setDisplayField("sizeField", key)}
6885
+ routeOpen={routePanelOpen}
6886
+ selectedCount={selectedPids.size}
6887
+ onToggleRoute={() => setRoutePanelOpen((open) => !open)}
6888
+ />
6889
+ )}
6890
  {/* Wave-10 item 9: the view's description used to render HERE, as a full-width band
6891
  between the toolbar and the grid β€” it cost every row a strip of vertical space and
6892
  read as an alert rather than as a label. It now sits under the view's name in the
 
6913
  <>
6914
  <DataEditor
6915
  ref={gridRef}
6916
+ columns={gridColumns}
6917
  getCellContent={getCellContent}
6918
  rows={displayRows.length}
6919
  theme={lightTheme}
 
6974
  // `false` from here takes the write over from glide's per-cell path.
6975
  onDelete={onGridDelete}
6976
  validateCell={validateCell}
6977
+ onColumnResize={onGridColumnResize}
6978
+ onColumnMoved={onGridColumnMoved}
6979
+ onColumnProposeMove={onGridColumnProposeMove}
6980
  drawHeader={drawGridHeader}
6981
  onHeaderMenuClick={(column, bounds) => openHeaderMenu(column, bounds)}
6982
  onHeaderClicked={onHeaderClicked}
 
7384
  The write goes through `patchAndRecord`, the SAME door a typed edit takes, so the
7385
  undo stack and the permission check are the ones that already exist rather than a
7386
  second writer sitting outside every guard. */
7387
+ onGeocoded={(writes) => {
7388
  for (const w of writes)
7389
  if (mapCoordField && canEditField(mapCoordField))
7390
  patchAndRecord(w.pid, { [mapCoordField.key]: w.value }, "a lookup");
7391
+ }}
7392
+ onRouteFieldCreated={revealRouteField}
7393
+ routePanelOpen={routePanelOpen}
7394
+ selectedPids={selectedPids}
7395
+ onSelectPids={selectPids}
7396
+ onOpen={setDetailPid}
7397
+ />
7398
  )}
7399
  {/* Owner item 19 β€” the hover-only Expand. The COUNTERPART to item 17: the single
7400
  click that used to open a record now highlights it, and this is where opening
 
7823
  : undefined
7824
  }
7825
  onDelete={
7826
+ menuField.custom && !menuField.shared && menuField.key !== lockedKey
7827
  ? () => {
7828
  deleteField(menuField.key);
7829
  setColumnMenu(null);
 
7856
  }
7857
  onDuplicate={
7858
  // Creatable strata only β€” a base Odoo field offers no Duplicate (contract).
7859
+ menuField.custom && !menuField.shared ? () => duplicateField(menuField) : undefined
7860
  }
7861
  onFormat={(format) => saveField({ ...menuField, format })}
7862
  /**
 
7909
  }
7910
  onGroupByField={() => updateConfig({ ...config, groupBy: menuField.key })}
7911
  onClearGroup={() => updateConfig({ ...config, groupBy: null })}
7912
+ userOptions={payload?.userOptions ?? []}
7913
  measures={measures}
7914
+ /* ⭐⭐ W39-T17 β€” field shares use the registry's canonical topic key, not the
7915
+ per-user browser storage key carried by `workspace.storageKey`. These are the same
7916
+ server-issued names used by the field-grant wall and the shared overlay write path.
7917
+ Query previews have no durable field share target, so the action is omitted. */
7918
+ tableKey={
7919
+ isQueryPreview
7920
+ ? undefined
7921
+ : scope === "product"
7922
+ ? "product_data"
7923
+ : scope?.startsWith("ut_")
7924
+ ? scope
7925
+ : "customer_data"
7926
+ }
7927
  />
7928
  )}
7929