Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- .gitattributes +2 -0
- api/routes_grid.py +33 -2
- platform/core/store_pg.py +5 -3
- platform/harness/pg/schema.sql +5 -5
- web/_qa_live_rail/1_before.png +3 -0
- web/_qa_live_rail/2_menu.png +0 -0
- web/_qa_live_rail/3_prompt.png +0 -0
- web/_qa_live_rail/4_after.png +0 -0
- web/_qa_live_rail/5_fields.png +0 -0
- web/_qa_live_rail/debug.png +3 -0
- web/dist-embed/index.html +0 -0
- web/src/customer-grid/CustomerGrid.tsx +0 -0
- web/src/customer-grid/OverlaySurface.tsx +66 -46
- web/src/customer-grid/ViewSidebar.tsx +30 -12
- web/src/customer-grid/liveWorkspace.ts +153 -0
- web/src/customer-grid/types.ts +11 -0
- web/src/customer-grid/useCustomerData.ts +11 -0
.gitattributes
CHANGED
|
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
|
|
|
|
|
|
|
|
| 33 |
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
web/_qa_live_rail/1_before.png filter=lfs diff=lfs merge=lfs -text
|
| 37 |
+
web/_qa_live_rail/debug.png filter=lfs diff=lfs merge=lfs -text
|
api/routes_grid.py
CHANGED
|
@@ -134,9 +134,18 @@ def workspace(scope: str = "customer",
|
|
| 134 |
raise err(503, "store_unavailable", "the tenant store is unavailable")
|
| 135 |
workspace = g["workspace"]
|
| 136 |
workspace["overlays"] = g["ws"].get("overlays") or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
workspace["measures"] = g["measures"]
|
| 138 |
workspace["measureSets"] = g["measure_sets"]
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
|
| 141 |
try:
|
| 142 |
from core import users as _users
|
|
@@ -169,11 +178,18 @@ def workspace(scope: str = "customer",
|
|
| 169 |
raise err(503, "store_unavailable", "the tenant store is unavailable")
|
| 170 |
workspace = g["workspace"]
|
| 171 |
workspace["overlays"] = g["ws"].get("overlays") or {}
|
|
|
|
|
|
|
|
|
|
| 172 |
# The measure channel is EMPTY on this topic (customer-grain descope) β stated
|
| 173 |
# explicitly so the client's pickers grey rather than guess.
|
| 174 |
workspace["measures"] = g["measures"]
|
| 175 |
workspace["measureSets"] = g["measure_sets"]
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
|
| 178 |
try:
|
| 179 |
from core import users as _users
|
|
@@ -217,6 +233,21 @@ def workspace(scope: str = "customer",
|
|
| 217 |
# (verify_api's overlay probe) without paying the pool call. Per-user by construction β
|
| 218 |
# `table_workspace` is this session's workspace.
|
| 219 |
workspace["overlays"] = g["ws"].get("overlays") or {}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
# ββ the STANDALONE measure channel (owner item 1, 2026-07-31) ββββββββββββββββββββββββββββ
|
| 221 |
# The embed receives these as top-level render args; standalone lifts them off THIS route
|
| 222 |
# (useCustomerData merges them into the payload slots the grid already reads). They ride
|
|
|
|
| 134 |
raise err(503, "store_unavailable", "the tenant store is unavailable")
|
| 135 |
workspace = g["workspace"]
|
| 136 |
workspace["overlays"] = g["ws"].get("overlays") or {}
|
| 137 |
+
# The field contract rides the cheap re-read on EVERY topic β see the customer branch
|
| 138 |
+
# below for why (a user table's first cohort changes its contract too: `_cohorts(ctx)`
|
| 139 |
+
# is scope-parameterized, so `ut_` surfaces have their own sets).
|
| 140 |
+
workspace["fields"] = g["fields"]
|
| 141 |
workspace["measures"] = g["measures"]
|
| 142 |
workspace["measureSets"] = g["measure_sets"]
|
| 143 |
+
# β `g["derived"]`, NOT `{}` (2026-08-04). R9 gave every topic its own cohort sets, and
|
| 144 |
+
# `ut_assembly` has been building this topic's cohort CELLS since β but this route threw
|
| 145 |
+
# them away, so the Locked-views column arrived on the cheap re-read with permanently
|
| 146 |
+
# empty values. A column that exists and can never have one is worse than an absent
|
| 147 |
+
# column: it reads as "this record is in no locked view", which is a claim.
|
| 148 |
+
workspace["derived"] = {str(pid): cells for pid, cells in (g["derived"] or {}).items()}
|
| 149 |
workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
|
| 150 |
try:
|
| 151 |
from core import users as _users
|
|
|
|
| 178 |
raise err(503, "store_unavailable", "the tenant store is unavailable")
|
| 179 |
workspace = g["workspace"]
|
| 180 |
workspace["overlays"] = g["ws"].get("overlays") or {}
|
| 181 |
+
# R9 made cohorts per-topic, so the PRODUCT surface has its own sets and its own
|
| 182 |
+
# first-cohort contract change. Same reason as the customer branch below.
|
| 183 |
+
workspace["fields"] = g["fields"]
|
| 184 |
# The measure channel is EMPTY on this topic (customer-grain descope) β stated
|
| 185 |
# explicitly so the client's pickers grey rather than guess.
|
| 186 |
workspace["measures"] = g["measures"]
|
| 187 |
workspace["measureSets"] = g["measure_sets"]
|
| 188 |
+
# `g["derived"]` β the same correction as the user-table branch above, for the same
|
| 189 |
+
# reason: `product_assembly` builds this topic's cohort cells and this route discarded
|
| 190 |
+
# them. The measure half stays empty on this topic by descope, which is a different
|
| 191 |
+
# statement and one the offer already makes.
|
| 192 |
+
workspace["derived"] = {str(pid): cells for pid, cells in (g["derived"] or {}).items()}
|
| 193 |
workspace["viewer"] = {"name": session.uname, "isAdmin": bool(session.admin)}
|
| 194 |
try:
|
| 195 |
from core import users as _users
|
|
|
|
| 233 |
# (verify_api's overlay probe) without paying the pool call. Per-user by construction β
|
| 234 |
# `table_workspace` is this session's workspace.
|
| 235 |
workspace["overlays"] = g["ws"].get("overlays") or {}
|
| 236 |
+
# β THE FIELD CONTRACT, 2026-08-04 β and it is NOT decoration.
|
| 237 |
+
#
|
| 238 |
+
# `fields_from_workspace(ws, cohorts=bool(cohort_lists))` appends the derived "Locked
|
| 239 |
+
# views" column ONLY when the caller owns at least one cohort. So a user's FIRST cohort
|
| 240 |
+
# CHANGES THE FIELD CONTRACT β and the rows call that used to be the only carrier of
|
| 241 |
+
# `fields` is deliberately never re-fetched on a write (it is a 15-minute-cached Odoo
|
| 242 |
+
# pull; this route is the cheap re-read). The client therefore had no way to learn about
|
| 243 |
+
# that column short of a remount, which is the second half of the owner's "it only shows
|
| 244 |
+
# up when I switch modules and come back".
|
| 245 |
+
#
|
| 246 |
+
# β `g["fields"]` is the SAME list `_payload` serves on `/customers` β same assembly,
|
| 247 |
+
# same permission wall (`hidden_keys` already applied) β so the two wires cannot disagree
|
| 248 |
+
# about what a column is. Taking it from anywhere else would fork the contract that
|
| 249 |
+
# verify_fields_contract.py exists to keep single-sourced.
|
| 250 |
+
workspace["fields"] = g["fields"]
|
| 251 |
# ββ the STANDALONE measure channel (owner item 1, 2026-07-31) ββββββββββββββββββββββββββββ
|
| 252 |
# The embed receives these as top-level render args; standalone lifts them off THIS route
|
| 253 |
# (useCustomerData merges them into the payload slots the grid already reads). They ride
|
platform/core/store_pg.py
CHANGED
|
@@ -10,9 +10,11 @@ Same names, same signatures, same return types, same failure semantics β so sw
|
|
| 10 |
an env var (`STORE_BACKEND=hf|pg`) and not a rewrite of every caller. `core/store_backend.py`
|
| 11 |
does the selection; nothing above it needs to know which store it is talking to.
|
| 12 |
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
The schema is `harness/pg/schema.sql`. Sequenced around the blocker, never stubbed past it.
|
| 17 |
|
| 18 |
WHAT POSTGRES FIXES, precisely (C1c β these are the reasons, not a preference):
|
|
|
|
| 10 |
an env var (`STORE_BACKEND=hf|pg`) and not a rewrite of every caller. `core/store_backend.py`
|
| 11 |
does the selection; nothing above it needs to know which store it is talking to.
|
| 12 |
|
| 13 |
+
β
VERIFIED against a real managed Postgres 2026-08-04 (W19: Neon us-east-2; `verify_store_pg.py`
|
| 14 |
+
80/80 INCLUDING the integration half β schema apply, tenant provisioning, jsonb/bytea round-trips,
|
| 15 |
+
80 concurrent FOR-UPDATE writes β run from HF egress; the owner's local network resets TLS:5432).
|
| 16 |
+
The CUTOVER remains parked behind C1e's triggers and this module is not the default backend.
|
| 17 |
+
Without `DATABASE_URL` the gate still SKIPS its integration half LOUDLY, never silently.
|
| 18 |
The schema is `harness/pg/schema.sql`. Sequenced around the blocker, never stubbed past it.
|
| 19 |
|
| 20 |
WHAT POSTGRES FIXES, precisely (C1c β these are the reasons, not a preference):
|
platform/harness/pg/schema.sql
CHANGED
|
@@ -8,11 +8,11 @@
|
|
| 8 |
-- billing mirror) is transactional and needs joins; the ANALYTICAL mirror stays in DuckDB,
|
| 9 |
-- one file per tenant, because file-per-tenant IS the isolation model there.
|
| 10 |
--
|
| 11 |
-
--
|
| 12 |
-
--
|
| 13 |
-
--
|
| 14 |
-
--
|
| 15 |
-
--
|
| 16 |
--
|
| 17 |
-- β CO-LOCATE (C1b). An Ashburn app with a European database adds ~90ms to every query and undoes
|
| 18 |
-- the reason the market pivot happened.
|
|
|
|
| 8 |
-- billing mirror) is transactional and needs joins; the ANALYTICAL mirror stays in DuckDB,
|
| 9 |
-- one file per tenant, because file-per-tenant IS the isolation model there.
|
| 10 |
--
|
| 11 |
+
-- β
RUN AGAINST A REAL SERVER 2026-08-04 (W19): applied end-to-end by verify_store_pg's
|
| 12 |
+
-- integration half against the owner's Neon (psycopg executes everything ABOVE the
|
| 13 |
+
-- "-- Optional:" marker; the tail below it is psql-variable syntax). Tenant provisioning,
|
| 14 |
+
-- jsonb/bytea round-trips and 80 concurrent FOR-UPDATE writes all proven. The CUTOVER remains
|
| 15 |
+
-- parked behind C1e's triggers; `STORE_BACKEND` still defaults to hf.
|
| 16 |
--
|
| 17 |
-- β CO-LOCATE (C1b). An Ashburn app with a European database adds ~90ms to every query and undoes
|
| 18 |
-- the reason the market pivot happened.
|
web/_qa_live_rail/1_before.png
ADDED
|
Git LFS Details
|
web/_qa_live_rail/2_menu.png
ADDED
|
web/_qa_live_rail/3_prompt.png
ADDED
|
web/_qa_live_rail/4_after.png
ADDED
|
web/_qa_live_rail/5_fields.png
ADDED
|
web/_qa_live_rail/debug.png
ADDED
|
Git LFS Details
|
web/dist-embed/index.html
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/OverlaySurface.tsx
CHANGED
|
@@ -80,7 +80,24 @@ export function OverlayProvider({ children }: { children: ReactNode }) {
|
|
| 80 |
);
|
| 81 |
}
|
| 82 |
|
| 83 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
if ("getBoundingClientRect" in anchor) {
|
| 85 |
const rect = anchor.getBoundingClientRect();
|
| 86 |
return {
|
|
@@ -109,10 +126,10 @@ interface OverlayLayerOptions {
|
|
| 109 |
panelRef: RefObject<HTMLElement | null>;
|
| 110 |
onDismiss: () => void;
|
| 111 |
dismissOnOutside?: boolean;
|
| 112 |
-
initialFocus?: "first" | "none" | string;
|
| 113 |
-
restoreFocus?: boolean;
|
| 114 |
-
trapFocus?: boolean;
|
| 115 |
-
outsideElements?: Array<HTMLElement | null>;
|
| 116 |
}
|
| 117 |
|
| 118 |
/** Shared dismissal/focus contract for anchored menus and fixed drawers. */
|
|
@@ -121,10 +138,10 @@ export function useOverlayLayer({
|
|
| 121 |
panelRef,
|
| 122 |
onDismiss,
|
| 123 |
dismissOnOutside = true,
|
| 124 |
-
initialFocus = "first",
|
| 125 |
-
restoreFocus = true,
|
| 126 |
-
trapFocus = false,
|
| 127 |
-
outsideElements = [],
|
| 128 |
}: OverlayLayerOptions): void {
|
| 129 |
const id = useId();
|
| 130 |
const stack = useContext(OverlayStackContext);
|
|
@@ -147,47 +164,47 @@ export function useOverlayLayer({
|
|
| 147 |
)
|
| 148 |
document.activeElement.blur();
|
| 149 |
dismissRef.current();
|
| 150 |
-
};
|
| 151 |
-
const onKeyDown = (event: KeyboardEvent) => {
|
| 152 |
-
if (stack && !stack.isTop(id)) return;
|
| 153 |
-
if (event.key === "Escape") {
|
| 154 |
-
event.preventDefault();
|
| 155 |
-
event.stopPropagation();
|
| 156 |
-
if (
|
| 157 |
-
document.activeElement instanceof HTMLElement &&
|
| 158 |
-
panelRef.current?.contains(document.activeElement)
|
| 159 |
-
)
|
| 160 |
-
document.activeElement.blur();
|
| 161 |
-
dismissRef.current();
|
| 162 |
-
return;
|
| 163 |
-
}
|
| 164 |
-
if (event.key !== "Tab" || !trapFocus) return;
|
| 165 |
-
const focusable = Array.from(
|
| 166 |
-
panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []
|
| 167 |
-
).filter((element) => element.getClientRects().length > 0);
|
| 168 |
-
if (!focusable.length) {
|
| 169 |
-
event.preventDefault();
|
| 170 |
-
panelRef.current?.focus();
|
| 171 |
-
return;
|
| 172 |
-
}
|
| 173 |
-
const first = focusable[0];
|
| 174 |
-
const last = focusable.at(-1)!;
|
| 175 |
-
const active = document.activeElement;
|
| 176 |
-
if (event.shiftKey && (active === first || !panelRef.current?.contains(active))) {
|
| 177 |
-
event.preventDefault();
|
| 178 |
-
last.focus();
|
| 179 |
-
} else if (!event.shiftKey && active === last) {
|
| 180 |
-
event.preventDefault();
|
| 181 |
-
first.focus();
|
| 182 |
-
}
|
| 183 |
-
};
|
| 184 |
document.addEventListener("pointerdown", onPointerDown, true);
|
| 185 |
document.addEventListener("keydown", onKeyDown, true);
|
| 186 |
return () => {
|
| 187 |
document.removeEventListener("pointerdown", onPointerDown, true);
|
| 188 |
document.removeEventListener("keydown", onKeyDown, true);
|
| 189 |
};
|
| 190 |
-
}, [dismissOnOutside, id, panelRef, stack, trapFocus]);
|
| 191 |
|
| 192 |
useLayoutEffect(() => {
|
| 193 |
const previous =
|
|
@@ -210,7 +227,10 @@ export function BodyPortal({ children }: { children: ReactNode }) {
|
|
| 210 |
}
|
| 211 |
|
| 212 |
interface AnchoredOverlayProps {
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
| 214 |
className: string;
|
| 215 |
children: ReactNode;
|
| 216 |
onDismiss: () => void;
|
|
@@ -248,7 +268,7 @@ export function AnchoredOverlay({
|
|
| 248 |
top: 0,
|
| 249 |
visibility: "hidden",
|
| 250 |
});
|
| 251 |
-
const anchorElement = "getBoundingClientRect" in anchor ? anchor : null;
|
| 252 |
|
| 253 |
useOverlayLayer({
|
| 254 |
panelRef,
|
|
|
|
| 80 |
);
|
| 81 |
}
|
| 82 |
|
| 83 |
+
/**
|
| 84 |
+
* β NULL IS A REACHABLE VALUE HERE, and it must not be fatal.
|
| 85 |
+
*
|
| 86 |
+
* Half the call sites pass `someRef.current`, which is legitimately null on the render
|
| 87 |
+
* before the ref attaches, and `Anchor` does not include null β so every one of them was
|
| 88 |
+
* one ordering accident away from `"getBoundingClientRect" in null`, a TypeError thrown
|
| 89 |
+
* during RENDER, which unmounts the whole tree. That is not a hypothetical: it is what
|
| 90 |
+
* `_qa_live_rail.py` reproduced on the shipped build when the saved-view menu's anchor
|
| 91 |
+
* came back null (2026-08-04).
|
| 92 |
+
*
|
| 93 |
+
* A missing anchor is a positioning problem, not a reason to lose the application. The
|
| 94 |
+
* panel degrades to a zero-rect at the viewport origin β visible, dismissible, obviously
|
| 95 |
+
* wrong β while the root causes stay fixable at their own call sites.
|
| 96 |
+
*/
|
| 97 |
+
const NO_RECT: AnchorRect = { left: 0, top: 0, right: 0, bottom: 0, width: 0, height: 0 };
|
| 98 |
+
|
| 99 |
+
function anchorRect(anchor: Anchor | null | undefined): AnchorRect {
|
| 100 |
+
if (!anchor) return NO_RECT;
|
| 101 |
if ("getBoundingClientRect" in anchor) {
|
| 102 |
const rect = anchor.getBoundingClientRect();
|
| 103 |
return {
|
|
|
|
| 126 |
panelRef: RefObject<HTMLElement | null>;
|
| 127 |
onDismiss: () => void;
|
| 128 |
dismissOnOutside?: boolean;
|
| 129 |
+
initialFocus?: "first" | "none" | string;
|
| 130 |
+
restoreFocus?: boolean;
|
| 131 |
+
trapFocus?: boolean;
|
| 132 |
+
outsideElements?: Array<HTMLElement | null>;
|
| 133 |
}
|
| 134 |
|
| 135 |
/** Shared dismissal/focus contract for anchored menus and fixed drawers. */
|
|
|
|
| 138 |
panelRef,
|
| 139 |
onDismiss,
|
| 140 |
dismissOnOutside = true,
|
| 141 |
+
initialFocus = "first",
|
| 142 |
+
restoreFocus = true,
|
| 143 |
+
trapFocus = false,
|
| 144 |
+
outsideElements = [],
|
| 145 |
}: OverlayLayerOptions): void {
|
| 146 |
const id = useId();
|
| 147 |
const stack = useContext(OverlayStackContext);
|
|
|
|
| 164 |
)
|
| 165 |
document.activeElement.blur();
|
| 166 |
dismissRef.current();
|
| 167 |
+
};
|
| 168 |
+
const onKeyDown = (event: KeyboardEvent) => {
|
| 169 |
+
if (stack && !stack.isTop(id)) return;
|
| 170 |
+
if (event.key === "Escape") {
|
| 171 |
+
event.preventDefault();
|
| 172 |
+
event.stopPropagation();
|
| 173 |
+
if (
|
| 174 |
+
document.activeElement instanceof HTMLElement &&
|
| 175 |
+
panelRef.current?.contains(document.activeElement)
|
| 176 |
+
)
|
| 177 |
+
document.activeElement.blur();
|
| 178 |
+
dismissRef.current();
|
| 179 |
+
return;
|
| 180 |
+
}
|
| 181 |
+
if (event.key !== "Tab" || !trapFocus) return;
|
| 182 |
+
const focusable = Array.from(
|
| 183 |
+
panelRef.current?.querySelectorAll<HTMLElement>(FOCUSABLE) ?? []
|
| 184 |
+
).filter((element) => element.getClientRects().length > 0);
|
| 185 |
+
if (!focusable.length) {
|
| 186 |
+
event.preventDefault();
|
| 187 |
+
panelRef.current?.focus();
|
| 188 |
+
return;
|
| 189 |
+
}
|
| 190 |
+
const first = focusable[0];
|
| 191 |
+
const last = focusable.at(-1)!;
|
| 192 |
+
const active = document.activeElement;
|
| 193 |
+
if (event.shiftKey && (active === first || !panelRef.current?.contains(active))) {
|
| 194 |
+
event.preventDefault();
|
| 195 |
+
last.focus();
|
| 196 |
+
} else if (!event.shiftKey && active === last) {
|
| 197 |
+
event.preventDefault();
|
| 198 |
+
first.focus();
|
| 199 |
+
}
|
| 200 |
+
};
|
| 201 |
document.addEventListener("pointerdown", onPointerDown, true);
|
| 202 |
document.addEventListener("keydown", onKeyDown, true);
|
| 203 |
return () => {
|
| 204 |
document.removeEventListener("pointerdown", onPointerDown, true);
|
| 205 |
document.removeEventListener("keydown", onKeyDown, true);
|
| 206 |
};
|
| 207 |
+
}, [dismissOnOutside, id, panelRef, stack, trapFocus]);
|
| 208 |
|
| 209 |
useLayoutEffect(() => {
|
| 210 |
const previous =
|
|
|
|
| 227 |
}
|
| 228 |
|
| 229 |
interface AnchoredOverlayProps {
|
| 230 |
+
/** β Nullable BY DECLARATION as of 2026-08-04. Several call sites pass `ref.current`,
|
| 231 |
+
* which is null before the ref attaches, and the old non-null type made that a
|
| 232 |
+
* render-time TypeError instead of a type error. See `anchorRect`. */
|
| 233 |
+
anchor: Anchor | null | undefined;
|
| 234 |
className: string;
|
| 235 |
children: ReactNode;
|
| 236 |
onDismiss: () => void;
|
|
|
|
| 268 |
top: 0,
|
| 269 |
visibility: "hidden",
|
| 270 |
});
|
| 271 |
+
const anchorElement = anchor && "getBoundingClientRect" in anchor ? anchor : null;
|
| 272 |
|
| 273 |
useOverlayLayer({
|
| 274 |
panelRef,
|
web/src/customer-grid/ViewSidebar.tsx
CHANGED
|
@@ -717,13 +717,15 @@ export default function ViewSidebar({
|
|
| 717 |
className="cg-view-more"
|
| 718 |
aria-label={`Actions for folder ${group.folder.name}`}
|
| 719 |
aria-haspopup="menu"
|
| 720 |
-
onClick={(e) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 721 |
setFolderMenu((cur) =>
|
| 722 |
-
cur?.id === group.folder!.id
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
)
|
| 726 |
-
}
|
| 727 |
>
|
| 728 |
Β·Β·Β·
|
| 729 |
</button>
|
|
@@ -850,13 +852,29 @@ export default function ViewSidebar({
|
|
| 850 |
aria-label={`Actions for ${view.name}`}
|
| 851 |
aria-haspopup="menu"
|
| 852 |
aria-expanded={menu?.viewId === view.id}
|
| 853 |
-
onClick={(event) =>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 854 |
setMenu((current) =>
|
| 855 |
-
current?.viewId === view.id
|
| 856 |
-
|
| 857 |
-
|
| 858 |
-
)
|
| 859 |
-
}
|
| 860 |
>
|
| 861 |
Β·Β·Β·
|
| 862 |
</button>
|
|
|
|
| 717 |
className="cg-view-more"
|
| 718 |
aria-label={`Actions for folder ${group.folder.name}`}
|
| 719 |
aria-haspopup="menu"
|
| 720 |
+
onClick={(e) => {
|
| 721 |
+
// β HOISTED OUT OF THE UPDATER β see the view menu's twin below. React
|
| 722 |
+
// nulls `currentTarget` when the handler returns, and a functional
|
| 723 |
+
// updater can be re-invoked AFTER that.
|
| 724 |
+
const anchor = e.currentTarget;
|
| 725 |
setFolderMenu((cur) =>
|
| 726 |
+
cur?.id === group.folder!.id ? null : { id: group.folder!.id, anchor }
|
| 727 |
+
);
|
| 728 |
+
}}
|
|
|
|
|
|
|
| 729 |
>
|
| 730 |
Β·Β·Β·
|
| 731 |
</button>
|
|
|
|
| 852 |
aria-label={`Actions for ${view.name}`}
|
| 853 |
aria-haspopup="menu"
|
| 854 |
aria-expanded={menu?.viewId === view.id}
|
| 855 |
+
onClick={(event) => {
|
| 856 |
+
/**
|
| 857 |
+
* β THE ANCHOR IS READ HERE, NOT INSIDE THE UPDATER β and that one line
|
| 858 |
+
* is the difference between this rail working and the whole app going
|
| 859 |
+
* white. Found by _qa_live_rail.py on 2026-08-04, reproduced on the
|
| 860 |
+
* shipped build.
|
| 861 |
+
*
|
| 862 |
+
* React sets `event.currentTarget = null` the moment this handler
|
| 863 |
+
* returns (`executeDispatch`'s finally). A FUNCTIONAL updater is not
|
| 864 |
+
* guaranteed to run inside the handler: the eager-state path evaluates
|
| 865 |
+
* it once, synchronously, while `currentTarget` is still the button β
|
| 866 |
+
* but React re-invokes it when it processes the queue for real, and by
|
| 867 |
+
* then it is null. The menu therefore opened correctly and only died
|
| 868 |
+
* later, on a re-render triggered by something else entirely β which is
|
| 869 |
+
* why a white screen appeared one interaction AFTER the click that
|
| 870 |
+
* caused it. `AnchoredOverlay` then did `"getBoundingClientRect" in
|
| 871 |
+
* null` and took the tree down with it.
|
| 872 |
+
*/
|
| 873 |
+
const anchor = event.currentTarget;
|
| 874 |
setMenu((current) =>
|
| 875 |
+
current?.viewId === view.id ? null : { viewId: view.id, anchor }
|
| 876 |
+
);
|
| 877 |
+
}}
|
|
|
|
|
|
|
| 878 |
>
|
| 879 |
Β·Β·Β·
|
| 880 |
</button>
|
web/src/customer-grid/liveWorkspace.ts
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// customer-grid / liveWorkspace.ts
|
| 3 |
+
// The workspace that arrives AFTER mount β pure and React-free, so
|
| 4 |
+
// verify_live_workspace.py can run it under node.
|
| 5 |
+
//
|
| 6 |
+
// β THE DEFECT THIS EXISTS FOR (owner, 2026-08-04). "Creating a new Cohort or
|
| 7 |
+
// Locked list under Product/Customer only shows up when I click a different
|
| 8 |
+
// module first and come back."
|
| 9 |
+
//
|
| 10 |
+
// It was exact. `CustomerGrid`'s init effect is gated on
|
| 11 |
+
// `initializedKey.current === storageKey`, so it reads `payload.workspace`
|
| 12 |
+
// EXACTLY ONCE per mount. Under Streamlit that was invisible β a rerun replaces
|
| 13 |
+
// the iframe, so every host round trip WAS a remount and init ran again. The
|
| 14 |
+
// standalone shell has no rerun: `CustomerGrid` stays mounted, and the only
|
| 15 |
+
// thing that remounts it is `key={active.key}` in Shell.tsx β i.e. switching
|
| 16 |
+
// modules, which is precisely the workaround the owner found.
|
| 17 |
+
//
|
| 18 |
+
// So the write path was complete and the READ path stopped at the door:
|
| 19 |
+
// `add_to_list` β the host creates the set β `rerender: true` β
|
| 20 |
+
// WORKSPACE_STALE_EVENT β `reread()` β `payload.workspace` genuinely carries the
|
| 21 |
+
// new locked view (wave 17 R1 projects every cohort as a view) β and `views`
|
| 22 |
+
// state, seeded once at init, never heard about it.
|
| 23 |
+
//
|
| 24 |
+
// TWO STRATA GO STALE TOGETHER, and fixing only the first would have looked
|
| 25 |
+
// fixed while staying broken:
|
| 26 |
+
// views the projected locked view = the rail row the owner is looking for.
|
| 27 |
+
// fields `fields_from_workspace(ws, cohorts=bool(cohort_lists))` β the
|
| 28 |
+
// derived "Locked views" column EXISTS ONLY ONCE A COHORT DOES. The
|
| 29 |
+
// user's FIRST cohort therefore changes the field contract, and an
|
| 30 |
+
// init-once `fields` would have left that column out of the Fields
|
| 31 |
+
// menu until the very remount we are removing the need for.
|
| 32 |
+
//
|
| 33 |
+
// β ADD-ONLY, AND THAT IS A DESIGN DECISION, NOT AN OMISSION. Everything the
|
| 34 |
+
// user already holds is left BY IDENTITY: an in-flight filter tree, a config
|
| 35 |
+
// mid-autosave (420 ms debounce), a rename waiting on its echo. This module is
|
| 36 |
+
// the answer to "what has APPEARED since we mounted", and nothing else. Taking
|
| 37 |
+
// host copies of things we already hold would re-introduce every blip
|
| 38 |
+
// optimism.ts / viewEcho.ts / folders.ts were written to remove β the whole
|
| 39 |
+
// no-blip layer rests on "this browser's copy is the newest truth".
|
| 40 |
+
//
|
| 41 |
+
// β TOMBSTONES ARE NOT OPTIONAL HERE, and an add-only merge without them is
|
| 42 |
+
// WORSE than the bug it fixes. The event queue sends ONE batch at a time
|
| 43 |
+
// (apiBridge `drain`), so this interleaving is ordinary:
|
| 44 |
+
// batch 1 [add_to_list] in flight
|
| 45 |
+
// user deletes a view β removed optimistically, queued behind batch 1
|
| 46 |
+
// batch 1 answers β rerender β reread β the workspace STILL lists
|
| 47 |
+
// the deleted view (its delete has not been sent)
|
| 48 |
+
// Without a tombstone the row comes back β and since this merge never removes,
|
| 49 |
+
// it would stay back until a remount. That is a delete that visibly failed.
|
| 50 |
+
// Same window and same rule as folders.ts and optimism.ts: ECHO_RECENT_MS, and
|
| 51 |
+
// anything past it yields to the host, because divergence is not an echo.
|
| 52 |
+
// ---------------------------------------------------------------------------
|
| 53 |
+
|
| 54 |
+
import { ECHO_RECENT_MS } from "./viewEcho";
|
| 55 |
+
import type { Field, SavedView, ViewConfig } from "./types";
|
| 56 |
+
|
| 57 |
+
/** id -> when THIS browser deleted it. Browser-clock arithmetic on purpose:
|
| 58 |
+
* both sides of the comparison come from this machine, so this is not the
|
| 59 |
+
* tenant-day contract ([[date-window-vocabulary]]) β that one is about two
|
| 60 |
+
* ENGINES agreeing on a date. */
|
| 61 |
+
export type Tombstones = Record<string, number>;
|
| 62 |
+
|
| 63 |
+
/** Upper bound on a tombstone map, matching FOLDER_STAMP_MAX. A long-lived tab
|
| 64 |
+
* must not accumulate an archive of everything it ever deleted. */
|
| 65 |
+
export const TOMBSTONE_MAX = 64;
|
| 66 |
+
|
| 67 |
+
const isRecent = (t: number | undefined, now: number): boolean =>
|
| 68 |
+
typeof t === "number" && now - t <= ECHO_RECENT_MS;
|
| 69 |
+
|
| 70 |
+
/** Drop entries past the echo window. Called at every stamp AND before every
|
| 71 |
+
* persist, so the blob stays a recent window rather than a growing log. */
|
| 72 |
+
export function pruneTombstones(stamps: Tombstones | undefined, now: number): Tombstones {
|
| 73 |
+
const kept = Object.entries(stamps ?? {}).filter(
|
| 74 |
+
([, t]) => typeof t === "number" && isRecent(t, now)
|
| 75 |
+
);
|
| 76 |
+
return Object.fromEntries(kept.slice(-TOMBSTONE_MAX));
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
+
/** Record one deletion. Pure so the caller's ref update stays a one-liner. */
|
| 80 |
+
export function stampTombstone(stamps: Tombstones | undefined, id: string, now: number): Tombstones {
|
| 81 |
+
return pruneTombstones({ ...(stamps ?? {}), [id]: now }, now);
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
/**
|
| 85 |
+
* Views that have APPEARED on the host since this browser last looked.
|
| 86 |
+
*
|
| 87 |
+
* Returns `current` BY IDENTITY when there is nothing to adopt β which is the
|
| 88 |
+
* load-bearing half of the contract, not an optimisation. `withWorkspace` mints
|
| 89 |
+
* a fresh payload object on every re-read, so `hostViews` changes identity each
|
| 90 |
+
* time whether or not its contents did; a merge that always returned a new
|
| 91 |
+
* array would re-render the grid (and re-write localStorage) on every echo.
|
| 92 |
+
*
|
| 93 |
+
* `normalize` is injected rather than imported because it needs the FIELD LIST
|
| 94 |
+
* the caller is about to commit β a locked view's projected `config.order` names
|
| 95 |
+
* the derived cohort column, and normalizing against a stale field array would
|
| 96 |
+
* quietly drop the very key that arrived with it. The caller therefore adopts
|
| 97 |
+
* fields FIRST and hands the result down (see CustomerGrid's live effect).
|
| 98 |
+
*/
|
| 99 |
+
export function adoptNewViews(
|
| 100 |
+
current: SavedView[],
|
| 101 |
+
hostViews: SavedView[] | undefined,
|
| 102 |
+
tombstones: Tombstones | undefined,
|
| 103 |
+
now: number,
|
| 104 |
+
normalize: (config: Partial<ViewConfig> | undefined) => ViewConfig
|
| 105 |
+
): SavedView[] {
|
| 106 |
+
if (!Array.isArray(hostViews) || hostViews.length === 0) return current;
|
| 107 |
+
const held = new Set(current.map((v) => v.id));
|
| 108 |
+
const fresh: SavedView[] = [];
|
| 109 |
+
for (const view of hostViews) {
|
| 110 |
+
if (!view || typeof view.id !== "string" || view.id === "") continue;
|
| 111 |
+
if (held.has(view.id)) continue;
|
| 112 |
+
// Deleted here seconds ago and the echo has not caught up. Resurrecting it β
|
| 113 |
+
// even for one round trip β is the delete blip, and this merge never removes,
|
| 114 |
+
// so it would be a permanent one.
|
| 115 |
+
if (isRecent(tombstones?.[view.id], now)) continue;
|
| 116 |
+
held.add(view.id); // a host list with a duplicate id adds once
|
| 117 |
+
fresh.push({ ...view, config: normalize(view.config) });
|
| 118 |
+
}
|
| 119 |
+
return fresh.length === 0 ? current : [...current, ...fresh];
|
| 120 |
+
}
|
| 121 |
+
|
| 122 |
+
/**
|
| 123 |
+
* Fields that have APPEARED on the host since this browser last looked β in
|
| 124 |
+
* practice the derived "Locked views" column, which the server emits only once
|
| 125 |
+
* the user owns at least one cohort.
|
| 126 |
+
*
|
| 127 |
+
* Appended in host order at the END, which is where `fields_from_workspace`
|
| 128 |
+
* puts the derived column anyway, and `reconcileOrder` folds any key missing
|
| 129 |
+
* from a saved `config.order` in for us β so nothing has to touch a stored view
|
| 130 |
+
* for the new column to become togglable in the Fields menu.
|
| 131 |
+
*
|
| 132 |
+
* β The tombstone map here is `FieldStamps.deleted`, the SAME one
|
| 133 |
+
* `reconcileFields` consults at mount, for the same reason: a column this
|
| 134 |
+
* browser dropped must not walk back in through a lagged echo.
|
| 135 |
+
*/
|
| 136 |
+
export function adoptNewFields(
|
| 137 |
+
current: Field[],
|
| 138 |
+
hostFields: Field[] | undefined,
|
| 139 |
+
tombstones: Tombstones | undefined,
|
| 140 |
+
now: number
|
| 141 |
+
): Field[] {
|
| 142 |
+
if (!Array.isArray(hostFields) || hostFields.length === 0) return current;
|
| 143 |
+
const held = new Set(current.map((f) => f.key));
|
| 144 |
+
const fresh: Field[] = [];
|
| 145 |
+
for (const field of hostFields) {
|
| 146 |
+
if (!field || typeof field.key !== "string" || field.key === "") continue;
|
| 147 |
+
if (held.has(field.key)) continue;
|
| 148 |
+
if (isRecent(tombstones?.[field.key], now)) continue;
|
| 149 |
+
held.add(field.key);
|
| 150 |
+
fresh.push(field);
|
| 151 |
+
}
|
| 152 |
+
return fresh.length === 0 ? current : [...current, ...fresh];
|
| 153 |
+
}
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -2109,6 +2109,17 @@ export interface GridWorkspace {
|
|
| 2109 |
storageKey: string;
|
| 2110 |
views: SavedView[];
|
| 2111 |
activeViewId?: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2112 |
/** C4 β the view rail's folders. Absent = no folders, the pre-wave-8 shape. */
|
| 2113 |
folders?: GridFolder[];
|
| 2114 |
/** C4 β the cohort rail's folders (the cohort page's workspace). */
|
|
|
|
| 2109 |
storageKey: string;
|
| 2110 |
views: SavedView[];
|
| 2111 |
activeViewId?: string;
|
| 2112 |
+
/**
|
| 2113 |
+
* 2026-08-04 β the FIELD CONTRACT, so it rides the cheap re-read a durable write triggers
|
| 2114 |
+
* (WORKSPACE_STALE) instead of the heavy rows call, which is deliberately never refetched.
|
| 2115 |
+
*
|
| 2116 |
+
* β This is not a convenience copy. `fields_from_workspace(ws, cohorts=β¦)` emits the derived
|
| 2117 |
+
* "Locked views" column only once a cohort EXISTS, so a user's FIRST cohort changes the
|
| 2118 |
+
* contract β and before this key the client could not learn that without a remount. Same
|
| 2119 |
+
* list the rows route serves, from the same assembly, with the same permission wall applied.
|
| 2120 |
+
* Absent = an older host: the client keeps the fields it already has (`withWorkspace`).
|
| 2121 |
+
*/
|
| 2122 |
+
fields?: Field[];
|
| 2123 |
/** C4 β the view rail's folders. Absent = no folders, the pre-wave-8 shape. */
|
| 2124 |
folders?: GridFolder[];
|
| 2125 |
/** C4 β the cohort rail's folders (the cohort page's workspace). */
|
web/src/customer-grid/useCustomerData.ts
CHANGED
|
@@ -64,6 +64,17 @@ function readHostPayload(): CustomersPayload | null {
|
|
| 64 |
*/
|
| 65 |
function withWorkspace(payload: CustomersPayload, ws: GridWorkspace): CustomersPayload {
|
| 66 |
const out: CustomersPayload = { ...payload, workspace: ws };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
if (Array.isArray(ws.measures)) out.measures = ws.measures;
|
| 68 |
if (ws.measureSets && typeof ws.measureSets === "object") out.measureSets = ws.measureSets;
|
| 69 |
if (ws.viewer && typeof ws.viewer.name === "string") out.viewer = ws.viewer;
|
|
|
|
| 64 |
*/
|
| 65 |
function withWorkspace(payload: CustomersPayload, ws: GridWorkspace): CustomersPayload {
|
| 66 |
const out: CustomersPayload = { ...payload, workspace: ws };
|
| 67 |
+
// 2026-08-04 β the FIELD CONTRACT rides this cheap call too. The derived "Locked views"
|
| 68 |
+
// column exists only once the caller owns a cohort, so creating the first one changes the
|
| 69 |
+
// contract; the rows call that used to be its only carrier is never refetched on a write.
|
| 70 |
+
//
|
| 71 |
+
// β IDENTITY IS PRESERVED WHEN NOTHING CHANGED. `payload.fields` is a hook dependency
|
| 72 |
+
// downstream (CustomerGrid's live adopt), and a fresh array on every echo would re-run
|
| 73 |
+
// that effect for no reason. An empty/absent list keeps what we already have rather than
|
| 74 |
+
// blanking the table β absent means "an older host", never "this table has no columns".
|
| 75 |
+
if (Array.isArray(ws.fields) && ws.fields.length > 0 &&
|
| 76 |
+
JSON.stringify(ws.fields) !== JSON.stringify(payload.fields))
|
| 77 |
+
out.fields = ws.fields;
|
| 78 |
if (Array.isArray(ws.measures)) out.measures = ws.measures;
|
| 79 |
if (ws.measureSets && typeof ws.measureSets === "object") out.measureSets = ws.measureSets;
|
| 80 |
if (ws.viewer && typeof ws.viewer.name === "string") out.viewer = ws.viewer;
|