Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +7 -1
- VERSION +1 -1
- api/routes_feedback.py +13 -1
- web/src/customer-grid/CustomerGrid.tsx +17 -1
- web/src/customer-grid/useVisibleRows.ts +28 -3
- web/src/index.css +19 -0
- web/src/shell/Shell.tsx +26 -2
RELEASES.json
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
{
|
| 5 |
"version": "v25",
|
| 6 |
"sha": "bc24829",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "v27 (c05a794)",
|
| 3 |
"releases": [
|
| 4 |
+
{
|
| 5 |
+
"version": "v27",
|
| 6 |
+
"sha": "c05a794",
|
| 7 |
+
"date": "2026-08-18",
|
| 8 |
+
"subject": "release v27"
|
| 9 |
+
},
|
| 10 |
{
|
| 11 |
"version": "v25",
|
| 12 |
"sha": "bc24829",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
v27 (c05a794)
|
api/routes_feedback.py
CHANGED
|
@@ -157,7 +157,19 @@ def list_feedback(tenant: str = "", session: Session = Depends(require_session))
|
|
| 157 |
if tenant:
|
| 158 |
want = str(tenant).strip().lower()
|
| 159 |
rows = [r for r in rows if str(r.get("tenant") or "").lower() == want]
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
out = {"rows": [dict(r) for r in rows], "total": len(rows), "capacity": MAX_ROWS,
|
| 162 |
"tenants": sorted({str(r.get("tenant") or "") for r in _rows() if r.get("tenant")})}
|
| 163 |
if len(_rows()) >= MAX_ROWS:
|
|
|
|
| 157 |
if tenant:
|
| 158 |
want = str(tenant).strip().lower()
|
| 159 |
rows = [r for r in rows if str(r.get("tenant") or "").lower() == want]
|
| 160 |
+
# β THE TIEBREAK IS THE STORE'S OWN ORDER, AND WITHOUT IT "NEWEST FIRST" IS FALSE FOR ANY TWO
|
| 161 |
+
# ROWS FILED IN THE SAME SECOND (wave-35 QA). `ts` is `int(time.time())` β SECOND resolution β
|
| 162 |
+
# and Python's sort is STABLE, so under `reverse=True` a tie keeps INSERTION order, which is
|
| 163 |
+
# oldest-first: the one direction this route promises never to return. It is not hypothetical
|
| 164 |
+
# and it is not a flake: `api_api`'s R8 row failed on it in the close-out battery, in the
|
| 165 |
+
# wave-35 QA battery and in a solo re-run, and the baseline recorded the true count anyway, so
|
| 166 |
+
# a REAL regression would have read as the known-flake line.
|
| 167 |
+
# β ENUMERATE OVER THE ALREADY-FILTERED LIST: `_rows()` appends, so a row's index IS its arrival
|
| 168 |
+
# order (the module docstring's "newest LAST in the store"), and `?tenant=` only drops rows, so
|
| 169 |
+
# the surviving indexes stay monotonic. Raising `ts` to sub-second resolution instead would
|
| 170 |
+
# break every id already minted as `fb_<ts>_β¦`.
|
| 171 |
+
rows = [r for _, r in sorted(enumerate(rows),
|
| 172 |
+
key=lambda p: (int(p[1].get("ts") or 0), p[0]), reverse=True)]
|
| 173 |
out = {"rows": [dict(r) for r in rows], "total": len(rows), "capacity": MAX_ROWS,
|
| 174 |
"tenants": sorted({str(r.get("tenant") or "") for r in _rows() if r.get("tenant")})}
|
| 175 |
if len(_rows()) >= MAX_ROWS:
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -1591,10 +1591,26 @@ function CustomerGridSurface({
|
|
| 1591 |
// scope, which for a Query surface is the SOURCE database β so autosaving a resize would
|
| 1592 |
// have written a view into the source's own workspace under the artefact's id. The router
|
| 1593 |
// is what keeps "which store does this spec belong to" answered in one place.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1594 |
const routed = routeQueryViewMutation(queryBinding, scope, {
|
| 1595 |
id: eventId(queryBinding ? "query-view-update" : "view"),
|
| 1596 |
type: "view_upsert",
|
| 1597 |
-
view:
|
| 1598 |
}, { query: mutateQueryWorkspace, native: emitHostEvent });
|
| 1599 |
if (routed.channel === "refused")
|
| 1600 |
signal(TOAST_EVENT, routed.refusal?.message ?? "That view change could not be saved.");
|
|
|
|
| 1591 |
// scope, which for a Query surface is the SOURCE database β so autosaving a resize would
|
| 1592 |
// have written a view into the source's own workspace under the artefact's id. The router
|
| 1593 |
// is what keeps "which store does this spec belong to" answered in one place.
|
| 1594 |
+
// ββ WAVE 35 QA β THE SUBJECT OF A QUERY WRITE IS THE ARTEFACT, AND STAMPING IT IS WHAT
|
| 1595 |
+
// MAKES W35-T23 TRUE. This emitted `updated` unchanged, whose `id` is the SOURCE database's
|
| 1596 |
+
// active view (the grid mounts on `gridScopeFor(active.source.database)`, so `views` are the
|
| 1597 |
+
// source's). `routeQueryViewMutation` requires `view.id === binding.artifactId` and the
|
| 1598 |
+
// server requires the same β so EVERY spec write on a Query surface was refused before it
|
| 1599 |
+
// left the browser, and the person saw *"A Query view holds one saved answerβ¦"*, the message
|
| 1600 |
+
// written for a genuine duplicate-view attempt.
|
| 1601 |
+
// β MEASURED, not deduced: resize, sort, group, hide and row height all failed identically
|
| 1602 |
+
// on a pre-wave artefact AND a brand-new one β six for six, zero network writes, `edited`
|
| 1603 |
+
// never flipping. The owner's item was *"I should be able to interact in each of the View
|
| 1604 |
+
// under Query as well, exactly like how I would be able to interact with it under Database
|
| 1605 |
+
// view"*, and the router's own docstring already says these are one thing to it.
|
| 1606 |
+
// β ONLY the emitted subject is re-identified. `setViews` and the tombstone keep using
|
| 1607 |
+
// `updated.id`, because the LOCAL row really is the source's view β the artefact id is who
|
| 1608 |
+
// the WRITE is about, not who the grid is showing.
|
| 1609 |
+
const subject = queryBinding ? { ...updated, id: queryBinding.artifactId } : updated;
|
| 1610 |
const routed = routeQueryViewMutation(queryBinding, scope, {
|
| 1611 |
id: eventId(queryBinding ? "query-view-update" : "view"),
|
| 1612 |
type: "view_upsert",
|
| 1613 |
+
view: subject as unknown as Record<string, unknown>,
|
| 1614 |
}, { query: mutateQueryWorkspace, native: emitHostEvent });
|
| 1615 |
if (routed.channel === "refused")
|
| 1616 |
signal(TOAST_EVENT, routed.refusal?.message ?? "That view change could not be saved.");
|
web/src/customer-grid/useVisibleRows.ts
CHANGED
|
@@ -804,7 +804,20 @@ export function runPipeline(input: PipelineInput): VisibleRowsResult {
|
|
| 804 |
// it client-side would rank the page and label the answer with the scope's count β the exact
|
| 805 |
// "12 rows under 'showing 200 of 5,728'" failure this bypass exists to prevent. The refusal
|
| 806 |
// belongs to `filter_sql`, which maps rank leaves to its UNANSWERABLE tri-state.
|
| 807 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 808 |
|
| 809 |
const fieldByKey = new Map<string, Field>();
|
| 810 |
for (const f of fields) fieldByKey.set(f.key, f);
|
|
@@ -863,12 +876,24 @@ export function runPipeline(input: PipelineInput): VisibleRowsResult {
|
|
| 863 |
ctx.rankSets = NO_RANKING.sets;
|
| 864 |
}
|
| 865 |
|
| 866 |
-
const members = new Set(memberPids);
|
| 867 |
out = out.filter(
|
| 868 |
(r) =>
|
| 869 |
-
|
| 870 |
matchFilterTree(filters, filterConj, r, fieldByKey, ctx)
|
| 871 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 872 |
}
|
| 873 |
|
| 874 |
const q = search.trim().toLowerCase();
|
|
|
|
| 804 |
// it client-side would rank the page and label the answer with the scope's count β the exact
|
| 805 |
// "12 rows under 'showing 200 of 5,728'" failure this bypass exists to prevent. The refusal
|
| 806 |
// belongs to `filter_sql`, which maps rank leaves to its UNANSWERABLE tri-state.
|
| 807 |
+
// ββ WAVE 35 QA β A CURATED SET IS NOT A FILTER, AND THE BYPASS ABOVE DOES NOT COVER IT.
|
| 808 |
+
// `memberPids` is a list of pids the VIEW names; the SQL window never applied it (measured: the
|
| 809 |
+
// "Starred records" view on `ut_odoo_gl_lines` painted "showing 200 of 978,257" with exactly one
|
| 810 |
+
// record starred). So narrowing by membership here is not the double-narrowing this bypass
|
| 811 |
+
// exists to prevent β it is the ONLY narrowing, and skipping it renders a view that promises the
|
| 812 |
+
// starred rows and shows the whole table.
|
| 813 |
+
// β The honest limit, stated rather than hidden: the window is one PAGE the server chose, so a
|
| 814 |
+
// starred record outside it still will not appear. Showing the starred rows that ARE on the page
|
| 815 |
+
// is strictly better than showing 200 unrelated ones under a heading that says "Starred records",
|
| 816 |
+
// and the real fix is the server applying `memberPids` to the window.
|
| 817 |
+
const curated = memberPids.length ? new Set(memberPids) : null;
|
| 818 |
+
if (serverWindowed) {
|
| 819 |
+
return asRows(curated ? rawRows.filter((r) => curated.has(r.pid)) : rawRows);
|
| 820 |
+
}
|
| 821 |
|
| 822 |
const fieldByKey = new Map<string, Field>();
|
| 823 |
for (const f of fields) fieldByKey.set(f.key, f);
|
|
|
|
| 876 |
ctx.rankSets = NO_RANKING.sets;
|
| 877 |
}
|
| 878 |
|
|
|
|
| 879 |
out = out.filter(
|
| 880 |
(r) =>
|
| 881 |
+
(curated ? curated.has(r.pid) : false) ||
|
| 882 |
matchFilterTree(filters, filterConj, r, fieldByKey, ctx)
|
| 883 |
);
|
| 884 |
+
} else if (curated) {
|
| 885 |
+
// ββ WAVE 35 QA β THE WHOLE NARROWING USED TO LIVE INSIDE `if (filters.length)`, SO A VIEW
|
| 886 |
+
// THAT IS *ONLY* A CURATED SET SHOWED EVERY ROW IN THE TABLE. `memberPids` was designed as
|
| 887 |
+
// pinned rows that "ride the `||` below without matching anything" (the note above) β an
|
| 888 |
+
// ADDITION to a filter result β and nothing covered the case where the membership IS the view.
|
| 889 |
+
// W35-T40's "Starred records" is exactly that: `filters: []` with `memberPids` non-empty. The
|
| 890 |
+
// server was right every time (`GET /starred/records` returned `ids:[7745], count:1` and the
|
| 891 |
+
// view's own `config.memberPids` carried it); the client threw the answer away and painted
|
| 892 |
+
// "3,634 records". Reproduced three times, on a small database and on a 978k-row one.
|
| 893 |
+
// β It is an `else if`, not a second unconditional pass: with filters present, membership must
|
| 894 |
+
// stay a UNION (pinned rows ride along), and re-applying it here would turn that union into an
|
| 895 |
+
// intersection and hide every row the filters matched.
|
| 896 |
+
out = out.filter((r) => curated.has(r.pid));
|
| 897 |
}
|
| 898 |
|
| 899 |
const q = search.trim().toLowerCase();
|
web/src/index.css
CHANGED
|
@@ -3087,6 +3087,21 @@ body {
|
|
| 3087 |
.shell-nav-templates {
|
| 3088 |
margin: 0 8px 2px;
|
| 3089 |
width: calc(100% - 16px);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3090 |
}
|
| 3091 |
/* β W33-T75's law reaches this row too. `.shell-nav.is-rail-loading > *` cannot select it β this
|
| 3092 |
button is a SIBLING of `.shell-nav`, not a child β so while `/nav` is in flight it would paint in
|
|
@@ -3097,6 +3112,10 @@ body {
|
|
| 3097 |
margin: 0 6px 2px;
|
| 3098 |
width: calc(100% - 12px);
|
| 3099 |
padding: 7px 10px;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3100 |
}
|
| 3101 |
.shell-side.is-collapsed .shell-side-bottom { padding: 8px 2px 10px; }
|
| 3102 |
.shell-side.is-collapsed .shell-account {
|
|
|
|
| 3087 |
.shell-nav-templates {
|
| 3088 |
margin: 0 8px 2px;
|
| 3089 |
width: calc(100% - 16px);
|
| 3090 |
+
/* ββ WAVE 35 QA β `margin-top: auto` IS WHAT MAKES THE ROW'S OWN `done-when` TRUE. It ships as a
|
| 3091 |
+
SIBLING of `.shell-side-bottom` for a real behavioural reason (that band's `onClickCapture`
|
| 3092 |
+
swallows a collapsed click and expands the rail instead, which a row with no popover must not
|
| 3093 |
+
inherit) β but the band ALSO carries `margin-top: auto`, so the band went to the bottom and
|
| 3094 |
+
this row stayed pinned under Connectors. MEASURED on the deployed build at 1600x1000: the row
|
| 3095 |
+
at y=362, the account button at y=945, a **549 px** gap. The ticket says "the rail's bottom
|
| 3096 |
+
band, directly above the account button" and the owner said "just above our User button".
|
| 3097 |
+
Taking the auto-margin HERE makes this row absorb the free space instead, so it lands against
|
| 3098 |
+
the band's top border and the band's own auto-margin becomes a no-op behind it.
|
| 3099 |
+
β It must survive `.is-rail-hidden`: while `/nav` is in flight this row is `display: none`, the
|
| 3100 |
+
free space falls back to the band's own `margin-top: auto`, and the account row stays pinned.
|
| 3101 |
+
β The gate that was green over this compared SOURCE-STRING INDEXES (`verify_ui.py`:
|
| 3102 |
+
`_shell_code.find("shell-nav-templates")` vs the account marker) β source order cannot see an
|
| 3103 |
+
auto margin, so it read "above" for a row 549 px away. [[gate-pins-a-spelling-not-a-claim]] */
|
| 3104 |
+
margin-top: auto;
|
| 3105 |
}
|
| 3106 |
/* β W33-T75's law reaches this row too. `.shell-nav.is-rail-loading > *` cannot select it β this
|
| 3107 |
button is a SIBLING of `.shell-nav`, not a child β so while `/nav` is in flight it would paint in
|
|
|
|
| 3112 |
margin: 0 6px 2px;
|
| 3113 |
width: calc(100% - 12px);
|
| 3114 |
padding: 7px 10px;
|
| 3115 |
+
/* The collapsed rule RE-SETS the whole margin shorthand, so it re-sets `margin-top` to 0 with it
|
| 3116 |
+
β the fix above would hold expanded and silently revert the moment the rail folded. Restated
|
| 3117 |
+
here rather than reordered, because the shorthand is what carries the collapsed gutters. */
|
| 3118 |
+
margin-top: auto;
|
| 3119 |
}
|
| 3120 |
.shell-side.is-collapsed .shell-side-bottom { padding: 8px 2px 10px; }
|
| 3121 |
.shell-side.is-collapsed .shell-account {
|
web/src/shell/Shell.tsx
CHANGED
|
@@ -924,7 +924,18 @@ function ShellFrame() {
|
|
| 924 |
*/
|
| 925 |
const VIEW_OPEN_RETRY_MS = [0, 250, 700, 1500, 2600, 5000, 9000, 14000, 21000] as const;
|
| 926 |
const openViewInDatabase = useCallback((database: string, viewId: string) => {
|
| 927 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 928 |
viewEmitCancel.current?.();
|
| 929 |
viewEmitCancel.current = retryEmit(
|
| 930 |
() => {
|
|
@@ -2222,7 +2233,20 @@ function ShellFrame() {
|
|
| 2222 |
<button
|
| 2223 |
type="button"
|
| 2224 |
ref={dbButton}
|
| 2225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2226 |
aria-haspopup="dialog"
|
| 2227 |
aria-expanded={!!dbAt}
|
| 2228 |
onMouseEnter={navCollapsed ? tipEnter("Database") : undefined}
|
|
|
|
| 924 |
*/
|
| 925 |
const VIEW_OPEN_RETRY_MS = [0, 250, 700, 1500, 2600, 5000, 9000, 14000, 21000] as const;
|
| 926 |
const openViewInDatabase = useCallback((database: string, viewId: string) => {
|
| 927 |
+
// ββ WAVE 35 QA β THIS WROTE `#/db/${database}` AND NOTHING IN THE APP READS A `db/` PREFIX,
|
| 928 |
+
// SO EVERY STARRED-VIEW TILE ON HOME AND ON STARRED WAS A DEAD CLICK. `routeKeyOf` strips only
|
| 929 |
+
// a leading `#/` (`nav.ts`), so the route became the literal `"db/ut_odoo_invoices"`;
|
| 930 |
+
// `resolveRoute` looks that up against entry keys that are BARE (`"ut_odoo_invoices"`), missed,
|
| 931 |
+
// and the shell fell back to Home β the URL changed, the screen did not. Reproduced on two
|
| 932 |
+
// different views, held for 30 s+, and confirmed structurally: `"db/"` appeared exactly ONCE
|
| 933 |
+
// in `Shell.tsx` + `nav.ts` + `dbFrame.tsx` combined β here, the writer, with no reader
|
| 934 |
+
// anywhere. Every other hash write in this file is `#/${key}` (`:1764`, `:2934`, β¦); this one
|
| 935 |
+
// invented a second vocabulary for the same thing. [[one-question-two-normalizers]]
|
| 936 |
+
// β The failure is silent by construction, which is why no gate saw it: a route that does not
|
| 937 |
+
// resolve is the SAME code path as "no route yet", and that path renders Home on purpose.
|
| 938 |
+
window.location.hash = `#/${database}`;
|
| 939 |
viewEmitCancel.current?.();
|
| 940 |
viewEmitCancel.current = retryEmit(
|
| 941 |
() => {
|
|
|
|
| 2233 |
<button
|
| 2234 |
type="button"
|
| 2235 |
ref={dbButton}
|
| 2236 |
+
// ββ WAVE 35 QA β `dbAt` ALONE IS THE FLYOUT ANCHOR, NOT THE CURRENT SURFACE, AND
|
| 2237 |
+
// THAT MADE W35-T03 FALSE ON THE ONE ROW A PERSON SPENDS MOST OF THEIR DAY IN.
|
| 2238 |
+
// Measured on the deployed build: standing on "Odoo customers", `.shell-nav-db` read
|
| 2239 |
+
// exactly `shell-nav-item shell-nav-db` β every rail row painted identically plain,
|
| 2240 |
+
// nothing marking Database as current. It lit up only while the flyout was manually
|
| 2241 |
+
// REOPENED, which is a click the ticket's "at a glance" does not allow for. Six of the
|
| 2242 |
+
// seven rows in that clause were right; this one was gated on the wrong fact.
|
| 2243 |
+
// β THE FLYOUT'S OWN ROWS ALREADY HAD THE RIGHT ANSWER (`active.key === item.key`,
|
| 2244 |
+
// below) β the button was the only thing in the rail reading a different question.
|
| 2245 |
+
// So this asks the SAME question the rows ask: is the resolved route one of the
|
| 2246 |
+
// databases this flyout lists? [[one-evaluator-per-question]]
|
| 2247 |
+
className={"shell-nav-item shell-nav-db"
|
| 2248 |
+
+ (dbAt || (!!active && dbEntries.some((e) => e.key === active.key))
|
| 2249 |
+
? " is-active" : "")}
|
| 2250 |
aria-haspopup="dialog"
|
| 2251 |
aria-expanded={!!dbAt}
|
| 2252 |
onMouseEnter={navCollapsed ? tipEnter("Database") : undefined}
|