fsanyoto commited on
Commit
9b857f7
Β·
verified Β·
1 Parent(s): 6865fa5

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
This view is limited to 50 files because it contains too many changes. Β  See raw diff
Files changed (50) hide show
  1. RELEASES.json +1 -1
  2. VERSION +1 -1
  3. api/ai_enrich.py +520 -0
  4. api/automation_engine.py +0 -0
  5. api/main.py +0 -0
  6. api/routes_automation.py +0 -0
  7. api/routes_nav.py +237 -10
  8. api/routes_query.py +0 -0
  9. api/routes_tables.py +186 -24
  10. api/web_agent.py +41 -23
  11. platform/aios_grid.py +12 -1
  12. platform/core/registry.py +26 -18
  13. platform/core/user_tables.py +390 -2
  14. web/src/assistant/AssistantPage.tsx +324 -26
  15. web/src/assistant/assistant.css +128 -0
  16. web/src/automation/AutomationBuilder.tsx +0 -0
  17. web/src/automation/AutomationChat.tsx +251 -0
  18. web/src/automation/AutomationDetail.tsx +147 -15
  19. web/src/automation/AutomationFind.tsx +1 -1
  20. web/src/automation/AutomationSurface.tsx +814 -806
  21. web/src/automation/AutomationTrigger.tsx +6 -6
  22. web/src/automation/automation.css +176 -0
  23. web/src/automation/automationApi.ts +100 -3
  24. web/src/automation/steps.ts +10 -0
  25. web/src/customer-grid/ColumnMenu.tsx +334 -6
  26. web/src/customer-grid/CustomerGrid.tsx +66 -17
  27. web/src/customer-grid/RecordDetail.tsx +0 -0
  28. web/src/customer-grid/ViewSidebar.tsx +236 -46
  29. web/src/customer-grid/apiBridge.ts +57 -0
  30. web/src/customer-grid/cells.ts +37 -0
  31. web/src/customer-grid/display.ts +10 -0
  32. web/src/customer-grid/folders.ts +128 -2
  33. web/src/customer-grid/iconShapes.ts +16 -0
  34. web/src/customer-grid/types.ts +63 -1
  35. web/src/filter-kit/FieldsHidePanel.tsx +1 -1
  36. web/src/filter-kit/FilterBuilderPanel.tsx +1003 -988
  37. web/src/filter-kit/ops.ts +400 -400
  38. web/src/inbox/InboxPage.tsx +9 -4
  39. web/src/inbox/inboxModel.ts +13 -2
  40. web/src/index.css +104 -12
  41. web/src/query/QueryPage.tsx +68 -44
  42. web/src/query/query.css +111 -0
  43. web/src/query/queryApi.ts +96 -3
  44. web/src/query/queryParts.tsx +187 -14
  45. web/src/settings/permsModel.ts +8 -0
  46. web/src/shell/LoginPage.tsx +161 -145
  47. web/src/shell/NavExtras.tsx +23 -2
  48. web/src/shell/Shell.tsx +0 -0
  49. web/src/shell/nav.ts +185 -3
  50. web/src/shell/navExtras.css +50 -0
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "7b711f1",
3
  "releases": [
4
  {
5
  "version": "v25",
 
1
  {
2
+ "current": "91ad73a",
3
  "releases": [
4
  {
5
  "version": "v25",
VERSION CHANGED
@@ -1 +1 @@
1
- 7b711f1
 
1
+ 91ad73a
api/ai_enrich.py ADDED
@@ -0,0 +1,520 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """ai_enrich.py -- the AI ENRICHMENT column (wave 34, owner ruling R13).
2
+
3
+ R13: *"A field kind called AI enrichment: a prompt per row that populates text. Detailed
4
+ configuration in the field's own config, including a token-usage limit."*
5
+
6
+ WHERE THE HALVES LIVE, and why the line is drawn here rather than anywhere else:
7
+
8
+ core/user_tables.py the CONTRACT and the STORAGE -- `UT_FIELD_TYPES`, `_clean_ai_enrich`,
9
+ the per-cell provenance stratum, and the law about what an automatic
10
+ run may overwrite. It owns the store document, so it owns the marks.
11
+ api/ai_enrich.py THIS file: the PROMPT vocabulary, the run PLAN, and the token ceiling's
12
+ own report. `core` may not import upward, so anything that needs to
13
+ reach a vendor or an HTTP session lives here.
14
+
15
+ Everything in this module is a PURE FUNCTION of data it is handed. That is deliberate: it is the
16
+ half a gate can exercise without a network, a store, or a running server, which is what makes
17
+ `api_ai_enrich` a real gate rather than a mount check.
18
+
19
+ TOKEN ACCOUNTING, and the hole it is filling. `api/ai_review.py::decide` -- the product's only
20
+ other LLM entry -- has no token accounting of ANY kind: `max_tokens: 300` per call, one 20 s
21
+ timeout, no retry, and nothing anywhere counts what a run spent. R13 asks for a usage limit, so
22
+ there was nothing to build on and the ledger starts here. The two measured community complaints
23
+ about this feature elsewhere are both REPORTING gaps rather than capability gaps (a preview said
24
+ 15 credits and the run spent 360; a finished run gave no completion signal), which is why
25
+ `ceiling_report` exists beside the ceiling itself.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ import re
31
+
32
+ import requests
33
+
34
+ #: A prompt names other columns the way a formula does, so a user who has written one already
35
+ #: knows this syntax. ⚠ ONE parser, exported, because `user_tables.ai_enrich_input_hash` fingerprints
36
+ #: the referenced cells and a second copy of this pattern would disagree with it the first time the
37
+ #: token syntax gained a form ([[one-question-two-normalizers]]).
38
+ _REF = re.compile(r'\{([a-zA-Z0-9_]{1,60})\}')
39
+
40
+ #: What a cell can say about where its value came from. Mirrors
41
+ #: `user_tables.AI_ENRICH_STATES` plus the two states that are DERIVED rather than stored:
42
+ #: `stale` (the inputs moved since the value was written) and `empty` (nothing has run).
43
+ #: β›” Re-exported rather than re-declared: the stored vocabulary has exactly one owner.
44
+ CELL_STATES = ('empty', 'agent', 'human', 'stale', 'error')
45
+
46
+ #: β›” THERE IS DELIBERATELY NO PROVIDER LIST IN THIS FILE. The product already has TWO opinions
47
+ #: about provider order (`harness/analyst.PROVIDERS` says groq-first, `routes_query.
48
+ #: QUERY_PROVIDER_ORDER` says cerebras-first) and `W34-T37` exists to reconcile them. Declaring a
49
+ #: third here would make that ticket harder and this feature wrong in a new way, so the catalogue
50
+ #: is BORROWED from `ai_review` (`PROVIDERS` + `ladder()` + the key names + the cheap-first order
51
+ #: owner ruling R14 set). ⚠ The CALL is ours because the requirements differ: `ai_review` hardcodes
52
+ #: `max_tokens: 300` and reads no `usage`, and R13 needs a per-column ceiling and a real ledger.
53
+ #: One provider catalogue, two call shapes, and the reason is written down rather than inferred.
54
+
55
+ #: The run's per-cell wall clock. Separate from `ai_review.TIMEOUT_SECONDS` because a review is
56
+ #: one classification a person is waiting on and this is a loop over rows.
57
+ TIMEOUT_SECONDS = float(os.environ.get('AIOS_AI_ENRICH_TIMEOUT') or 30)
58
+ #: How many failing rows carry their error into the report before it is summarised. A report that
59
+ #: lists 900 identical `429`s is a report nobody reads.
60
+ MAX_REPORTED_ERRORS = 10
61
+ #: Bound on what one row hands the model, mirroring `ai_review`'s own two caps: a table can hold a
62
+ #: 32 KB JSON cell per row and no prompt needs it.
63
+ MAX_CELL_CHARS = 400
64
+
65
+
66
+ def prompt_refs(prompt):
67
+ """The column keys a prompt names, in first-appearance order, de-duplicated.
68
+
69
+ `"Summarise {name} for {segment}"` -> `['name', 'segment']`.
70
+ """
71
+ seen, out = set(), []
72
+ for key in _REF.findall(str(prompt or '')):
73
+ if key not in seen:
74
+ seen.add(key)
75
+ out.append(key)
76
+ return out
77
+
78
+
79
+ def render_prompt(prompt, row, field_keys=()):
80
+ """`(text, unresolved)` -- the prompt with `{token}` replaced by this row's cells.
81
+
82
+ β›” AN UNKNOWN TOKEN IS REPORTED, NOT SILENTLY BLANKED. A prompt naming a column that does not
83
+ exist is a prompt that will quietly ask a different question on every row, and the resulting
84
+ text looks exactly like a correct answer. `unresolved` is what the caller shows the user;
85
+ R6's standing rule is the same sentence one layer up (a limit that cannot be removed must be
86
+ reported with its cause).
87
+
88
+ ⚠ A token naming a real column whose CELL is empty is NOT unresolved: an empty cell is data.
89
+ """
90
+ known = set(field_keys or ())
91
+ unresolved = []
92
+
93
+ def _sub(match):
94
+ key = match.group(1)
95
+ if known and key not in known:
96
+ unresolved.append(key)
97
+ return match.group(0)
98
+ return str((row or {}).get(key, ''))
99
+
100
+ return _REF.sub(_sub, str(prompt or '')), sorted(set(unresolved))
101
+
102
+
103
+ def ceiling_report(spent, ceiling, rows_done, rows_total):
104
+ """R6's second sentence as DATA, or None while the run is still inside its budget.
105
+
106
+ `{subject, limit, spent, effect, cause, recommendation}` -- the same shape
107
+ `user_tables.limit_report` returns, so the client renders one vocabulary for every limit the
108
+ product enforces rather than a second one for this feature.
109
+
110
+ β›” `effect` is `stopped`, never `truncated`. A run that quietly stopped filling cells leaves a
111
+ column that looks complete and is not, which is the failure the whole reporting rule is
112
+ arranged against. The caller STOPS and says so.
113
+ """
114
+ if ceiling is None or spent < ceiling:
115
+ return None
116
+ left = max(0, int(rows_total or 0) - int(rows_done or 0))
117
+ rest = f'{left:,} rows' if left != 1 else '1 row'
118
+ return {
119
+ 'subject': 'tokens',
120
+ 'limit': int(ceiling),
121
+ 'spent': int(spent),
122
+ 'effect': 'stopped',
123
+ 'cause': f'this run reached its token ceiling of {int(ceiling):,} after filling '
124
+ f'{int(rows_done or 0):,} of {int(rows_total or 0):,} rows',
125
+ 'recommendation': f'raise the column\'s token limit, shorten its prompt, or run the '
126
+ f'remaining {rest} again. Nothing was truncated and no cell holds a '
127
+ f'partial answer',
128
+ }
129
+
130
+
131
+ def plan_rows(rows, col_id, config, marks, cost_of=None):
132
+ """Which rows an automatic run should fill, and why the others were left alone.
133
+
134
+ Returns `{'run': [row_id, ...], 'skipped': {reason: count}, 'estimate': int}`.
135
+
136
+ β›” THE SELECTION IS SEPARATE FROM THE RUN ON PURPOSE. It is pure, so a gate can prove the
137
+ never-overwrite-a-human-edit law without a vendor, and so the client can PREVIEW a run
138
+ honestly before it spends anything -- which is exactly the complaint R13 cites (a preview
139
+ that said 15 credits against a run that spent 360).
140
+
141
+ `cost_of(row)` estimates one row's tokens; absent, the column's own ceiling is used per row,
142
+ which is the pessimistic direction and the right one for a preview.
143
+ """
144
+ import core.user_tables as ut
145
+
146
+ cfg = config if isinstance(config, dict) else {}
147
+ prompt = str(cfg.get('prompt') or '')
148
+ policy = str(cfg.get('overwrite') or 'blank')
149
+ refs = prompt_refs(prompt)
150
+ per_row = int(cfg.get('maxTokens') or 0)
151
+ run, skipped, estimate = [], {}, 0
152
+ for row_id, row in (rows or {}).items():
153
+ row = row if isinstance(row, dict) else {}
154
+ mark = (marks or {}).get(str(row_id)) or {}
155
+ fresh = ut.ai_enrich_input_hash(prompt, row, refs)
156
+ has_value = bool(str(row.get(str(col_id)) or '').strip())
157
+ if ut.ai_enrich_may_write(mark, policy, fresh, has_value=has_value):
158
+ run.append(str(row_id))
159
+ estimate += int(cost_of(row)) if callable(cost_of) else per_row
160
+ else:
161
+ # The same predicate again, so the count a preview shows and the cells a run leaves
162
+ # alone can never be two different sets.
163
+ reason = ('human_edited' if ut.ai_enrich_human_authored(mark, has_value)
164
+ else 'unchanged' if has_value else 'skipped')
165
+ skipped[reason] = skipped.get(reason, 0) + 1
166
+ return {'run': run, 'skipped': skipped, 'estimate': estimate}
167
+
168
+
169
+ def cell_state(mark, config, row, col_id):
170
+ """What ONE cell should say about itself: one of `CELL_STATES`.
171
+
172
+ ⚠ `stale` and `empty` are computed here and never stored -- see the stratum's own note in
173
+ `core/user_tables.py`. A stored staleness flag needs a sweep to stay true, and a sweep nobody
174
+ runs is a flag shipped without its writer.
175
+ """
176
+ import core.user_tables as ut
177
+
178
+ mark = mark if isinstance(mark, dict) else {}
179
+ has_value = bool(str((row or {}).get(str(col_id)) or '').strip())
180
+ if str(mark.get('state') or '') == 'error':
181
+ return 'error'
182
+ # β›” THE SAME PREDICATE THE RUNNER OBEYS, called rather than restated. A cell this reports as
183
+ # the human's while `ai_enrich_may_write` would overwrite it is the worst of both answers.
184
+ if ut.ai_enrich_human_authored(mark, has_value):
185
+ return 'human'
186
+ if str(mark.get('state') or '') != 'agent':
187
+ return 'empty'
188
+ cfg = config if isinstance(config, dict) else {}
189
+ prompt = str(cfg.get('prompt') or '')
190
+ fresh = ut.ai_enrich_input_hash(prompt, row or {}, prompt_refs(prompt))
191
+ return 'stale' if ut.ai_enrich_is_stale(mark, fresh) else 'agent'
192
+
193
+
194
+ # ══════════════════════════════════════════════════════════════════════ THE RUN
195
+ # οΏ½οΏ½οΏ½β›” THE LEDGER IS THE POINT OF THIS HALF. `ai_review.decide` (the product's only other LLM entry)
196
+ # counts nothing: it sends `max_tokens: 300` and never reads `usage` off the response, so no run in
197
+ # this product has ever been able to say what it cost. R13 asks for a token-usage limit, so the
198
+ # accounting starts here, and every leg below reads `usage` even where it is not needed, because a
199
+ # ledger with one blind provider is not a ledger.
200
+
201
+
202
+ def _usage_tokens(body):
203
+ """Total tokens for one call, from whichever shape the provider answered in.
204
+
205
+ ⚠ RETURNS None WHEN THE PROVIDER DID NOT SAY, and the caller treats that as a real unknown
206
+ rather than as zero. A ledger that silently books an unmeasured call at 0 reports a cheaper
207
+ run than happened, which is exactly the cost-surprise complaint R13 cites.
208
+ """
209
+ usage = (body or {}).get('usage')
210
+ if not isinstance(usage, dict):
211
+ return None
212
+ for key in ('total_tokens', 'totalTokens'):
213
+ got = usage.get(key)
214
+ if isinstance(got, int) and not isinstance(got, bool):
215
+ return got
216
+ ins = usage.get('prompt_tokens', usage.get('input_tokens'))
217
+ outs = usage.get('completion_tokens', usage.get('output_tokens'))
218
+ if isinstance(ins, int) and isinstance(outs, int):
219
+ return ins + outs
220
+ return None
221
+
222
+
223
+ #: R6's subject: the em dash (U+2014) and the en dash (U+2013), as a regex character class.
224
+ #: β›” BUILT FROM CODE POINTS, NOT TYPED, AND THIS IS NOT COSMETIC. `web_prose` reads a Python
225
+ #: string's VALUE off the AST, so a class written `'[<em><en>]'` is reported as two findings in
226
+ #: the one function whose entire job is to REMOVE those characters. That is a false positive, and
227
+ #: the wrong fix would be to silence the gate or to weaken the pattern. `chr()` keeps the value
228
+ #: byte-identical while putting no dash in a literal, so the gate sees what it should see and the
229
+ #: normalizer keeps working. ⚠ Lane D hit the same wall in `routes_query` (note D-13).
230
+ _DASH = '[' + chr(0x2014) + chr(0x2013) + ']'
231
+
232
+
233
+ def no_dashes(text):
234
+ """R6 applied to MODEL-AUTHORED text, before it is stored.
235
+
236
+ β›”β›” A PROMPT INSTRUCTION DOES NOT ENFORCE THIS, AND THE MEASUREMENT IS LANE D'S, NOT A GUESS
237
+ (mailbox note D-4, 2026-08-16): a system prompt ending *"Never use an em dash or an en dash"*
238
+ was answered on the very next live turn with *"Which view type would you like-grid, chart,
239
+ list, or another?"* carrying U+2014 (cerebras). `web_prose` scans SOURCE, so it is
240
+ structurally blind to a dash that arrives at runtime.
241
+ ⚠ AND THIS COLUMN IS THE WORST CASE IN THE WAVE, which is why D routed the note here: chat
242
+ prose is read once, but an enrichment value is WRITTEN INTO A CELL, then re-read and
243
+ re-rendered forever. Normalising after the write would never reach the rows already stored.
244
+
245
+ ⚠ A DIGIT RANGE IS A DIFFERENT SENTENCE and gets the first rule: "10-20" means "10 to 20", and
246
+ rewriting it as "10, 20" states two numbers where the model stated a span. That is a wrong
247
+ value, not a punctuation fix.
248
+
249
+ β›”β›” THIS IS THE SECOND IMPLEMENTATION IN THE PRODUCT and that is a known cost, taken
250
+ deliberately rather than by drift. The first is `routes_query._no_dashes` (lane D's, shipped
251
+ first). Importing it would point a LIBRARY module at a ROUTE module, which is backwards, and
252
+ lane D's file is not in this lane's fence to move it. So the two are held in step by a PARITY
253
+ CHECK instead of by hope: `verify_ai_enrich` section 10 imports D's function and asserts both
254
+ agree over a corpus including every shape either docstring names. One shared home is owed and
255
+ is booked as a PENDING line, not left implicit ([[one-question-two-normalizers]]).
256
+ """
257
+ text = str(text or '')
258
+ text = re.sub(rf'(?<=\d)\s*{_DASH}\s*(?=\d)', ' to ', text)
259
+ text = re.sub(rf'\s*{_DASH}\s*(?=[,.;:!?])', '', text) # abutting punctuation: it just goes
260
+ text = re.sub(rf'(?<=[,;:])\s*{_DASH}\s*', ' ', text) # already punctuated: one space
261
+ return re.sub(rf'\s*{_DASH}\s*', ', ', text)
262
+
263
+
264
+ #: What the model is told about its job. It fills ONE cell, so anything conversational it adds is
265
+ #: a defect in the column rather than a nicety. `UNKNOWN` is the honest out: a row the data cannot
266
+ #: answer is recorded as an error against that row, never as an invented value.
267
+ #: ⚠ The dash sentence is here for the same reason lane D kept theirs: it costs nothing and it
268
+ #: reduces how often `no_dashes` has to act. It is NOT the enforcement; `no_dashes` is.
269
+ _SYSTEM = ('You fill in ONE cell of a spreadsheet. Answer with the value only: no preamble, no '
270
+ 'quotes, no markdown, no explanation. Never use an em dash or an en dash; use a comma, '
271
+ 'a colon or a full stop. If the information given does not let you answer, reply with '
272
+ 'exactly: UNKNOWN')
273
+
274
+
275
+ def _ask(provider, model, prompt, max_tokens, timeout):
276
+ """One cell's answer. `(text, tokens_or_None, problem)`; a truthy `problem` means no text.
277
+
278
+ ⚠ Both request shapes are `ai_review`'s, changed in exactly two ways: they carry the COLUMN's
279
+ ceiling instead of a hardcoded 300, and they read `usage` back.
280
+ """
281
+ key = (os.environ.get(provider['env']) or '').strip()
282
+ if not key:
283
+ return '', None, f"{provider['name']} has no key on this deployment"
284
+ try:
285
+ if provider['shape'] == 'anthropic':
286
+ r = requests.post(
287
+ provider['url'], timeout=timeout,
288
+ headers={'x-api-key': key, 'anthropic-version': '2023-06-01',
289
+ 'content-type': 'application/json'},
290
+ json={'model': model, 'max_tokens': max_tokens, 'system': _SYSTEM,
291
+ 'messages': [{'role': 'user', 'content': prompt}]})
292
+ if r.status_code >= 400:
293
+ return '', None, f"{provider['name']} answered {r.status_code}"
294
+ body = r.json()
295
+ # β›” stop_reason FIRST. A safety refusal is a 200 with an EMPTY content list, so
296
+ # reading content[0] before this turns a refusal into an IndexError inside the loop.
297
+ if body.get('stop_reason') == 'refusal':
298
+ return '', _usage_tokens(body), f"{provider['name']} declined this row"
299
+ parts = [b.get('text') or '' for b in (body.get('content') or [])
300
+ if isinstance(b, dict) and b.get('type') == 'text']
301
+ return ''.join(parts).strip(), _usage_tokens(body), ''
302
+ r = requests.post(
303
+ provider['url'], timeout=timeout,
304
+ headers={'Authorization': f"Bearer {key}", 'Content-Type': 'application/json'},
305
+ json={'model': model, 'max_tokens': max_tokens, 'temperature': 0,
306
+ 'messages': [{'role': 'system', 'content': _SYSTEM},
307
+ {'role': 'user', 'content': prompt}]})
308
+ if r.status_code >= 400:
309
+ return '', None, f"{provider['name']} answered {r.status_code}"
310
+ body = r.json()
311
+ choices = body.get('choices') or []
312
+ if not choices:
313
+ return '', _usage_tokens(body), f"{provider['name']} returned no choices"
314
+ text = ((choices[0] or {}).get('message') or {}).get('content') or ''
315
+ return str(text).strip(), _usage_tokens(body), ''
316
+ except Exception as exc: # noqa: BLE001
317
+ return '', None, f"{provider['name']} failed: {type(exc).__name__}"
318
+
319
+
320
+ def on_change_fields(defn, changed_keys):
321
+ """Which `ai_enrich` columns in this table want a run because one of their inputs moved.
322
+
323
+ A column qualifies when its trigger is `on_change` AND its prompt names at least one of the
324
+ columns that just changed. β›” A column that names NOTHING (a prompt with no `{token}`) never
325
+ fires on change, whatever its trigger says: it would re-ask the same question of the same row
326
+ forever, once per unrelated edit, and bill for every one.
327
+
328
+ ⚠ PURE, AND HANDED THE DEFINITION IT SHOULD USE. `automation_engine.grid_hook` calls
329
+ `all_definitions(st)` once PER EVENT, which turns a 20,000-row import into 20,000 whole
330
+ document reads (`D-134`); this function reads no store at all, so the caller can do one read
331
+ for a whole batch. Rebuilding that shape here was the one thing T54's ticket said not to do.
332
+ """
333
+ import core.user_tables as ut
334
+
335
+ touched = {str(k) for k in (changed_keys or ())}
336
+ if not touched:
337
+ return []
338
+ out = []
339
+ for field in ut.ai_enrich_fields(defn):
340
+ cfg = field.get('aiEnrich') or {}
341
+ if (cfg.get('trigger') or {}).get('mode') != 'on_change':
342
+ continue
343
+ refs = set(prompt_refs(cfg.get('prompt')))
344
+ # ⚠ A column never fires on a change to ITSELF. Its own value moving is either the run
345
+ # that just wrote it or a person typing over it, and both would loop.
346
+ refs.discard(str(field.get('key') or ''))
347
+ if refs & touched:
348
+ out.append(field)
349
+ return out
350
+
351
+
352
+ def scheduled_fields(defn):
353
+ """`[(field, cron)]` for every enrichment column that runs on a cadence.
354
+
355
+ β›” IT DOES NOT PARSE THE CRON AND MUST NOT. `core.user_tables` stores that string opaque
356
+ precisely so the cadence vocabulary has ONE owner, and the owner is the scheduler
357
+ (`automation_engine`), not the field layer. A second parser here would be a door that starts
358
+ refusing a cadence the other one accepts, which is exactly the divergence the whole field
359
+ contract keeps paying for ([[one-question-two-normalizers]]).
360
+
361
+ ⚠ SO THIS IS HALF A FEATURE ON PURPOSE, and the other half is a cross-fence ask: something has
362
+ to TICK. Nothing in lane F's fence runs on a timer.
363
+ """
364
+ import core.user_tables as ut
365
+
366
+ out = []
367
+ for field in ut.ai_enrich_fields(defn):
368
+ trigger = ((field.get('aiEnrich') or {}).get('trigger') or {})
369
+ if trigger.get('mode') == 'schedule' and str(trigger.get('cron') or '').strip():
370
+ out.append((field, str(trigger['cron'])))
371
+ return out
372
+
373
+
374
+ def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, budget=None,
375
+ timeout=None, ask=None):
376
+ """Fill one `ai_enrich` column over the rows an automatic run is allowed to touch.
377
+
378
+ Returns a REPORT and never raises for a vendor problem:
379
+ `{planned, filled, failed, skipped{reason: count}, tokens, provider, model,
380
+ limit: <ceiling_report or None>, errors[], problem}`
381
+
382
+ β›” THE RUN BUDGET IS DERIVED FROM THE COLUMN'S OWN CEILING, and the arithmetic is the point:
383
+ `budget = maxTokens * planned`. `maxTokens` bounds the OUTPUT of one call while spend counts
384
+ INPUT tokens too, so a real run crosses this before it finishes every row. That is intended:
385
+ R13 asked for a token-usage limit and a ceiling that can never bite is not one. A caller may
386
+ pass a TIGHTER `budget`; nothing may pass a looser one.
387
+
388
+ β›” TWO STORE WRITES FOR THE WHOLE RUN, not two per row: one `patch_many_cells` for the values
389
+ and one `stamp_ai_enrich` for the marks. Each is a read-modify-write of the whole tenant
390
+ document, which is why `patch_cells` in a loop was never an option (D-134's shape).
391
+
392
+ ⚠ `ask` is injectable so a gate can exercise the ledger, the ceiling and every failure path
393
+ with no network and no vendor bill. It defaults to the real `_ask`.
394
+ """
395
+ import ai_review
396
+ import core.user_tables as ut
397
+
398
+ defn = ut.get(table_key, st=st) or {}
399
+ field = next((f for f in ut.ai_enrich_fields(defn) if f.get('key') == str(col_id)), None)
400
+ if field is None:
401
+ return {'problem': f'{col_id!r} is not an AI enrichment column on this database',
402
+ 'planned': 0, 'filled': 0, 'failed': 0, 'skipped': {}, 'tokens': 0,
403
+ 'errors': [], 'limit': None, 'provider': '', 'model': ''}
404
+ cfg = field.get('aiEnrich') or {}
405
+ # ⭐ THE BULK MENU'S TWO CHOICES ARE THIS ONE OVERRIDE (`W34-T54`): "Rows never filled" is
406
+ # `blank` and "All rows" is `always`. β›” It overrides the column's SAVED POLICY for one run and
407
+ # NOTHING ELSE: `ai_enrich_may_write` still refuses a human-edited cell under every value, so
408
+ # "All rows" means every row the AGENT wrote, which is exactly what the menu label says.
409
+ # ⚠ An unrecognised value falls back to the column's own setting rather than to a default: a
410
+ # typo'd override must not quietly widen what a run touches.
411
+ if policy in ut.AI_ENRICH_OVERWRITE:
412
+ cfg = {**cfg, 'overwrite': policy}
413
+ all_rows = defn.get('rows') or {}
414
+ marks = ut.ai_enrich_marks(table_key, col_id, st=st)
415
+ # β›”β›” `rows` NARROWS; `manual` DECIDES WHETHER THE POLICY APPLIES. They are two questions and
416
+ # an early draft of this function conflated them, which would have let an ON-CHANGE fire (an
417
+ # automatic act, scoped to one row) overwrite a cell a person had typed into. That is the one
418
+ # law this whole feature is arranged around, broken by a parameter name.
419
+ # rows=None -> the automatic plan over every row
420
+ # rows=[...], manual=False -> the automatic plan, INTERSECTED with those rows (on_change)
421
+ # rows=[...], manual=True -> exactly those rows, policy skipped
422
+ # `manual` is skippable because a person clicking "run this cell" has asked, in front of the
423
+ # value being replaced; refusing them would be the product overruling an explicit act with a
424
+ # rule written for an implicit one. `ai_enrich_may_write`'s own docstring says exactly this.
425
+ # ⚠ A named row is always INTERSECTED with the table's own row set, so a stale caller cannot
426
+ # name a record that has since been deleted.
427
+ named = None if rows is None else [str(r) for r in rows if str(r) in all_rows]
428
+ if named is not None and manual:
429
+ plan = {'run': named, 'skipped': {},
430
+ 'estimate': int(cfg.get('maxTokens') or 0) * len(named)}
431
+ else:
432
+ plan = plan_rows(all_rows, col_id, cfg, marks)
433
+ if named is not None:
434
+ keep = set(named)
435
+ dropped = [r for r in plan['run'] if r not in keep]
436
+ plan = {**plan, 'run': [r for r in plan['run'] if r in keep]}
437
+ if dropped:
438
+ plan['skipped'] = {**plan['skipped'], 'not_in_this_run': len(dropped)}
439
+ rows = all_rows
440
+ keys = [f.get('key') for f in (defn.get('fields') or []) if f.get('key')]
441
+ report = {'planned': len(plan['run']), 'filled': 0, 'failed': 0,
442
+ 'skipped': dict(plan['skipped']), 'tokens': 0, 'errors': [], 'limit': None,
443
+ 'provider': '', 'model': '', 'problem': ''}
444
+ if not plan['run']:
445
+ return report
446
+
447
+ caller = ask or _ask
448
+ ceiling = int(cfg.get('maxTokens') or 0) * len(plan['run'])
449
+ if isinstance(budget, int) and not isinstance(budget, bool):
450
+ ceiling = min(ceiling, max(0, budget))
451
+ live = ai_review.ladder()
452
+ if not live and ask is None:
453
+ # β›” FAIL CLOSED AND SAY SO. A run that quietly filled nothing looks identical to a run
454
+ # with nothing to do; `planned` above is the number that distinguishes them.
455
+ report['problem'] = 'no AI provider is configured on this deployment'
456
+ return report
457
+ provider = (live or [{'name': 'injected', 'model': 'injected', 'env': '',
458
+ 'shape': 'openai', 'url': ''}])[0]
459
+ model = str(cfg.get('model') or provider['model'])
460
+ report['provider'], report['model'] = provider['name'], model
461
+ tmo = float(timeout or TIMEOUT_SECONDS)
462
+
463
+ values, stamps = {}, {}
464
+ for row_id in plan['run']:
465
+ if ceiling and report['tokens'] >= ceiling:
466
+ # β›” STOP AND REPORT (R6's second sentence). Cells already written STAY written and
467
+ # carry honest marks; nothing is truncated and no cell holds half an answer.
468
+ report['limit'] = ceiling_report(report['tokens'], ceiling,
469
+ report['filled'], report['planned'])
470
+ left = report['planned'] - report['filled'] - report['failed']
471
+ if left > 0:
472
+ report['skipped']['over_budget'] = left
473
+ break
474
+ row = rows.get(row_id) or {}
475
+ text, unresolved = render_prompt(cfg.get('prompt'), row, keys)
476
+ if unresolved:
477
+ # Reported per row rather than refused up front: the prompt is valid for the column,
478
+ # and naming the missing token is what lets somebody fix it.
479
+ why = ('the prompt names ' + ', '.join(unresolved)
480
+ + ', which is not a column on this database')
481
+ report['failed'] += 1
482
+ stamps[row_id] = {'state': 'error', 'error': why}
483
+ if len(report['errors']) < MAX_REPORTED_ERRORS:
484
+ report['errors'].append({'row': row_id, 'error': why})
485
+ continue
486
+ answer, tokens, problem = caller(provider, model, text[:MAX_CELL_CHARS * 8],
487
+ int(cfg.get('maxTokens') or 300), tmo)
488
+ if isinstance(tokens, int) and not isinstance(tokens, bool):
489
+ report['tokens'] += tokens
490
+ if problem or not answer or answer.strip().upper() == 'UNKNOWN':
491
+ # β›” THE CELL IS LEFT ALONE. An errored row keeps whatever it held; only the MARK
492
+ # changes, so a failed run never destroys a value it could not replace.
493
+ why = problem or ('the model had nothing to answer from' if not answer
494
+ else 'the model answered UNKNOWN for this row')
495
+ report['failed'] += 1
496
+ stamps[row_id] = {'state': 'error', 'error': why, 'model': model}
497
+ if len(report['errors']) < MAX_REPORTED_ERRORS:
498
+ report['errors'].append({'row': row_id, 'error': why})
499
+ continue
500
+ # β›” NORMALISED BEFORE THE WRITE, NOT AFTER (note D-6). This is the ONE line where model
501
+ # prose becomes stored product data; a cell fixed after the fact leaves every row already
502
+ # written carrying the dash, and `web_prose` can never see it because it scans source.
503
+ values[row_id] = no_dashes(answer)[:MAX_CELL_CHARS]
504
+ stamps[row_id] = {'state': 'agent', 'model': model,
505
+ 'hash': ut.ai_enrich_input_hash(cfg.get('prompt'), row,
506
+ prompt_refs(cfg.get('prompt'))),
507
+ 'tokens': tokens if isinstance(tokens, int) else 0}
508
+ report['filled'] += 1
509
+
510
+ if values:
511
+ ut.patch_many_cells(table_key, {r: {str(col_id): v} for r, v in values.items()}, st=st)
512
+ if stamps:
513
+ ut.stamp_ai_enrich(table_key, col_id, stamps, st=st)
514
+ # ⚠ The ceiling is re-asked AFTER the loop too: a run whose LAST row crossed the budget spent
515
+ # over it without re-entering the guard, and staying silent there would under-report the one
516
+ # run most likely to surprise somebody.
517
+ if report['limit'] is None and ceiling and report['tokens'] >= ceiling:
518
+ report['limit'] = ceiling_report(report['tokens'], ceiling,
519
+ report['filled'], report['planned'])
520
+ return report
api/automation_engine.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/main.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_automation.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_nav.py CHANGED
@@ -248,6 +248,191 @@ def _read_recents(runtime, uname, allowed):
248
  return out[:_MAX_RECENTS]
249
 
250
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
  @router.get("/nav")
252
  def nav(session: Session = Depends(require_session)):
253
  """`{pages: [{key,label,source?,chrome}], landing}`.
@@ -336,7 +521,7 @@ def nav(session: Session = Depends(require_session)):
336
  # empty rather than leaving 200-[] ambiguous.
337
  return {"pages": [], "landing": None, "empty": "no_databases"}
338
  raise err(403, "no_surfaces",
339
- "your account has no dashboards assigned β€” ask an administrator")
340
  # WAVE 19 (R8 / C1): the tenant's name + icon overrides, merged LAST β€” after the registry
341
  # rows, after the tenant module filter, after the user tables. Merged HERE rather than
342
  # applied by the client for one reason: `label` is what every reader of this payload shows,
@@ -350,6 +535,38 @@ def nav(session: Session = Depends(require_session)):
350
  # leak with no symptom until two tenants rename the same registry key. Copy the rows before
351
  # merging on the day that invariant changes.
352
  meta = _read_nav_meta(session.runtime)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
353
  # Wave 21 (C3): the definitions, once β€” `manage`/`canDelete` below answer from `createdBy`.
354
  # WAVE 27 (C9): `locked` answers from `recordMode`, off the same one read.
355
  # ⭐ W31-T10: that read is now the SAME one the merge above did β€” it used to be a second,
@@ -398,6 +615,13 @@ def nav(session: Session = Depends(require_session)):
398
  p["locked"] = True
399
  elif session.admin:
400
  p["manage"] = True
 
 
 
 
 
 
 
401
  entry = meta.get(p.get("key")) if meta else None
402
  if not entry:
403
  continue
@@ -422,8 +646,11 @@ def nav(session: Session = Depends(require_session)):
422
  # both doors finally agree on what a placeable key is. This block keeps using the assembled
423
  # list because it already holds it: re-enumerating here would be a second store read for an
424
  # answer sitting in a local variable.
425
- recents = _read_recents(session.runtime, session.uname,
426
- {str(p.get("key", "")) for p in pages})
 
 
 
427
  # ⭐ W31-T11 β€” TWO KINDS OF ABSENCE, NAMED SEPARATELY, and both keys are ALWAYS PRESENT.
428
  # `omitted` β€” this workspace's catalogue does not include these modules. Deliberate.
429
  # `degraded` β€” a part of this payload could not be read. NOT deliberate, and the shell says
@@ -460,7 +687,7 @@ def nav_opened(body: dict = Body(default=None),
460
  raise err(400, "bad_request", "no page was named")
461
  if not session.runtime.available():
462
  raise err(503, "store_unavailable",
463
- "the tenant store is unavailable β€” nothing was recorded")
464
  stamp, uname = _now(), session.uname
465
 
466
  def _up(data):
@@ -484,7 +711,7 @@ def nav_opened(body: dict = Body(default=None),
484
  session.runtime.update(_NAV_RECENTS_KEY, _up, flush='async')
485
  except Exception:
486
  raise err(503, "store_unavailable",
487
- "the tenant store refused the write β€” nothing was recorded")
488
  return {"key": key, "at": stamp}
489
 
490
 
@@ -549,7 +776,7 @@ def save_nav_meta(body: dict = Body(default=None),
549
  if "name" in body:
550
  if not key.startswith("ut_"):
551
  raise err(400, "name_not_allowed",
552
- "only a database you created can be renamed β€” this one's name comes "
553
  "from the module registry")
554
  name = " ".join(str(body.get("name") or "").split())[:_MAX_NAV_NAME]
555
  if not name:
@@ -560,7 +787,7 @@ def save_nav_meta(body: dict = Body(default=None),
560
 
561
  if not session.runtime.available():
562
  raise err(503, "store_unavailable",
563
- "the tenant store is unavailable β€” nothing was saved")
564
 
565
  def _up(data):
566
  data = data if isinstance(data, dict) else {}
@@ -583,7 +810,7 @@ def save_nav_meta(body: dict = Body(default=None),
583
  session.runtime.update(_NAV_META_KEY, _up)
584
  except Exception:
585
  raise err(503, "store_unavailable",
586
- "the change was not saved β€” the store refused the write")
587
  return {"key": key, "meta": _read_nav_meta(session.runtime).get(key, {})}
588
 
589
 
@@ -608,7 +835,7 @@ def save_nav_prefs(body: dict = Body(default=None),
608
  clean = _clean_nav_prefs(body or {}, keys, keep_unknown_ut=not enumerated)
609
  if not session.runtime.available():
610
  raise err(503, "store_unavailable",
611
- "the tenant store is unavailable β€” nothing was saved")
612
 
613
  def _up(data):
614
  data = data if isinstance(data, dict) else {}
@@ -622,7 +849,7 @@ def save_nav_prefs(body: dict = Body(default=None),
622
  session.runtime.update(_NAV_PREFS_KEY, _up)
623
  except Exception:
624
  raise err(503, "store_unavailable",
625
- "the folder change was not saved β€” the store refused the write")
626
  return {"prefs": clean}
627
 
628
 
 
248
  return out[:_MAX_RECENTS]
249
 
250
 
251
+ # ── W34-T10 (ruling R1, contract C1): the "mark important" counts, per database ──────────────
252
+
253
+ #: A wall-clock ceiling on the WHOLE counting block, checked between databases.
254
+ #:
255
+ #: β›” IT IS A COLD-START BOUND, NOT A PERFORMANCE BUDGET, and the measurement is the reason it
256
+ #: exists at all. Every database's views live in its OWN store bucket (`<key>_table_workspace`),
257
+ #: never in the `user_tables` document this route already holds β€” so this block is `N` reads on a
258
+ #: route D-175 spent a wave reducing to one. What makes it affordable is that those buckets are
259
+ #: TINY. Measured on tenant #0, 2026-08-16: twelve of them cost **6.2 ms WARM in total**, and the
260
+ #: largest (`customer_data`) is 45,035 bytes against the 28.6 MB `user_tables` document; ten of the
261
+ #: twelve are under 1 KB. COLD is the other half of the truth: the same twelve cost 7,331 ms of
262
+ #: first fetch. So this budget bounds the FIRST request after a container starts, and a database it
263
+ #: does not reach is reported through `degraded` rather than quietly carrying no number.
264
+ #:
265
+ #: ⚠ THE NUMBER IS CHOSEN AGAINST THE CLIENT'S DEADLINE, NOT AGAINST A FEELING. `nav.ts`'s
266
+ #: `NAV_TIMEOUT_MS` is 20 s and a cold `/nav` already spends most of that on the `user_tables`
267
+ #: download; letting this block run unbounded (measured: 7,331 ms for twelve cold buckets) would
268
+ #: convert a slow success into a manufactured failure, which is the exact mistake that deadline's
269
+ #: own comment warns about. 2.5 s is a bound the route can afford to lose.
270
+ _IMPORTANT_BUDGET_S = 2.5
271
+
272
+ #: Suffix `core.view_templates.workspace_key` appends. Stripping it is how a page key becomes the
273
+ #: TOPIC key the cohort bucket is named from β€” `customer_data` -> `customer_table_workspace` ->
274
+ #: `customer` -> `customer_cohorts`. Derived rather than re-listed on purpose: `_WS_KEYS` is
275
+ #: already the one place `customer_data`/`product_data` are mapped to their topic, and a second
276
+ #: copy here would be free to drift from it ([[one-question-two-normalizers]]).
277
+ _WS_SUFFIX = "_table_workspace"
278
+
279
+
280
+ def _visible_views(doc, uname, is_admin, granted_ids):
281
+ """Every view on ONE database that THIS caller can see, from an already-read workspace doc.
282
+
283
+ β›” PER-CALLER, NOT TENANT-WIDE, and this is the half a server-side count is most likely to get
284
+ wrong. `<key>_table_workspace` is `{username: {views, fields, overlays}, '__shared__': {...}}`
285
+ β€” one home per view, never both β€” so "how many views are marked important here" has a
286
+ DIFFERENT answer for every account. Measured on tenant #0: `leadership` has one marked view and
287
+ `admin` has none, on the same database. A tenant-wide count would put a number in the owner's
288
+ rail that no view sidebar they can open would ever add up to.
289
+
290
+ ⚠ `table_store._may_see` is imported rather than re-expressed. It is private, and reaching for
291
+ it is still the right call: the alternative is `TableOps.shared_views`, which re-reads the whole
292
+ bucket per database (the N whole reads this block exists to avoid), and the only other option is
293
+ a second copy of a permission predicate. A wrong copy of `_may_see` widens what a user is told
294
+ exists; a private import cannot.
295
+ """
296
+ import core.table_store as table_store
297
+ out = {}
298
+ for vid, view in ((doc.get(uname) or {}).get("views") or {}).items():
299
+ if isinstance(view, dict):
300
+ out[str(vid)] = view
301
+ for vid, view in ((doc.get(table_store.SHARED_KEY) or {}).get("views") or {}).items():
302
+ if isinstance(view, dict) and table_store._may_see(view, uname, is_admin):
303
+ out.setdefault(str(vid), view)
304
+ # The wave-21 named-user grants. The ids come from ONE tenant-wide bucket read once for the
305
+ # whole request; the RECORD is already in hand, in whichever stratum its owner keeps it.
306
+ if granted_ids:
307
+ for stratum, blob in doc.items():
308
+ if stratum == uname or not isinstance(blob, dict):
309
+ continue
310
+ for vid, view in (blob.get("views") or {}).items():
311
+ if str(vid) in granted_ids and isinstance(view, dict):
312
+ out.setdefault(str(vid), view)
313
+ return out
314
+
315
+
316
+ def _view_record_count(cfg, cohorts):
317
+ """How many RECORDS this view resolves to, or None when that cannot be answered for free.
318
+
319
+ β›” `None` IS AN ANSWER AND IT IS THE IMPORTANT ONE. Two of the three shapes below are exact
320
+ because the view CARRIES its row set; the third β€” an ordinary filtered view β€” can only be
321
+ counted by running its filters over the records, and the records are the one thing this route
322
+ must never read (`D-175`/`D-185`: `/nav` is a rows-free projection, and reaching for `rows`
323
+ here raises by that projection's own contract). So a filtered view is reported as UNCOUNTED and
324
+ the database's `partial` flag says so, which is the whole of R6's second sentence applied to a
325
+ badge: a limit that cannot be removed is REPORTED with its cause, never papered over with a
326
+ number that is short by an unknown amount.
327
+
328
+ ⚠ THIS IS ALSO WHY THE SERVER DOES NOT SIMPLY MIRROR THE CLIENT. `CustomerGrid::alertCounts`
329
+ counts a filtered view fine and gives up on a SERVER-WINDOWED one (`D-205`'s `Important 0+`);
330
+ this end is the exact inverse β€” it has no rows at all and no window either. The two are honest
331
+ about different halves, which is why `partial` had to be on the wire rather than derived.
332
+ """
333
+ if not isinstance(cfg, dict):
334
+ return None
335
+ # A cohort-locked view IS its cohort: the lock and the id are the same fact (`grid_events`
336
+ # re-stamps it on every write), and a cohort's membership is a stored pid LIST, not a query.
337
+ lock = str(cfg.get("cohortLock") or "").strip()
338
+ if lock:
339
+ n = cohorts.get(lock)
340
+ return int(n) if isinstance(n, int) else None
341
+ # A curated row set carries its own count.
342
+ pids = cfg.get("memberPids")
343
+ if isinstance(pids, list) and pids:
344
+ return len(pids)
345
+ return None
346
+
347
+
348
+ def _important_counts(session, keys):
349
+ """`({key: {marked, counted, partial}}, unread)` for the databases in `keys`.
350
+
351
+ `marked` = views this caller can see on that database whose `config.important is True`.
352
+ `counted` = the SUM of those views' record counts β€” a record matching two marked views
353
+ contributes twice, because that is what the badge the owner is moving has always
354
+ meant (`CustomerGrid::importantTotal`: *"a sum of per-view counts, which is what
355
+ was asked"*), and a distinct-record total would disagree with the per-view numbers
356
+ a user can read off the sidebar and add up themselves.
357
+ `partial` = at least one marked view could not be counted.
358
+
359
+ `unread` is the set of keys whose bucket did not answer β€” a store blip or the budget above.
360
+ They are reported through `degraded`, never as a confident zero.
361
+ """
362
+ import core.shares as shares
363
+ import core.view_templates as view_templates
364
+ import modules.cohort as cohort_mod
365
+
366
+ uname, is_admin = session.uname, session.admin
367
+ out, unread = {}, set()
368
+ try:
369
+ # ⚠ THE ROLE FILTER IS NOT BELT-AND-BRACES. `shared_with` answers "is there an entry naming
370
+ # me", and `grid_events._granted_views` β€” the reader whose answer this badge has to agree
371
+ # with β€” then requires `role_for(...) in ('view','edit')`. Dropping that second test would
372
+ # count a view the sidebar does not list, i.e. a badge one higher than anything a person can
373
+ # add up. One tenant-wide bucket, read once and cached, so it costs a dict lookup per id.
374
+ granted = {str(v) for v in
375
+ ((shares.shared_with(uname, kind="view", st=session.runtime) or {})
376
+ .get("view") or [])
377
+ if shares.role_for("view", str(v), uname,
378
+ st=session.runtime) in ("view", "edit")}
379
+ except Exception:
380
+ granted = set() # no grants is the fail-closed answer: a narrower count, never wider
381
+ cohort_cache = {}
382
+ started = time.perf_counter()
383
+ for key in keys:
384
+ if key in out or key in unread:
385
+ continue # the caller's order may repeat a key; a repeat must not respend
386
+ ws_key = view_templates.workspace_key(key)
387
+ if not ws_key:
388
+ continue # not a table at all (a module surface, a folder head)
389
+ if time.perf_counter() - started > _IMPORTANT_BUDGET_S:
390
+ unread.add(key)
391
+ continue
392
+ try:
393
+ # β›” PROJECTED, and the two dropped keys are the whole reason this is affordable.
394
+ # `overlays` is per-record field values and `fields` is the schema stratum; neither
395
+ # says anything about a view. On `customer_data` they are most of the bucket.
396
+ doc = session.runtime.get_projection(ws_key, drop=("overlays", "fields")) or {}
397
+ except Exception:
398
+ unread.add(key)
399
+ continue
400
+ marked = [v for v in _visible_views(doc, uname, is_admin, granted).values()
401
+ if ((v.get("config") or {}).get("important") is True)]
402
+ if not marked:
403
+ out[key] = {"marked": 0, "counted": 0, "partial": False}
404
+ continue
405
+ scope = ws_key[:-len(_WS_SUFFIX)] if ws_key.endswith(_WS_SUFFIX) else key
406
+ if scope not in cohort_cache:
407
+ try:
408
+ # ⚠ Read through `session.runtime`, NOT `modules.cohort`'s own module-level
409
+ # helpers: those call `core.store` directly, so they carry no tenant namespace.
410
+ # The MODULE is asked for the bucket NAME (it owns that rule) and this route does
411
+ # the reading, which is the only tenant-correct combination.
412
+ bucket = session.runtime.get(cohort_mod.key_for(scope)) or {}
413
+ # β›” THIS CALLER'S OWN COHORTS ONLY, and the consequence is deliberate: a SHARED
414
+ # view locked to a cohort somebody else owns finds no id here, so it is reported
415
+ # UNCOUNTED (`partial`) rather than counted from a stratum this session cannot
416
+ # see. Widening the read to every user's cohorts would make the badge disclose the
417
+ # SIZE of another person's private list, which is a leak wearing a bug fix.
418
+ mine = bucket.get(uname) or {}
419
+ cohort_cache[scope] = {
420
+ str(cid): len(c.get("members") or [])
421
+ for cid, c in mine.items() if isinstance(c, dict)
422
+ }
423
+ except Exception:
424
+ cohort_cache[scope] = {}
425
+ counted, partial = 0, False
426
+ for view in marked:
427
+ n = _view_record_count(view.get("config"), cohort_cache[scope])
428
+ if n is None:
429
+ partial = True
430
+ else:
431
+ counted += n
432
+ out[key] = {"marked": len(marked), "counted": counted, "partial": partial}
433
+ return out, unread
434
+
435
+
436
  @router.get("/nav")
437
  def nav(session: Session = Depends(require_session)):
438
  """`{pages: [{key,label,source?,chrome}], landing}`.
 
521
  # empty rather than leaving 200-[] ambiguous.
522
  return {"pages": [], "landing": None, "empty": "no_databases"}
523
  raise err(403, "no_surfaces",
524
+ "your account has no dashboards assigned. Ask an administrator.")
525
  # WAVE 19 (R8 / C1): the tenant's name + icon overrides, merged LAST β€” after the registry
526
  # rows, after the tenant module filter, after the user tables. Merged HERE rather than
527
  # applied by the client for one reason: `label` is what every reader of this payload shows,
 
535
  # leak with no symptom until two tenants rename the same registry key. Copy the rows before
536
  # merging on the day that invariant changes.
537
  meta = _read_nav_meta(session.runtime)
538
+ # ⭐⭐ W34-T10 (ruling R1, contract C1) β€” THE MARK-IMPORTANT NUMBERS, COMPUTED ONCE.
539
+ #
540
+ # β›” COMPUTED HERE RATHER THAN INSIDE THE LOOP BELOW, AND THAT PLACEMENT IS THE SAFETY
541
+ # ARGUMENT, not tidiness. The `for p in pages:` loop sits OUTSIDE the try/except that wraps
542
+ # the `all_defs()` read, so anything raising inside it takes the whole nav down for every
543
+ # user β€” a 500 where the store-blip path is careful to answer 200 with `degraded`. One call,
544
+ # one guard, and the loop stays a dict lookup.
545
+ #
546
+ # β›”β›” AND THE ORDER IS LOAD-BEARING, WHICH IS THE ONE THING THE FIRST DRAFT GOT WRONG.
547
+ # A budget spent in whatever order the pages happen to arrive is a lottery: measured cold on
548
+ # tenant #0, the block spent all 1.5 s of its first draft on ten EMPTY Odoo buckets and was cut
549
+ # off two rows before `customer_data`, which holds the only marked view in the tenant. The
550
+ # feature would have shipped, been correct, and shown nothing on a cold container. Counting in
551
+ # the user's own RECENTS order first fixes that with a fact this route already holds: a mark
552
+ # lives on a database somebody works in, and `nav_recents` is exactly the list of those,
553
+ # newest first.
554
+ _page_keys = {str(p.get("key", "")) for p in pages}
555
+ recents = _read_recents(session.runtime, session.uname, _page_keys)
556
+ _recent_first = [r["key"] for r in recents]
557
+ _seen_first = set(_recent_first)
558
+ _recent_first += [k for k in (str(p.get("key", "")) for p in pages)
559
+ if k not in _seen_first]
560
+ _important, _imp_unread = {}, set()
561
+ try:
562
+ _important, _imp_unread = _important_counts(session, _recent_first)
563
+ except Exception:
564
+ _imp_unread = set(_page_keys)
565
+ if _imp_unread:
566
+ # The SAME honest-absence channel W31-T11 built for the `ut_*` merge. A database whose
567
+ # count could not be read must not be indistinguishable from one with nothing marked:
568
+ # both would render as no badge, and only one of them is true.
569
+ _degraded.append("important")
570
  # Wave 21 (C3): the definitions, once β€” `manage`/`canDelete` below answer from `createdBy`.
571
  # WAVE 27 (C9): `locked` answers from `recordMode`, off the same one read.
572
  # ⭐ W31-T10: that read is now the SAME one the merge above did β€” it used to be a second,
 
615
  p["locked"] = True
616
  elif session.admin:
617
  p["manage"] = True
618
+ # ⭐ W34-T10 / C1 β€” always emitted for a database this route could READ, including when
619
+ # nothing is marked (`{marked: 0, counted: 0, partial: false}`). That is the same rule
620
+ # `omitted`/`degraded` follow at the bottom of this function and for the same reason: a key
621
+ # a consumer has to test for is a key a consumer forgets to test for. ABSENT here means
622
+ # "not a database, or we could not read it" β€” the second case is named in `degraded`.
623
+ if key in _important:
624
+ p["important"] = _important[key]
625
  entry = meta.get(p.get("key")) if meta else None
626
  if not entry:
627
  continue
 
646
  # both doors finally agree on what a placeable key is. This block keeps using the assembled
647
  # list because it already holds it: re-enumerating here would be a second store read for an
648
  # answer sitting in a local variable.
649
+ #
650
+ # ⚠ W34-T10 MOVED THE READ, NOT THE RULE. `recents` is now computed ABOVE the enrichment loop,
651
+ # because the important-count block spends its budget in RECENTS ORDER (see there). It is still
652
+ # ONE read of `nav_recents`, still pruned against the assembled page list, and it is used here
653
+ # unchanged β€” a second `_read_recents` call would be the extra store read this comment forbids.
654
  # ⭐ W31-T11 β€” TWO KINDS OF ABSENCE, NAMED SEPARATELY, and both keys are ALWAYS PRESENT.
655
  # `omitted` β€” this workspace's catalogue does not include these modules. Deliberate.
656
  # `degraded` β€” a part of this payload could not be read. NOT deliberate, and the shell says
 
687
  raise err(400, "bad_request", "no page was named")
688
  if not session.runtime.available():
689
  raise err(503, "store_unavailable",
690
+ "the tenant store is unavailable. Nothing was recorded.")
691
  stamp, uname = _now(), session.uname
692
 
693
  def _up(data):
 
711
  session.runtime.update(_NAV_RECENTS_KEY, _up, flush='async')
712
  except Exception:
713
  raise err(503, "store_unavailable",
714
+ "the tenant store refused the write. Nothing was recorded.")
715
  return {"key": key, "at": stamp}
716
 
717
 
 
776
  if "name" in body:
777
  if not key.startswith("ut_"):
778
  raise err(400, "name_not_allowed",
779
+ "only a database you created can be renamed. This one's name comes "
780
  "from the module registry")
781
  name = " ".join(str(body.get("name") or "").split())[:_MAX_NAV_NAME]
782
  if not name:
 
787
 
788
  if not session.runtime.available():
789
  raise err(503, "store_unavailable",
790
+ "the tenant store is unavailable. Nothing was saved.")
791
 
792
  def _up(data):
793
  data = data if isinstance(data, dict) else {}
 
810
  session.runtime.update(_NAV_META_KEY, _up)
811
  except Exception:
812
  raise err(503, "store_unavailable",
813
+ "the change was not saved: the store refused the write.")
814
  return {"key": key, "meta": _read_nav_meta(session.runtime).get(key, {})}
815
 
816
 
 
835
  clean = _clean_nav_prefs(body or {}, keys, keep_unknown_ut=not enumerated)
836
  if not session.runtime.available():
837
  raise err(503, "store_unavailable",
838
+ "the tenant store is unavailable. Nothing was saved.")
839
 
840
  def _up(data):
841
  data = data if isinstance(data, dict) else {}
 
849
  session.runtime.update(_NAV_PREFS_KEY, _up)
850
  except Exception:
851
  raise err(503, "store_unavailable",
852
+ "the folder change was not saved: the store refused the write.")
853
  return {"prefs": clean}
854
 
855
 
api/routes_query.py CHANGED
The diff for this file is too large to render. See raw diff
 
api/routes_tables.py CHANGED
@@ -165,7 +165,7 @@ def _records_or_refuse(session, table_key, st=None):
165
  defn = _defn_or_refuse(session, table_key, st=st)
166
  if not _ut().records_mutable(table_key, st=st):
167
  raise err(403, "records_read_only",
168
- "records in this automation-owned database are read-only β€” add Instagram "
169
  "handles in a Profile database and let enrichment populate this database")
170
  return defn
171
 
@@ -376,7 +376,7 @@ def delete_shared_field(table_key: str, field_key: str,
376
  owner = str(defn.get("createdBy") or "")
377
  if not session.admin and owner != session.uname:
378
  raise err(403, "forbidden",
379
- f"a tenant-wide column can be removed by its creator or an admin β€” this one "
380
  f"was added by {owner or 'somebody else'}, and dropping it would delete the "
381
  f"value for every account")
382
  dropped = shared_overlay.drop_field(table_key, str(field_key), st=session.runtime)
@@ -735,17 +735,17 @@ def create_table(body: dict = Body(default=None),
735
  raise err(400, "bad_label", "give the database a name")
736
  if not session.runtime.available():
737
  raise err(503, "store_unavailable",
738
- "the tenant store is unavailable β€” nothing was created")
739
  source = body.get("source")
740
  try:
741
  key = ut.create(label, session.uname, fields=body.get("fields"),
742
  source=source, st=session.runtime)
743
  except Exception:
744
  raise err(503, "store_unavailable",
745
- "the tenant store refused the write β€” nothing was created")
746
  if not key:
747
  raise err(400, "refused",
748
- f"could not create it β€” the name may be empty or this tenant already has "
749
  f"{ut.MAX_TABLES} databases")
750
  return {"key": key}
751
 
@@ -845,7 +845,7 @@ def delete_table(table_key: str, session: Session = Depends(require_session)):
845
  try:
846
  _ut().delete(table_key, st=session.runtime)
847
  except Exception:
848
- raise err(503, "store_unavailable", "the delete did not land β€” try again")
849
  return {"ok": True}
850
 
851
 
@@ -993,6 +993,9 @@ def table_rows(table_key: str, session: Session = Depends(require_session)):
993
  # so a tenant using `json` for a short config sees no change at all.
994
  rows = aios_grid.rows_from_pool(
995
  g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
 
 
 
996
  # ⭐ R6's SECOND SENTENCE, ON THE WIRE (W30-T29). *"if there is lag or it can't be done, you
997
  # need to explicitly tell me why and recommend a fix."* A ceiling that still applies to this
998
  # database says so here, with its cause and the recommendation, rather than waiting to be
@@ -1016,6 +1019,48 @@ def table_rows(table_key: str, session: Session = Depends(require_session)):
1016
  "recordsMutable": _ut().records_mutable(table_key, st=_lent)}
1017
 
1018
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1019
  @router.get("/tables/{table_key}/rows/{pid}/fields/{fkey}")
1020
  def table_cell(table_key: str, pid: str, fkey: str,
1021
  session: Session = Depends(require_session)):
@@ -1062,7 +1107,7 @@ def add_row(table_key: str, body: dict = Body(default=None),
1062
  rid = ut.add_row(table_key, values, session.uname, st=session.runtime,
1063
  rid=(body or {}).get("rid"))
1064
  except Exception:
1065
- raise err(503, "store_unavailable", "the row was not saved β€” the store refused")
1066
  if rid is None:
1067
  # C3 (wave 25): `add_row` also refuses a profile cell that is not a handle, so the cap
1068
  # sentence alone would misdirect β€” the reader would go and count rows. Ask the same
@@ -1072,11 +1117,11 @@ def add_row(table_key: str, body: dict = Body(default=None),
1072
  _h, ok = ut.normalize_profile(values[pf["key"]], pf["profile"].get("source"))
1073
  if not ok:
1074
  raise err(400, "refused",
1075
- f"{str(values[pf['key']])[:80]!r} is not an Instagram profile β€” "
1076
  f"{pf.get('label') or pf['key']!r} takes a handle (@name) or a "
1077
  f"profile link (instagram.com/name)")
1078
  raise err(400, "refused",
1079
- f"row refused β€” the table may be at its {ut.MAX_ROWS}-row cap")
1080
  _refresh_relations(session)
1081
  return {"rid": rid, "pid": int(rid)}
1082
 
@@ -1130,14 +1175,14 @@ def import_rows(table_key: str, body: dict = Body(default=None),
1130
  for key, value in row.items():
1131
  why = ut.cell_type_refusal(by_key[key], value)
1132
  if why:
1133
- raise err(400, "bad_value", f"row {index + 1}: {why} β€” nothing was imported")
1134
  try:
1135
  made = ut.add_rows(table_key, rows_in, session.uname, st=session.runtime)
1136
  except Exception:
1137
- raise err(503, "store_unavailable", "nothing was imported β€” the store refused")
1138
  if made is None:
1139
  raise err(400, "refused",
1140
- f"nothing was imported β€” {len(rows_in)} rows would take this database past "
1141
  f"its {ut.MAX_ROWS}-row cap, or a profile column rejected a value")
1142
  _refresh_relations(session)
1143
  return {"imported": len(made), "pids": [int(r) for r in made]}
@@ -1168,7 +1213,7 @@ def _field_or_refuse(session, table_key, fkey=""):
1168
  ut = _ut()
1169
  if not ut.is_user_table(table_key, st=session.runtime):
1170
  raise err(400, "not_a_user_table",
1171
- "only a user-created database has an editable schema β€” a connected source "
1172
  "owns its own columns")
1173
  if fkey and not ut.may_edit_field(table_key, fkey, session.uname, session.admin,
1174
  st=session.runtime):
@@ -1221,12 +1266,41 @@ def add_field(table_key: str, body: dict = Body(default=None),
1221
  # field relational", which is the question that was always meant.
1222
  if field.get("type") in ("link", "rollup"):
1223
  _refresh_relations(session)
1224
- return {"field": field}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1225
 
1226
 
1227
  def _refusal_sentence(ut, session, body, table_key="", fkey=""):
1228
  """Why was this column refused? The specific reason when we can name one, the general list
1229
  otherwise β€” never a specific-sounding guess."""
 
 
 
 
 
 
 
 
 
 
1230
  # ⭐ C3 (wave 25, R7): the one-profile-per-table refusal NAMES THE EXISTING COLUMN, which is
1231
  # what the contract asks for and what makes it actionable β€” "at most one" sends the reader
1232
  # hunting through a 40-column schema for a flag they cannot see from the header.
@@ -1238,11 +1312,11 @@ def _refusal_sentence(ut, session, body, table_key="", fkey=""):
1238
  # real mistake was the column type sends them to fix the wrong thing, which is the
1239
  # misdirection D-46 closed one door over.
1240
  if str((body or {}).get("type") or "text").strip().lower() != "text":
1241
- return ("a profile column is a flag on an ordinary TEXT column β€” it validates what "
1242
  "is typed into it, which it can only do for text")
1243
  existing = ut.profile_field(table_key, st=session.runtime) if table_key else None
1244
  if existing and existing.get("key") != str(fkey):
1245
- return (f"this database already has a profile column β€” "
1246
  f"{existing.get('label') or existing.get('key')!r}. A database has at most "
1247
  f"one, so the automation knows which handle to enrich; edit that column, or "
1248
  f"take the flag off it first")
@@ -1250,16 +1324,16 @@ def _refusal_sentence(ut, session, body, table_key="", fkey=""):
1250
  if isinstance(bag, dict):
1251
  flow = str(bag.get("flowId") or "").strip()
1252
  if not flow:
1253
- return ("an automation column has to name the automation that fills it β€” pick a "
1254
  "flow, or make this an ordinary column")
1255
  if not ut.flow_bound(bag, st=session.runtime):
1256
  return (f"this column names automation {flow!r}, which does not exist in this "
1257
- f"workspace β€” it may have been deleted; pick a flow that is still there")
1258
  kind = str((body or {}).get("type") or "").strip()
1259
  if kind and kind not in ut.UT_FIELD_TYPES:
1260
  return (f"{kind!r} is not a column type here (types: "
1261
  f"{', '.join(sorted(ut.UT_FIELD_TYPES))})")
1262
- return (f"the column was refused β€” check the name and type, or the table may be at its "
1263
  f"{ut.MAX_FIELDS}-column cap (types: {', '.join(sorted(ut.UT_FIELD_TYPES))})")
1264
 
1265
 
@@ -1282,7 +1356,7 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
1282
  try:
1283
  migrated = ut.rename_choice_values(table_key, fkey, renames, st=session.runtime)
1284
  except Exception:
1285
- raise err(503, "store_unavailable", "the rename did not land β€” try again")
1286
  # The per-user workspace strata and any view filter naming the old value are the OTHER
1287
  # half of C-RENAME and belong to `core.table_store`. Called only if it is there: an
1288
  # enumerator's mirror waits for its counterpart rather than guessing at its shape, and a
@@ -1309,7 +1383,79 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
1309
  out = {"field": field}
1310
  if migrated is not None:
1311
  out["migrated"] = migrated
1312
- return out
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1313
 
1314
 
1315
  @router.delete("/tables/{table_key}/fields/{fkey}")
@@ -1317,7 +1463,7 @@ def delete_field(table_key: str, fkey: str, session: Session = Depends(require_s
1317
  _field_or_refuse(session, table_key, fkey)
1318
  if not _ut().delete_field(table_key, fkey, st=session.runtime):
1319
  raise err(400, "refused",
1320
- "that column could not be removed β€” a database must keep at least one")
1321
  _refresh_relations(session)
1322
  return {"deleted": fkey}
1323
 
@@ -1342,7 +1488,7 @@ def delete_row(table_key: str, rid: str, session: Session = Depends(require_sess
1342
  try:
1343
  ok = _ut().delete_row(table_key, rid, st=lent)
1344
  except Exception:
1345
- raise err(503, "store_unavailable", "the delete did not land β€” try again")
1346
  if not ok:
1347
  raise err(400, "refused", "rows can only be deleted from user-created databases")
1348
  _refresh_relations(session)
@@ -1375,7 +1521,7 @@ def patch_row(table_key: str, pid: int, body: dict = Body(default=None),
1375
  "pid": pid, "updates": updates}, ctx)
1376
  except grid_events.StoreUnavailable:
1377
  raise err(503, "store_unavailable",
1378
- "the tenant store is unavailable β€” your change was not saved")
1379
  # ⚠ THE READ-BACK IS THE DEFINITION ROW, AND ON A `ut_` SCOPE THAT IS THE WHOLE OF IT.
1380
  #
1381
  # β›” CORRECTED, wave-29 T22 (owner item 2a): this note used to say "THE READ-BACK SPANS BOTH
@@ -1414,6 +1560,22 @@ def patch_row(table_key: str, pid: int, body: dict = Body(default=None),
1414
  return False
1415
 
1416
  refused = sorted(k for k in updates if not _took(k))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1417
  out = {"pid": pid, "updates": accepted}
1418
  if refused:
1419
  out["refused"] = refused
 
165
  defn = _defn_or_refuse(session, table_key, st=st)
166
  if not _ut().records_mutable(table_key, st=st):
167
  raise err(403, "records_read_only",
168
+ "records in this automation-owned database are read-only. Add Instagram "
169
  "handles in a Profile database and let enrichment populate this database")
170
  return defn
171
 
 
376
  owner = str(defn.get("createdBy") or "")
377
  if not session.admin and owner != session.uname:
378
  raise err(403, "forbidden",
379
+ f"a tenant-wide column can be removed by its creator or an admin. This one "
380
  f"was added by {owner or 'somebody else'}, and dropping it would delete the "
381
  f"value for every account")
382
  dropped = shared_overlay.drop_field(table_key, str(field_key), st=session.runtime)
 
735
  raise err(400, "bad_label", "give the database a name")
736
  if not session.runtime.available():
737
  raise err(503, "store_unavailable",
738
+ "the tenant store is unavailable. Nothing was created")
739
  source = body.get("source")
740
  try:
741
  key = ut.create(label, session.uname, fields=body.get("fields"),
742
  source=source, st=session.runtime)
743
  except Exception:
744
  raise err(503, "store_unavailable",
745
+ "the tenant store refused the write. Nothing was created")
746
  if not key:
747
  raise err(400, "refused",
748
+ f"could not create it. The name may be empty or this tenant already has "
749
  f"{ut.MAX_TABLES} databases")
750
  return {"key": key}
751
 
 
845
  try:
846
  _ut().delete(table_key, st=session.runtime)
847
  except Exception:
848
+ raise err(503, "store_unavailable", "the delete did not land. Try again")
849
  return {"ok": True}
850
 
851
 
 
993
  # so a tenant using `json` for a short config sees no change at all.
994
  rows = aios_grid.rows_from_pool(
995
  g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
996
+ # ⭐⭐ WAVE-34 (R13) β€” the per-cell enrichment STATE rides the row, beside `_created`/`lat`/
997
+ # `lon`. See `_stamp_ai_states` for why it is a row key rather than a map beside `rows`.
998
+ _stamp_ai_states(table_key, g["fields"], rows, st=_lent)
999
  # ⭐ R6's SECOND SENTENCE, ON THE WIRE (W30-T29). *"if there is lag or it can't be done, you
1000
  # need to explicitly tell me why and recommend a fix."* A ceiling that still applies to this
1001
  # database says so here, with its cause and the recommendation, rather than waiting to be
 
1019
  "recordsMutable": _ut().records_mutable(table_key, st=_lent)}
1020
 
1021
 
1022
+ #: The per-cell provenance a row carries on the wire, one key per enrichment column.
1023
+ #: β›” A ROW KEY RATHER THAN A SIBLING MAP, and the choice is load-bearing rather than cosmetic.
1024
+ #: A `{colId: {pid: state}}` map beside `rows` would need a new PROP on `RecordDetail` and a new
1025
+ #: argument at `CustomerGrid`'s call site, both in another lane's fence, to reach the two surfaces
1026
+ #: that must paint it. The row already carries `_created`, `lat` and `lon` for exactly this
1027
+ #: reason, so every reader already tolerates keys that are not columns, and both surfaces hold the
1028
+ #: row already. ⚠ COLLISION-PROOF BY CONSTRUCTION: `_clean_field` strips leading underscores off
1029
+ #: every field key, so no column can ever be called `_ai_*`.
1030
+ AI_STATE_PREFIX = "_ai_"
1031
+
1032
+
1033
+ def _stamp_ai_states(table_key, fields, rows, st=None):
1034
+ """Add `_ai_<colId>` to each row for every `ai_enrich` column. Mutates and returns `rows`.
1035
+
1036
+ β›” A PROJECTION, NOT THE MARK SET. The stratum holds a hash, a model, a timestamp, a token
1037
+ count and an error per cell; a browser needs ONE WORD to paint a state, and shipping the rest
1038
+ would grow this payload by a dict per enriched cell for data no reader reads. The vocabulary
1039
+ is `agent`/`human`/`stale`/`error` (`api/ai_enrich.py::cell_state`), which is also what the
1040
+ RUNNER obeys, so the badge and the behaviour cannot disagree about whose cell it is.
1041
+
1042
+ ⚠ AN ABSENT KEY MEANS `empty`, and only non-empty states are stamped: a table with no
1043
+ enrichment column is untouched, and a freshly created column adds nothing until something
1044
+ runs. β›” `stale` is DERIVED here rather than stored, so it is computed against TODAY'S row
1045
+ instead of against whatever was true when the value was written.
1046
+ """
1047
+ cols = [f for f in (fields or []) if str(f.get("type") or "") == "ai_enrich"]
1048
+ if not cols:
1049
+ return rows
1050
+ import ai_enrich as _ae
1051
+ for field in cols:
1052
+ col = str(field.get("key") or "")
1053
+ marks = _ut().ai_enrich_marks(table_key, col, st=st)
1054
+ cfg = field.get("aiEnrich") if isinstance(field.get("aiEnrich"), dict) else {}
1055
+ for row in (rows or []):
1056
+ if not isinstance(row, dict):
1057
+ continue
1058
+ state = _ae.cell_state(marks.get(str(row.get("pid"))), cfg, row, col)
1059
+ if state != "empty":
1060
+ row[AI_STATE_PREFIX + col] = state
1061
+ return rows
1062
+
1063
+
1064
  @router.get("/tables/{table_key}/rows/{pid}/fields/{fkey}")
1065
  def table_cell(table_key: str, pid: str, fkey: str,
1066
  session: Session = Depends(require_session)):
 
1107
  rid = ut.add_row(table_key, values, session.uname, st=session.runtime,
1108
  rid=(body or {}).get("rid"))
1109
  except Exception:
1110
+ raise err(503, "store_unavailable", "the row was not saved. The store refused")
1111
  if rid is None:
1112
  # C3 (wave 25): `add_row` also refuses a profile cell that is not a handle, so the cap
1113
  # sentence alone would misdirect β€” the reader would go and count rows. Ask the same
 
1117
  _h, ok = ut.normalize_profile(values[pf["key"]], pf["profile"].get("source"))
1118
  if not ok:
1119
  raise err(400, "refused",
1120
+ f"{str(values[pf['key']])[:80]!r} is not an Instagram profile. "
1121
  f"{pf.get('label') or pf['key']!r} takes a handle (@name) or a "
1122
  f"profile link (instagram.com/name)")
1123
  raise err(400, "refused",
1124
+ f"row refused. The table may be at its {ut.MAX_ROWS}-row cap")
1125
  _refresh_relations(session)
1126
  return {"rid": rid, "pid": int(rid)}
1127
 
 
1175
  for key, value in row.items():
1176
  why = ut.cell_type_refusal(by_key[key], value)
1177
  if why:
1178
+ raise err(400, "bad_value", f"row {index + 1}: {why}. Nothing was imported")
1179
  try:
1180
  made = ut.add_rows(table_key, rows_in, session.uname, st=session.runtime)
1181
  except Exception:
1182
+ raise err(503, "store_unavailable", "nothing was imported. The store refused")
1183
  if made is None:
1184
  raise err(400, "refused",
1185
+ f"nothing was imported. {len(rows_in)} rows would take this database past "
1186
  f"its {ut.MAX_ROWS}-row cap, or a profile column rejected a value")
1187
  _refresh_relations(session)
1188
  return {"imported": len(made), "pids": [int(r) for r in made]}
 
1213
  ut = _ut()
1214
  if not ut.is_user_table(table_key, st=session.runtime):
1215
  raise err(400, "not_a_user_table",
1216
+ "only a user-created database has an editable schema. A connected source "
1217
  "owns its own columns")
1218
  if fkey and not ut.may_edit_field(table_key, fkey, session.uname, session.admin,
1219
  st=session.runtime):
 
1266
  # field relational", which is the question that was always meant.
1267
  if field.get("type") in ("link", "rollup"):
1268
  _refresh_relations(session)
1269
+ return _with_dropped(ut, {"field": field}, body)
1270
+
1271
+
1272
+ def _with_dropped(ut, out, body):
1273
+ """Attach the NAMED list of config keys the validator did not keep (wave 34, R13 / W34-T51).
1274
+
1275
+ β›”β›” THIS EXISTS BECAUSE THE VALIDATOR HAS NO ERROR CHANNEL AND CANNOT GROW ONE. Every bag
1276
+ cleaner in `core/user_tables.py` returns `dict | None` and drops unknown keys in silence, and
1277
+ `verify_fields_contract` asserts that they do -- so the drop is correct and the SILENCE is the
1278
+ defect. T51's contract is that an unknown config key is dropped **and named**, so the naming
1279
+ rides the response beside the accepted field rather than inside the validator.
1280
+
1281
+ ⚠ OMITTED WHEN EMPTY, deliberately: an always-present `dropped: []` teaches every reader to
1282
+ ignore the key, which is how a report stops being read before it stops being true.
1283
+ """
1284
+ dropped = ut.ai_enrich_dropped_keys((body or {}).get("aiEnrich"))
1285
+ if dropped:
1286
+ out = dict(out)
1287
+ out["dropped"] = dropped
1288
+ return out
1289
 
1290
 
1291
  def _refusal_sentence(ut, session, body, table_key="", fkey=""):
1292
  """Why was this column refused? The specific reason when we can name one, the general list
1293
  otherwise β€” never a specific-sounding guess."""
1294
+ # ⭐ WAVE-34 (R13): the enrichment column's own sentence, named BEFORE the automation bag
1295
+ # below. `_clean_field` DERIVES `field.automation` for this kind, so a refused enrichment
1296
+ # column would otherwise be explained by the flow law -- "pick a flow, or make this an
1297
+ # ordinary column" -- which is the D-46 misdirection exactly, pointing at a control the user
1298
+ # never touched.
1299
+ if str((body or {}).get("type") or "").strip().lower() == "ai_enrich":
1300
+ bag = (body or {}).get("aiEnrich")
1301
+ if not isinstance(bag, dict) or not str(bag.get("prompt") or "").strip():
1302
+ return ("an AI enrichment column needs a prompt. It is the only thing that can "
1303
+ "produce a value here, so a column without one would stay empty forever")
1304
  # ⭐ C3 (wave 25, R7): the one-profile-per-table refusal NAMES THE EXISTING COLUMN, which is
1305
  # what the contract asks for and what makes it actionable β€” "at most one" sends the reader
1306
  # hunting through a 40-column schema for a flag they cannot see from the header.
 
1312
  # real mistake was the column type sends them to fix the wrong thing, which is the
1313
  # misdirection D-46 closed one door over.
1314
  if str((body or {}).get("type") or "text").strip().lower() != "text":
1315
+ return ("a profile column is a flag on an ordinary TEXT column. It validates what "
1316
  "is typed into it, which it can only do for text")
1317
  existing = ut.profile_field(table_key, st=session.runtime) if table_key else None
1318
  if existing and existing.get("key") != str(fkey):
1319
+ return (f"this database already has a profile column: "
1320
  f"{existing.get('label') or existing.get('key')!r}. A database has at most "
1321
  f"one, so the automation knows which handle to enrich; edit that column, or "
1322
  f"take the flag off it first")
 
1324
  if isinstance(bag, dict):
1325
  flow = str(bag.get("flowId") or "").strip()
1326
  if not flow:
1327
+ return ("an automation column has to name the automation that fills it. Pick a "
1328
  "flow, or make this an ordinary column")
1329
  if not ut.flow_bound(bag, st=session.runtime):
1330
  return (f"this column names automation {flow!r}, which does not exist in this "
1331
+ f"workspace. It may have been deleted; pick a flow that is still there")
1332
  kind = str((body or {}).get("type") or "").strip()
1333
  if kind and kind not in ut.UT_FIELD_TYPES:
1334
  return (f"{kind!r} is not a column type here (types: "
1335
  f"{', '.join(sorted(ut.UT_FIELD_TYPES))})")
1336
+ return (f"the column was refused. Check the name and type, or the table may be at its "
1337
  f"{ut.MAX_FIELDS}-column cap (types: {', '.join(sorted(ut.UT_FIELD_TYPES))})")
1338
 
1339
 
 
1356
  try:
1357
  migrated = ut.rename_choice_values(table_key, fkey, renames, st=session.runtime)
1358
  except Exception:
1359
+ raise err(503, "store_unavailable", "the rename did not land. Try again")
1360
  # The per-user workspace strata and any view filter naming the old value are the OTHER
1361
  # half of C-RENAME and belong to `core.table_store`. Called only if it is there: an
1362
  # enumerator's mirror waits for its counterpart rather than guessing at its shape, and a
 
1383
  out = {"field": field}
1384
  if migrated is not None:
1385
  out["migrated"] = migrated
1386
+ return _with_dropped(ut, out, body)
1387
+
1388
+
1389
+ def _fire_on_change(table_key, pid, changed, session):
1390
+ """Run any `on_change` enrichment column whose prompt names a cell that just moved.
1391
+
1392
+ β›” ONE DEFINITION READ FOR THE WHOLE WRITE, and that is the point rather than an optimisation.
1393
+ `automation_engine.grid_hook` calls `all_definitions(st)` once PER EVENT, which turns a
1394
+ 20,000-row import into 20,000 whole-document reads on the single process this product runs
1395
+ (`D-134`, and `W34-T54`'s own `how:` says not to rebuild it). `on_change_fields` is pure and
1396
+ takes the definition, so this reads once and asks about every column.
1397
+
1398
+ β›” AND IT NEVER FAILS THE WRITE. The cell edit has already succeeded and been acknowledged;
1399
+ an enrichment that could not run is a missing value, not a lost edit, and the run's own report
1400
+ carries the reason. ⚠ It is also deliberately SYNCHRONOUS and bounded to this one row: a
1401
+ fan-out here would put a vendor call on the critical path of every keystroke-commit.
1402
+ """
1403
+ import ai_enrich as _ae
1404
+
1405
+ try:
1406
+ defn = _ut().get(table_key, st=session.runtime) or {}
1407
+ wanted = _ae.on_change_fields(defn, changed.keys())
1408
+ for field in wanted:
1409
+ _ae.run_field(table_key, field["key"], st=session.runtime, rows=[str(pid)])
1410
+ except Exception: # noqa: BLE001
1411
+ pass
1412
+
1413
+
1414
+ @router.post("/tables/{table_key}/fields/{fkey}/enrich")
1415
+ def enrich_field(table_key: str, fkey: str, body: dict = Body(default=None),
1416
+ session: Session = Depends(require_session)):
1417
+ """Run an AI enrichment column (wave 34, owner ruling R13). Returns the run's own REPORT.
1418
+
1419
+ β›” THIS DOOR SPENDS MONEY, so it rides the same wall every other schema write rides
1420
+ (`_field_or_refuse`) rather than a looser one of its own. A read-only viewer cannot bill the
1421
+ tenant by opening a grid.
1422
+
1423
+ `{"rows": ["3"]}` is a MANUAL run of exactly those records: a person asked, in front of the
1424
+ value being replaced, so it skips the `overwrite` policy. An ABSENT `rows` is the automatic
1425
+ plan, where the policy and the never-overwrite-a-human law both apply. The two are one
1426
+ function with one flag, not two runners.
1427
+
1428
+ ⚠ THE REPORT IS THE PRODUCT, not a status code. It carries `filled`, `failed`, `skipped` by
1429
+ reason, `tokens` spent, the provider, per-row errors, and `limit` (R6's second sentence: a
1430
+ ceiling that stopped the run names its cause and a remedy). A 200 with `filled: 0` and a
1431
+ populated `skipped` is a correct, informative answer, and the client must render it rather
1432
+ than treat it as success.
1433
+ """
1434
+ _field_or_refuse(session, table_key, fkey)
1435
+ import ai_enrich as _ae
1436
+
1437
+ rows = (body or {}).get("rows")
1438
+ if rows is not None and not isinstance(rows, list):
1439
+ raise err(400, "bad_rows", "`rows` must be a list of record ids, or absent to run the "
1440
+ "rows this column's own settings choose")
1441
+ # The caller's permitted pool, the same one the row doors use. A named row outside it is
1442
+ # dropped rather than refused: a stale client naming a record that has been deleted or moved
1443
+ # out of scope should not fail a run over the rows it can legitimately fill.
1444
+ if rows is not None:
1445
+ allowed = {str(p) for p in scoped_pids(session, table_key)[0]}
1446
+ rows = [str(r) for r in rows if str(r) in allowed]
1447
+ # ⭐ `W34-T54`'s bulk menu is this one field: "Rows never filled" sends `blank`, "All rows"
1448
+ # sends `always`. Anything else falls back to the column's own saved policy rather than to a
1449
+ # default, so a typo cannot quietly widen what a run touches.
1450
+ report = _ae.run_field(table_key, fkey, st=session.runtime, rows=rows,
1451
+ policy=str((body or {}).get("scope") or "") or None,
1452
+ # A named row set through THIS door is a person asking.
1453
+ manual=rows is not None)
1454
+ if report.get("problem"):
1455
+ # A run that could not start at all is not a 200: nothing was attempted, nothing was
1456
+ # spent, and the reason is actionable (no provider configured, or the wrong column).
1457
+ raise err(400, "enrich_refused", str(report["problem"]))
1458
+ return report
1459
 
1460
 
1461
  @router.delete("/tables/{table_key}/fields/{fkey}")
 
1463
  _field_or_refuse(session, table_key, fkey)
1464
  if not _ut().delete_field(table_key, fkey, st=session.runtime):
1465
  raise err(400, "refused",
1466
+ "that column could not be removed. A database must keep at least one")
1467
  _refresh_relations(session)
1468
  return {"deleted": fkey}
1469
 
 
1488
  try:
1489
  ok = _ut().delete_row(table_key, rid, st=lent)
1490
  except Exception:
1491
+ raise err(503, "store_unavailable", "the delete did not land. Try again")
1492
  if not ok:
1493
  raise err(400, "refused", "rows can only be deleted from user-created databases")
1494
  _refresh_relations(session)
 
1521
  "pid": pid, "updates": updates}, ctx)
1522
  except grid_events.StoreUnavailable:
1523
  raise err(503, "store_unavailable",
1524
+ "the tenant store is unavailable. Your change was not saved")
1525
  # ⚠ THE READ-BACK IS THE DEFINITION ROW, AND ON A `ut_` SCOPE THAT IS THE WHOLE OF IT.
1526
  #
1527
  # β›” CORRECTED, wave-29 T22 (owner item 2a): this note used to say "THE READ-BACK SPANS BOTH
 
1560
  return False
1561
 
1562
  refused = sorted(k for k in updates if not _took(k))
1563
+ # ⭐⭐ WAVE-34 (R13) β€” THE HUMAN-EDIT STAMP, AND IT HAS TO HAPPEN HERE. "Did a person write
1564
+ # this cell?" is not recoverable from the value afterwards, so the only place to record it is
1565
+ # the door a person writes through. `ai_enrich_may_write` then refuses to let any automatic
1566
+ # run overwrite it, whatever the column's `overwrite` policy says.
1567
+ # ⚠ STAMPED FROM THE CELLS THAT ACTUALLY TOOK, never from what was asked: marking a refused
1568
+ # write `human` would freeze a cell against the agent on the strength of an edit that never
1569
+ # landed. `note_human_edit` filters to the enrichment columns itself and is a no-op otherwise.
1570
+ took = {k: v for k, v in accepted.items() if k not in refused}
1571
+ if took:
1572
+ try:
1573
+ _ut().note_human_edit(table_key, took, pid, st=session.runtime)
1574
+ except Exception: # noqa: BLE001
1575
+ # Provenance is metadata about a write that has already succeeded. Failing the
1576
+ # request here would tell the user their edit was lost when it was not.
1577
+ pass
1578
+ _fire_on_change(table_key, pid, took, session)
1579
  out = {"pid": pid, "updates": accepted}
1580
  if refused:
1581
  out["refused"] = refused
api/web_agent.py CHANGED
@@ -1,9 +1,10 @@
1
  """web_agent.py β€” THE SEAM between an automation step and a browser that runs somewhere else.
2
 
3
- CONTRACT C5 (wave 31, ruling R10 / D-51). E ships this; **C mounts it** in
4
- `automation_engine.py`'s action dispatch for `web_read` and adds the `verify_wiring` row β€”
5
- β›” E cannot verify its own mounting, and an unmounted runner is a whole, correct, UNREACHABLE
6
- feature, which is exactly how five wave-29 features shipped behind green gates.
 
7
 
8
  result, error = web_agent.run_step(step, ctx) # -> (dict|None, str)
9
 
@@ -30,22 +31,39 @@ foreground step of an automation RUN and far too slow for a route a person is wa
30
  cost is per JOB, not per step β€” `run_plan` sends a whole flow's steps in ONE job for that reason.
31
 
32
  ────────────────────────────────────────────────────────────────────────────────────────────────
33
- β›” THREE THINGS ARE TRUE OF PRODUCTION TODAY AND ARE NOT DEFECTS IN THIS FILE. `capability()`
34
- reports each as a sentence, and `verify_web_agent.py` holds each to a check, so none of them can
35
- be discovered by a customer instead of by us:
36
-
37
- 1. THE SPACE'S TOKEN CANNOT PAY FOR A JOB. `deploy_web.py` pushes `HF_TOKEN`, which is scoped to
38
- the org `royal-imports`; HF answers **402 `Pre-paid credit balance is insufficient`** there
39
- and 0 jobs have ever run under it. `AIOS_HF_TOKEN` (user `fsanyoto`, 100 jobs) works and is
40
- NOT pushed. Measured 2026-08-12.
41
- 2. THE SPACE DOES NOT SHIP THE JOB SCRIPT. `deploy_web.py` uploads `api/*.py` and
42
- `platform/{core,modules,harness}`; `jobs/` is in no manifest. And `check_upload()` walks
43
- IMPORTS, so it structurally cannot see a file opened by PATH β€” its own comments say so, about
44
- `aios_grid_fields.json`, which crashed a Space for exactly this reason.
45
- 3. `routes_web_agent.py` IS NOT MOUNTED. `main.py` is another lane's file this wave.
46
-
47
- Each needs one line somebody else owns. Until then this module answers with a sentence rather
48
- than a silence, which is the whole of R6's second half.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  """
50
  import base64
51
  import hashlib
@@ -223,7 +241,7 @@ def _sentence_for(exc, namespace):
223
  f"point this deployment at one that has it ({ENV_NAMESPACE} / {ENV_TOKEN[0]}). "
224
  f"Nothing was read.")
225
  if status in (401, 403):
226
- return (f"Hugging Face refused the browser job as unauthorised for {where} β€” this "
227
  f"deployment's token does not carry `job.write` on that account. Point it at an "
228
  f"account the token owns ({ENV_NAMESPACE}), or configure a token that owns this "
229
  f"one ({ENV_TOKEN[0]}). Nothing was read.")
@@ -524,7 +542,7 @@ def _await(api, job_id, namespace, started, log):
524
  break
525
  if state in ("COMPLETED", "ERROR", "CANCELED"):
526
  return None, (f"The browser job {job_id} ended {state} without returning a result"
527
- f"{' β€” it never started printing' if not booted else ''}. "
528
  f"Nothing was read.")
529
  if not booted and waited > START_TIMEOUT:
530
  # The submitted-but-never-started case, named as itself.
@@ -534,7 +552,7 @@ def _await(api, job_id, namespace, started, log):
534
  pass
535
  return None, (f"The browser job {job_id} was accepted but had not started after "
536
  f"{int(START_TIMEOUT)}s (it stayed {state or 'queued'}), so it was "
537
- f"cancelled. Nothing was read β€” try again, or the platform is busy.")
538
  if waited > RUN_TIMEOUT:
539
  try:
540
  api.cancel_job(job_id=job_id, namespace=namespace)
 
1
  """web_agent.py β€” THE SEAM between an automation step and a browser that runs somewhere else.
2
 
3
+ CONTRACT C5 (wave 31, ruling R10 / D-51). E shipped this and C mounted it in
4
+ `automation_engine.py`'s action dispatch; both halves landed and the `verify_wiring` row exists.
5
+ ⚠ That sentence was written in the FUTURE TENSE as a wave-31 hand-off and stayed that way for
6
+ three waves, which is the smaller cousin of the correction below: a fence note describes one
7
+ wave's division of labour and reads, afterwards, as a description of the code.
8
 
9
  result, error = web_agent.run_step(step, ctx) # -> (dict|None, str)
10
 
 
31
  cost is per JOB, not per step β€” `run_plan` sends a whole flow's steps in ONE job for that reason.
32
 
33
  ────────────────────────────────────────────────────────────────────────────────────────────────
34
+ β›”β›” WAVE 34 Β· W34-T44 β€” THIS BLOCK LISTED "THREE THINGS TRUE OF PRODUCTION TODAY". ALL THREE WERE
35
+ FALSE BY THE TIME ANYBODY READ THEM, AND THIS DOCSTRING IS WHERE THE ERROR CAME FROM.
36
+
37
+ It said the Space token 402s, that `deploy_web.py` never uploads `jobs/`, and that
38
+ `routes_web_agent.py` is not mounted β€” so the whole web-action family was inert in production.
39
+ **Four later documents repeated it** (the wave-34 PRD's scouted index, the plan-time scout's
40
+ report, `W34-T44`'s own `how:`, and `run_gates.ps1`'s annotation telling a deploy to expect
41
+ `web_agent` at 48/50), and every one of them traced back here. Re-checked line by line, against
42
+ the files themselves, on 2026-08-16:
43
+
44
+ 1. THE TOKEN IS PUSHED. `deploy_web.py:1128` adds `AIOS_HF_TOKEN` to the Space's secrets when it
45
+ is set, and `ENV_TOKEN` below prefers it over `HF_TOKEN`, so the org token that 402s is never
46
+ the one reached. The 402 itself is still true OF THAT TOKEN and is why the order matters.
47
+ 2. THE JOB SCRIPT SHIPS. `deploy_web.py::_jobs_files` (:413) collects `jobs/` and :867 uploads
48
+ it. The `check_upload()` caveat stands as a caveat: it walks IMPORTS and cannot see a file
49
+ opened by PATH, so this upload is covered by the manifest, never by that check.
50
+ 3. IT IS MOUNTED. `main.py:264`, `app.include_router(routes_web_agent.router)` β€” and
51
+ `verify_web_agent.py` asserts the route is in the SERVED set (`app.openapi()["paths"]`),
52
+ which is green.
53
+
54
+ ⭐ MEASURED END TO END the same day, from a dev box: `capability()` = ready True / "configured" /
55
+ `AIOS_HF_TOKEN` / playwright 1.62.0, and a real `web_read` of `https://example.com h1` submitted in
56
+ 3.8 s, was alive at 21.4 s and returned `value "Example Domain"`, status 200, 106 ms in-browser,
57
+ 24.5 s wall clock cold.
58
+ ⚠ STILL UNPROVEN, so nobody over-corrects in the other direction: that run was NOT on the deployed
59
+ Space, which carries the secret only if the last deploy read this `.env`. Proving it needs a click
60
+ on staging, and until then "it works" means "it works from a box with the token".
61
+
62
+ β›” THE STANDING LESSON, kept here because this file is where it started: **a docstring stating
63
+ facts about PRODUCTION has no expiry and nothing re-reads it when the world changes.** Three
64
+ sentences about somebody else's file outlived all three of those files' fixes, and were quoted as
65
+ current by four documents and a ticket. `capability()` answers the same question in under a second
66
+ and returns TODAY's answer; prefer calling it to reading this.
67
  """
68
  import base64
69
  import hashlib
 
241
  f"point this deployment at one that has it ({ENV_NAMESPACE} / {ENV_TOKEN[0]}). "
242
  f"Nothing was read.")
243
  if status in (401, 403):
244
+ return (f"Hugging Face refused the browser job as unauthorised for {where}. This "
245
  f"deployment's token does not carry `job.write` on that account. Point it at an "
246
  f"account the token owns ({ENV_NAMESPACE}), or configure a token that owns this "
247
  f"one ({ENV_TOKEN[0]}). Nothing was read.")
 
542
  break
543
  if state in ("COMPLETED", "ERROR", "CANCELED"):
544
  return None, (f"The browser job {job_id} ended {state} without returning a result"
545
+ f"{'. It never started printing' if not booted else ''}. "
546
  f"Nothing was read.")
547
  if not booted and waited > START_TIMEOUT:
548
  # The submitted-but-never-started case, named as itself.
 
552
  pass
553
  return None, (f"The browser job {job_id} was accepted but had not started after "
554
  f"{int(START_TIMEOUT)}s (it stayed {state or 'queued'}), so it was "
555
+ f"cancelled. Nothing was read. Try again, or the platform is busy.")
556
  if waited > RUN_TIMEOUT:
557
  try:
558
  api.cancel_job(job_id=job_id, namespace=namespace)
platform/aios_grid.py CHANGED
@@ -120,9 +120,20 @@ def _round(v):
120
  #: surfaces that CREATE them are the user-table databases, where a relation between two tables is
121
  #: a thing that exists. On the Odoo-backed Customer/Product grids there is no second user table to
122
  #: point at, so the column menu there simply never offers one.
 
 
 
 
 
 
 
 
 
 
 
123
  CUSTOM_FIELD_TYPES = {"text", "select", "multiselect", "user", "int", "currency", "pct", "date",
124
  "checkbox", "phone", "email", "url", "rating", "created_time", "formula",
125
- "automation", "image", "json", "link", "rollup", "code"}
126
 
127
  #: ⭐ WAVE-27 item 13 (owner ruling R13) β€” the `code` field's LANGUAGES.
128
  #:
 
120
  #: surfaces that CREATE them are the user-table databases, where a relation between two tables is
121
  #: a thing that exists. On the Odoo-backed Customer/Product grids there is no second user table to
122
  #: point at, so the column menu there simply never offers one.
123
+ #: ⭐⭐ WAVE-34 (owner ruling R13) β€” `ai_enrich` JOINS, and it had to join HERE in the same change
124
+ #: that put it in `UT_FIELD_TYPES`, not a ticket later. The wave planned a SERVER-FIRST landing on
125
+ #: the reasoning that a kind the client does not offer is invisible while a kind the server refuses
126
+ #: deletes a column. That reasoning is sound and the conclusion was still wrong, because THREE
127
+ #: parity gates chain over these sets and none of them permits a partial landing:
128
+ #: `verify_api` W18-UT `UT_FIELD_TYPES - CUSTOM_FIELD_TYPES == set()` (this line)
129
+ #: `aw_fields_contract` Β§5 `CREATABLE_TYPES == CUSTOM_FIELD_TYPES`, EXACT set equality
130
+ #: `types.ts` `CREATABLE_TYPES: readonly FieldType[]`, so the union must carry it too
131
+ #: Measured live by lane B at 17:09: `api_api` went 969/969 to 968/969 the moment the kind entered
132
+ #: `core/user_tables.py` alone, printing `got {'ai_enrich'} want set()`. The comments on
133
+ #: `json`, `link`/`rollup` and `code` below all say the same thing in their own words.
134
  CUSTOM_FIELD_TYPES = {"text", "select", "multiselect", "user", "int", "currency", "pct", "date",
135
  "checkbox", "phone", "email", "url", "rating", "created_time", "formula",
136
+ "automation", "image", "json", "link", "rollup", "code", "ai_enrich"}
137
 
138
  #: ⭐ WAVE-27 item 13 (owner ruling R13) β€” the `code` field's LANGUAGES.
139
  #:
platform/core/registry.py CHANGED
@@ -57,7 +57,7 @@ REGISTRY = [
57
  # briefing still read it, and pages_sales.py survives UNREGISTERED as the Y1 envelope's
58
  # template + verify_api's fixture.
59
  {'key': 'sales', 'label': 'Sales', 'brand': True, 'hq': False, 'validate': True, 'source': 'Odoo', 'archived': True,
60
- 'note': 'Revenue, YoY, seasonality, reps, customers, SKUs β€” by BU. Retired wave 16: '
61
  'rebuild as grid chart/dashboard views (compare series, KPI deltas, tables).'},
62
  # ARCHIVED AS A PAGE (owner item 14, wave 8): "archive the current form of the Customer
63
  # dashboard completely, i want to redesign it, exactly with the backend we have". So the
@@ -104,7 +104,7 @@ REGISTRY = [
104
  # _LEGACY_KEYS maps cohortβ†’customer_data so grants + ?page= deep links land on the grid
105
  # that now hosts the cohorts.
106
  {'key': 'cohort', 'label': 'Cohort', 'brand': True, 'hq': False, 'validate': False, 'source': 'Odoo', 'parent': 'customers', 'archived': True,
107
- 'note': 'Hand-curated, unchanging customer lists β€” folded into the Customer rail as '
108
  'locked views (wave 16). The set is fixed: it changes only when someone adds '
109
  'or removes a member. Open them from Customer > Views > Cohorts.'},
110
  # ARCHIVED wave 16 (owner item 7, R11, 2026-08-02) beside Sales. The agent DRAWER and the
@@ -131,9 +131,9 @@ REGISTRY = [
131
  'note': 'The SKU catalogue as a grid: per-product revenue, units and (consolidated) '
132
  'stock columns, with saved views and custom fields. Identity = the SKU code.'},
133
  {'key': 'products', 'label': 'SKU', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
134
- 'note': 'SKU movers, zombies, velocity, coverage, drawers β€” by BU (sales-derived).'},
135
  {'key': 'assortment', 'label': 'Assortment', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
136
- 'note': 'Facet-level performance (category/color/occasion/collection/season) + season readiness β€” by BU.'},
137
  {'key': 'financial', 'label': 'Financial', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
138
  'note': 'Gross margin by BU/category/SKU. Cash-conversion cycle is HQ-consolidated.'},
139
  {'key': 'pricing', 'label': 'Pricing', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
@@ -148,7 +148,7 @@ REGISTRY = [
148
  'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 13: "We should be able to replace '
149
  'Procurement completely, and add it as part of the Product database"). The buy '
150
  'list is now a saved VIEW on the Product grid, filtered on a FORMULA field over '
151
- 'the supplier/lead-time columns and the demand measure β€” the owner\'s ruling R3: '
152
  '"Buy list is just a View, with a Filter from a Formula field that taps into '
153
  'Metrics Fields... the 8-month demand baseline etc. is just math in a Formula '
154
  'field." The ROW STAYS validate_only: `modules/procurement.py` still owns the '
@@ -162,7 +162,7 @@ REGISTRY = [
162
  'source': 'Odoo', 'api_surface': False, 'admin_only': True,
163
  'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 15: "Collections should also be '
164
  'entirely replicable as just a View under Customer"). The worklist is the shared '
165
- '"Collections" view on the Customer grid β€” same numbers, from this module\'s own '
166
  'reconciled blocks (ar_open/ar_overdue/ar_exposure/days_to_pay + the four aging '
167
  'buckets). The ROW STAYS validate_only so validate.py keeps running ar.validate(), '
168
  'which is the proof those columns rest on; archiving it would have skipped the '
@@ -171,7 +171,7 @@ REGISTRY = [
171
  'sender (app._collections_statements -> modules/collections_send), the ONE '
172
  'sanctioned Odoo writer, which the same ruling says stays untouched. So the row '
173
  'keeps a Streamlit page for ADMINS ONLY (`admin_only`) and leaves the API payload '
174
- 'entirely (`api_surface: False`) β€” no "Collections" in the React nav, no second '
175
  'worklist, and the biweekly send keeps its door.'},
176
  {'key': 'returns', 'label': 'Returns', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
177
  'note': 'Credit-note lens: refund concentration by SKU (quality) and customer (behavior). Company-level.'},
@@ -180,7 +180,7 @@ REGISTRY = [
180
  {'key': 'backorders', 'label': 'Backorders', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
181
  'note': 'Confirmed-undelivered order lines aged vs promise date, valued, with a supply-aware next action per row (ship / expedite / call). Wholesale scope.'},
182
  {'key': 'pricecomp', 'label': 'Price Compliance', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
183
- 'note': 'Selling below the customer pricelist tier (LTM, per customer x SKU): the pocket-price floor worklist with annualized leak $. Sub-30% ratios usually mean a stale or pack-basis RULE β€” fix the rule, not the rep.'},
184
  {'key': 'o2c', 'label': 'Cash Timing', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
185
  'note': 'Order-to-cash stage decomposition (order-to-ship / ship-to-invoice / invoice-to-paid, each with its owner) + the terms-gap rollup: contractual vs actual days per payment term with the free-credit $ it strands.'},
186
  {'key': 'bookings', 'label': 'Order Book', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
@@ -193,29 +193,37 @@ REGISTRY = [
193
  'note': 'Operating expense from the GL (all expense-type accounts; COGS excluded): trend, operating leverage (opex % of revenue), the YoY cost bridge, XmR control-limit spike watch list, fixed/variable split and a drill-to-ledger category directory. Company-level.'},
194
  {'key': 'health', 'label': 'Data Health', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
195
  'note': 'Close/reconciliation scan; period-filtered, mostly company-level. ARCHITECTURE '
196
- '(owner 2026-07-23): every discrepancy / potential-error marker lives here β€” '
197
  'procurement mapping gaps, count-trust (unverified on-hand counts), untracked-on-'
198
- 'order β€” so operational workflows stay clean for doing the work.'},
199
  {'key': 'dictionary', 'label': 'Metric Dictionary', 'brand': False, 'hq': True, 'validate': True,
200
  'nav': False, 'validate_only': True,
201
- 'note': 'PAGE RETIRED 2026-07-23 (owner: broken/not customer-facing) β€” row kept ONLY so '
202
  'validate.py keeps running the semantic-layer contracts (the Analyst grounding '
203
- 'proof). No nav, no page. Wave 17 R8 ("I don\'t even know what it does β€” delete '
204
  'them") made that literal: `validate_only` takes it out of the account menu too, '
205
  'which is the last place it was still visible. The proof survives; the door does not.'},
206
- {'key': 'automation', 'label': 'Automation', 'brand': False, 'hq': True, 'validate': False,
207
  'nav': True,
208
- 'note': 'Wave 18 (C-AUTONAV): the Automation surface β€” scheduled jobs that create and '
209
- 'refresh user databases (website scrape-to-DB, the Instagram field). React-only '
210
- 'surface (no PAGE_FUNCS entry, the no-new-Streamlit rule); admins hold it via '
211
- '"all", other users need the explicit grant β€” fail-closed default.'},
 
 
 
 
 
 
 
 
212
  {'key': 'settings', 'label': 'Settings', 'brand': False, 'hq': True, 'validate': False,
213
  'nav': False, # sidebar sentinel (account group) β€” owner IA 2026-07-12
214
  'note': 'User scope settings: the Business Unit toggle (strict isolation) and the Data basis '
215
  'toggle (Orders vs Invoiced) moved here from the sidebar.'},
216
  {'key': 'analyst', 'label': 'AIOS Analyst', 'brand': False, 'hq': True, 'validate': False,
217
  'nav': False, # sidebar sentinel button; eval gate = harness/evals.py (run pre-ship, NOT in validate.py β€” live LLM cost)
218
- 'note': 'Ask the business a question in plain language: a small AI model calls governed tools over the semantic layer β€” answers carry their query trace and drill links. AI-generated output (Art. 50 labeled).'},
219
  ]
220
 
221
  BY_KEY = {m['key']: m for m in REGISTRY}
 
57
  # briefing still read it, and pages_sales.py survives UNREGISTERED as the Y1 envelope's
58
  # template + verify_api's fixture.
59
  {'key': 'sales', 'label': 'Sales', 'brand': True, 'hq': False, 'validate': True, 'source': 'Odoo', 'archived': True,
60
+ 'note': 'Revenue, YoY, seasonality, reps, customers, SKUs. By BU. Retired wave 16: '
61
  'rebuild as grid chart/dashboard views (compare series, KPI deltas, tables).'},
62
  # ARCHIVED AS A PAGE (owner item 14, wave 8): "archive the current form of the Customer
63
  # dashboard completely, i want to redesign it, exactly with the backend we have". So the
 
104
  # _LEGACY_KEYS maps cohortβ†’customer_data so grants + ?page= deep links land on the grid
105
  # that now hosts the cohorts.
106
  {'key': 'cohort', 'label': 'Cohort', 'brand': True, 'hq': False, 'validate': False, 'source': 'Odoo', 'parent': 'customers', 'archived': True,
107
+ 'note': 'Hand-curated, unchanging customer lists. Folded into the Customer rail as '
108
  'locked views (wave 16). The set is fixed: it changes only when someone adds '
109
  'or removes a member. Open them from Customer > Views > Cohorts.'},
110
  # ARCHIVED wave 16 (owner item 7, R11, 2026-08-02) beside Sales. The agent DRAWER and the
 
131
  'note': 'The SKU catalogue as a grid: per-product revenue, units and (consolidated) '
132
  'stock columns, with saved views and custom fields. Identity = the SKU code.'},
133
  {'key': 'products', 'label': 'SKU', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
134
+ 'note': 'SKU movers, zombies, velocity, coverage, drawers. By BU (sales-derived).'},
135
  {'key': 'assortment', 'label': 'Assortment', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
136
+ 'note': 'Facet-level performance (category/color/occasion/collection/season) + season readiness. By BU.'},
137
  {'key': 'financial', 'label': 'Financial', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
138
  'note': 'Gross margin by BU/category/SKU. Cash-conversion cycle is HQ-consolidated.'},
139
  {'key': 'pricing', 'label': 'Pricing', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
 
148
  'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 13: "We should be able to replace '
149
  'Procurement completely, and add it as part of the Product database"). The buy '
150
  'list is now a saved VIEW on the Product grid, filtered on a FORMULA field over '
151
+ 'the supplier/lead-time columns and the demand measure. The owner\'s ruling R3: '
152
  '"Buy list is just a View, with a Filter from a Formula field that taps into '
153
  'Metrics Fields... the 8-month demand baseline etc. is just math in a Formula '
154
  'field." The ROW STAYS validate_only: `modules/procurement.py` still owns the '
 
162
  'source': 'Odoo', 'api_surface': False, 'admin_only': True,
163
  'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 15: "Collections should also be '
164
  'entirely replicable as just a View under Customer"). The worklist is the shared '
165
+ '"Collections" view on the Customer grid. Same numbers, from this module\'s own '
166
  'reconciled blocks (ar_open/ar_overdue/ar_exposure/days_to_pay + the four aging '
167
  'buckets). The ROW STAYS validate_only so validate.py keeps running ar.validate(), '
168
  'which is the proof those columns rest on; archiving it would have skipped the '
 
171
  'sender (app._collections_statements -> modules/collections_send), the ONE '
172
  'sanctioned Odoo writer, which the same ruling says stays untouched. So the row '
173
  'keeps a Streamlit page for ADMINS ONLY (`admin_only`) and leaves the API payload '
174
+ 'entirely (`api_surface: False`). No "Collections" in the React nav, no second '
175
  'worklist, and the biweekly send keeps its door.'},
176
  {'key': 'returns', 'label': 'Returns', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
177
  'note': 'Credit-note lens: refund concentration by SKU (quality) and customer (behavior). Company-level.'},
 
180
  {'key': 'backorders', 'label': 'Backorders', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
181
  'note': 'Confirmed-undelivered order lines aged vs promise date, valued, with a supply-aware next action per row (ship / expedite / call). Wholesale scope.'},
182
  {'key': 'pricecomp', 'label': 'Price Compliance', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
183
+ 'note': 'Selling below the customer pricelist tier (LTM, per customer x SKU): the pocket-price floor worklist with annualized leak $. Sub-30% ratios usually mean a stale or pack-basis RULE. Fix the rule, not the rep.'},
184
  {'key': 'o2c', 'label': 'Cash Timing', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
185
  'note': 'Order-to-cash stage decomposition (order-to-ship / ship-to-invoice / invoice-to-paid, each with its owner) + the terms-gap rollup: contractual vs actual days per payment term with the free-credit $ it strands.'},
186
  {'key': 'bookings', 'label': 'Order Book', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
 
193
  'note': 'Operating expense from the GL (all expense-type accounts; COGS excluded): trend, operating leverage (opex % of revenue), the YoY cost bridge, XmR control-limit spike watch list, fixed/variable split and a drill-to-ledger category directory. Company-level.'},
194
  {'key': 'health', 'label': 'Data Health', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
195
  'note': 'Close/reconciliation scan; period-filtered, mostly company-level. ARCHITECTURE '
196
+ '(owner 2026-07-23): every discrepancy / potential-error marker lives here. '
197
  'procurement mapping gaps, count-trust (unverified on-hand counts), untracked-on-'
198
+ 'order. So operational workflows stay clean for doing the work.'},
199
  {'key': 'dictionary', 'label': 'Metric Dictionary', 'brand': False, 'hq': True, 'validate': True,
200
  'nav': False, 'validate_only': True,
201
+ 'note': 'PAGE RETIRED 2026-07-23 (owner: broken/not customer-facing). Row kept ONLY so '
202
  'validate.py keeps running the semantic-layer contracts (the Analyst grounding '
203
+ 'proof). No nav, no page. Wave 17 R8 ("I don\'t even know what it does. Delete '
204
  'them") made that literal: `validate_only` takes it out of the account menu too, '
205
  'which is the last place it was still visible. The proof survives; the door does not.'},
206
+ {'key': 'automation', 'label': 'Agents', 'brand': False, 'hq': True, 'validate': False,
207
  'nav': True,
208
+ 'note': 'Wave 18 (C-AUTONAV): scheduled jobs that create and refresh user databases '
209
+ '(website scrape-to-DB, the Instagram field). React-only surface (no PAGE_FUNCS '
210
+ 'entry, the no-new-Streamlit rule); admins hold it via "all", other users need the '
211
+ 'explicit grant, fail-closed default. '
212
+ '⭐ WAVE 34 · R12. THE LABEL IS "Agents" AND THE KEY IS STILL `automation`. The '
213
+ 'owner ruled the module becomes Agent: this is our agent deployment surface going '
214
+ 'forward. Only the LABEL moved, because the key is a permission scope, a nav '
215
+ 'placement key, a grant name and the `#/automation` route; renaming it would revoke '
216
+ 'every stored grant and 404 every bookmark to buy a caption. ⚠ The plural is not a '
217
+ 'slip: R15 and the wave PRD both spell the rail row "Agents", and the degraded '
218
+ 'fallback row in Shell.tsx hard-codes that spelling, so a singular here would paint '
219
+ 'two different words in one list depending on whether /nav answered.'},
220
  {'key': 'settings', 'label': 'Settings', 'brand': False, 'hq': True, 'validate': False,
221
  'nav': False, # sidebar sentinel (account group) β€” owner IA 2026-07-12
222
  'note': 'User scope settings: the Business Unit toggle (strict isolation) and the Data basis '
223
  'toggle (Orders vs Invoiced) moved here from the sidebar.'},
224
  {'key': 'analyst', 'label': 'AIOS Analyst', 'brand': False, 'hq': True, 'validate': False,
225
  'nav': False, # sidebar sentinel button; eval gate = harness/evals.py (run pre-ship, NOT in validate.py β€” live LLM cost)
226
+ 'note': 'Ask the business a question in plain language: a small AI model calls governed tools over the semantic layer. Answers carry their query trace and drill links. AI-generated output (Art. 50 labeled).'},
227
  ]
228
 
229
  BY_KEY = {m['key']: m for m in REGISTRY}
platform/core/user_tables.py CHANGED
@@ -336,7 +336,7 @@ def limit_report(table_key, st=None):
336
  'cause': f'this database is EDITABLE, so its rows live in the shared `{STORE_KEY}` '
337
  f'document that every request copies; at the widest shipped row (0.318 KB) '
338
  f'{cap:,} rows is 19.1 MB against a 32 MB per-table budget',
339
- 'recommendation': 'connect it to a source instead β€” connected data is served '
340
  'read-through from the mirror and is not bounded (R6); for typed data '
341
  'the increment is D-87, a per-table row key, not a bigger number',
342
  }
@@ -439,9 +439,15 @@ def records_mutable(table_key, st=None):
439
  #: waves because `verify_fields_contract` diffed the client's offer against the ODOO grid's
440
  #: acceptance and never against this set. Wave-27 item 13 adds that derived leg, which is what
441
  #: found it (measured, not reviewed: `_clean_field({'type':'image'})` returned None).
 
 
 
 
 
 
442
  UT_FIELD_TYPES = {'text', 'select', 'multiselect', 'user', 'int', 'currency', 'pct', 'date',
443
  'checkbox', 'phone', 'email', 'url', 'rating', 'automation', 'json',
444
- 'link', 'rollup', 'code', 'image', 'formula'}
445
  #: A cell is still a scalar on the wire (`Row` values are strings/numbers) β€” what this bounds is
446
  #: the DOCUMENT inside it. Source data is one already-paid provider response; keep it whole so a
447
  #: provider field is never silently discarded merely because the response is rich. 32 MiB matches
@@ -902,6 +908,19 @@ def clean_fields(raw):
902
  entry = None
903
  elif fx is not None:
904
  entry['formula'] = fx
 
 
 
 
 
 
 
 
 
 
 
 
 
905
  if entry is None:
906
  seen.discard(key)
907
  continue
@@ -1664,6 +1683,308 @@ def _clean_rollup(raw):
1664
  return out
1665
 
1666
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1667
  def _clean_field(raw, previous=None):
1668
  """One field dict β†’ the stored shape, or None. The single validator for create AND patch, so
1669
  a column cannot be typed one way on the way in and another on the way back."""
@@ -1899,6 +2220,28 @@ def _clean_field(raw, previous=None):
1899
  out['formula'] = fx
1900
  elif ftype == 'formula':
1901
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1902
  return out
1903
 
1904
 
@@ -2094,6 +2437,16 @@ def flow_bound(bag, st=None):
2094
  lives in `aios_grid._clean_automation` β€” this guards the WRITE doors only.)"""
2095
  if not isinstance(bag, dict):
2096
  return True # no bag, no law β€” an ordinary column
 
 
 
 
 
 
 
 
 
 
2097
  flow = str(bag.get('flowId') or '').strip()
2098
  if not flow:
2099
  return False
@@ -2399,6 +2752,41 @@ def patch_cells(table_key, row_id, values, st=None):
2399
  return True
2400
 
2401
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2402
  def patch_link_cell(table_key, row_id, fkey, value, st=None):
2403
  """Persist one user-picked Link cell in the shared row and return its canonical id string.
2404
 
 
336
  'cause': f'this database is EDITABLE, so its rows live in the shared `{STORE_KEY}` '
337
  f'document that every request copies; at the widest shipped row (0.318 KB) '
338
  f'{cap:,} rows is 19.1 MB against a 32 MB per-table budget',
339
+ 'recommendation': 'connect it to a source instead. Connected data is served '
340
  'read-through from the mirror and is not bounded (R6); for typed data '
341
  'the increment is D-87, a per-table row key, not a bigger number',
342
  }
 
439
  #: waves because `verify_fields_contract` diffed the client's offer against the ODOO grid's
440
  #: acceptance and never against this set. Wave-27 item 13 adds that derived leg, which is what
441
  #: found it (measured, not reviewed: `_clean_field({'type':'image'})` returned None).
442
+ #: ⭐ WAVE-34 (owner ruling R13) β€” `ai_enrich` JOINS, and it lands HERE FIRST rather than last.
443
+ #: The block above records that `image` reached four of the five surfaces and missed this one, so
444
+ #: the client offered a column the server deleted on the next read. `ai_enrich` is added to this
445
+ #: set in `W34-T51` (server) while the CLIENT half waits for `W34-T53`, which is the safe order:
446
+ #: a kind the server accepts and the client does not offer is invisible, whereas a kind the client
447
+ #: offers and the server refuses is a column created, named, configured and gone.
448
  UT_FIELD_TYPES = {'text', 'select', 'multiselect', 'user', 'int', 'currency', 'pct', 'date',
449
  'checkbox', 'phone', 'email', 'url', 'rating', 'automation', 'json',
450
+ 'link', 'rollup', 'code', 'image', 'formula', 'ai_enrich'}
451
  #: A cell is still a scalar on the wire (`Row` values are strings/numbers) β€” what this bounds is
452
  #: the DOCUMENT inside it. Source data is one already-paid provider response; keep it whole so a
453
  #: provider field is never silently discarded merely because the response is rich. 32 MiB matches
 
908
  entry = None
909
  elif fx is not None:
910
  entry['formula'] = fx
911
+ # ⭐⭐ WAVE-34 (R13) β€” the AI ENRICHMENT bag rides THIS door too, and it is the SIXTH time
912
+ # this seam has been fixed after the fact (`pinned`, `code`, `link`/`rollup`, `formula`,
913
+ # `agg`). It is written in the same change as `_clean_field`'s arm rather than a wave
914
+ # later, which is the only difference between this entry and the five above it.
915
+ if entry is not None:
916
+ ae = (_clean_ai_enrich(f.get('aiEnrich'))
917
+ if f.get('aiEnrich') is not None else None)
918
+ if (ftype == 'ai_enrich') != (ae is not None):
919
+ entry = None
920
+ elif ae is not None:
921
+ entry['aiEnrich'] = ae
922
+ # C3: derived, never typed beside the config. Same call as the other door.
923
+ entry['automation'] = _field_agent_binding(entry)
924
  if entry is None:
925
  seen.discard(key)
926
  continue
 
1683
  return out
1684
 
1685
 
1686
+ # ---------------------------------------------------------------------------------------------
1687
+ # THE AI ENRICHMENT COLUMN (wave 34, owner ruling R13)
1688
+ # ---------------------------------------------------------------------------------------------
1689
+ # R13: *"a field kind called AI enrichment: a prompt per row that populates text. Detailed
1690
+ # configuration in the field's own config, including a token-usage limit."*
1691
+ #
1692
+ # β›” POSTURE: `link`/`rollup`/`formula`'s, NOT `code`'s. The bag DEFINES the column β€” a prompt is
1693
+ # the only thing that can ever produce a value here β€” so the type/bag pairing is enforced BOTH
1694
+ # WAYS and either half alone refuses the field. An `ai_enrich` column with no prompt is a column
1695
+ # that renders, sorts, filters and can never hold anything, which is the silent kind of broken
1696
+ # every other bag on this door is paired to prevent.
1697
+ #
1698
+ # ⚠ AND THE VALUE IS STORED, NOT COMPUTED. `formula` is client-recomputed on every paint and
1699
+ # `rollup` is read-through; neither persists. An enrichment that vanished on reload would not be
1700
+ # the feature (PRD assumption A6), so an `ai_enrich` cell is an ordinary stored string, a human
1701
+ # may type over it, and it is deliberately NOT in `is_computed_cell`.
1702
+
1703
+ #: The bag's own key set, read by BOTH the cleaner and the reporter below so they cannot disagree
1704
+ #: about what "unknown" means. β›” ONE constant, because T51's contract is that an unknown key is
1705
+ #: DROPPED **and NAMED** β€” two hand-maintained lists would eventually drop a key the report did
1706
+ #: not mention, which reads to a user as the config silently not saving.
1707
+ AI_ENRICH_KEYS = ('prompt', 'model', 'maxTokens', 'trigger', 'overwrite')
1708
+ #: When the column runs. `manual` = only when a person asks; `on_change` = when a referenced cell
1709
+ #: moves; `schedule` = on a cadence. The community ask R13 cites ("enrichment on a schedule") is
1710
+ #: the third of these, so it is in the vocabulary from the start rather than added later.
1711
+ AI_ENRICH_TRIGGER_MODES = ('manual', 'on_change', 'schedule')
1712
+ #: What an AUTOMATIC run may replace. β›” A HUMAN-EDITED CELL IS NEVER OVERWRITTEN AND THAT IS NOT
1713
+ #: IN THIS VOCABULARY β€” it is a law (`ai_enrich_may_write`), not a policy, so no configuration can
1714
+ #: turn it off. These three decide only what happens to cells the AGENT itself wrote.
1715
+ AI_ENRICH_OVERWRITE = ('blank', 'stale', 'always')
1716
+ #: Bounded because the ceiling is the feature (R13). The floor is one useful sentence; the roof is
1717
+ #: what one cell can cost before the run has to say so out loud.
1718
+ AI_ENRICH_MIN_TOKENS, AI_ENRICH_MAX_TOKENS = 16, 4000
1719
+ AI_ENRICH_DEFAULT_TOKENS = 300
1720
+ #: A prompt is a template, not an essay. Long enough for real instructions plus a few `{field}`
1721
+ #: tokens, short enough that the per-cell input cost is predictable.
1722
+ AI_ENRICH_PROMPT_MAX = 2000
1723
+ #: `cron` is stored as an opaque bounded string. ⚠ It is NOT parsed here on purpose: this module
1724
+ #: owns the field contract, and the scheduler that reads it owns the cadence vocabulary. Parsing
1725
+ #: it in two places is how one door starts refusing a cadence the other accepts.
1726
+ AI_ENRICH_CRON_MAX = 120
1727
+
1728
+
1729
+ def _clean_ai_enrich(raw):
1730
+ """The `ai_enrich` config bag β†’ the stored shape, or None if the column cannot run.
1731
+
1732
+ ⚠ Every key is normalised to a canonical form, so a definition saved and re-read is
1733
+ byte-identical (`_clean_ai_enrich(_clean_ai_enrich(x)) == _clean_ai_enrich(x)`). That
1734
+ idempotence is what makes the round-trip clause testable rather than a claim.
1735
+ """
1736
+ if not isinstance(raw, dict):
1737
+ return None
1738
+ prompt = str(raw.get('prompt') or '').strip()[:AI_ENRICH_PROMPT_MAX]
1739
+ if not prompt:
1740
+ return None # the prompt IS the column; see the posture note above
1741
+ out = {'prompt': prompt}
1742
+ # The model is OPTIONAL and an unknown name is DROPPED rather than refused: it selects a
1743
+ # PROVIDER for a column that already knows what to ask, so a stale name should fall back to
1744
+ # the ladder's own order, never destroy the column. (`code`'s language argument, same shape.)
1745
+ model = str(raw.get('model') or '').strip().lower()[:60]
1746
+ if model:
1747
+ out['model'] = model
1748
+ tokens = raw.get('maxTokens')
1749
+ # `bool` is an `int` subclass β€” `True` would otherwise validate as a 1-token ceiling.
1750
+ if isinstance(tokens, bool) or not isinstance(tokens, (int, float)):
1751
+ tokens = AI_ENRICH_DEFAULT_TOKENS
1752
+ out['maxTokens'] = max(AI_ENRICH_MIN_TOKENS, min(int(tokens), AI_ENRICH_MAX_TOKENS))
1753
+ trig = raw.get('trigger') if isinstance(raw.get('trigger'), dict) else {}
1754
+ mode = str(trig.get('mode') or '').strip().lower()
1755
+ trigger = {'mode': mode if mode in AI_ENRICH_TRIGGER_MODES else 'manual'}
1756
+ # β›” `cron` is kept ONLY on a scheduled column. A cadence stored under `manual` is a setting
1757
+ # the user can see and nothing will ever read β€” the shape D-246 wears on the Odoo sync, where
1758
+ # a `manual` preset does nothing because no branch reads it.
1759
+ cron = str(trig.get('cron') or '').strip()[:AI_ENRICH_CRON_MAX]
1760
+ if trigger['mode'] == 'schedule' and cron:
1761
+ trigger['cron'] = cron
1762
+ out['trigger'] = trigger
1763
+ overwrite = str(raw.get('overwrite') or '').strip().lower()
1764
+ out['overwrite'] = overwrite if overwrite in AI_ENRICH_OVERWRITE else 'blank'
1765
+ return out
1766
+
1767
+
1768
+ def ai_enrich_dropped_keys(raw):
1769
+ """The config keys `_clean_ai_enrich` will NOT keep, sorted β€” the NAMING half of T51's
1770
+ "an unknown config key is DROPPED and NAMED, never silently eaten".
1771
+
1772
+ β›” IT EXISTS BECAUSE EVERY OTHER BAG CLEANER IN THIS FILE HAS NO ERROR CHANNEL. `_clean_link`,
1773
+ `_clean_rollup`, `_clean_profile` and `_clean_format` all return `dict | None` and drop
1774
+ unknown keys in silence β€” `verify_fields_contract` even asserts that they do. Copying one of
1775
+ them as a template gets the DROP right and the NAMING wrong by construction, so the report is
1776
+ a separate function reading the SAME `AI_ENRICH_KEYS` constant rather than a second list.
1777
+
1778
+ ⚠ It reports UNKNOWN keys only. A known key whose VALUE was normalised (an out-of-range
1779
+ ceiling clamped, an unknown model dropped) is not a surprise worth a sentence β€” the stored
1780
+ definition comes straight back to the editor, so the user sees the accepted value itself.
1781
+ """
1782
+ if not isinstance(raw, dict):
1783
+ return []
1784
+ return sorted(str(k) for k in raw if k not in AI_ENRICH_KEYS)
1785
+
1786
+
1787
+ def ai_enrich_fields(defn):
1788
+ """Every `ai_enrich` column in this table definition, in declaration order.
1789
+
1790
+ One reader for "which columns are field agents", so the Agents module (contract C3), the
1791
+ runner and the scheduler cannot disagree about the set ([[one-evaluator-per-question]]).
1792
+ """
1793
+ return [f for f in ((defn or {}).get('fields') or [])
1794
+ if isinstance(f, dict) and f.get('type') == 'ai_enrich'
1795
+ and isinstance(f.get('aiEnrich'), dict)]
1796
+
1797
+
1798
+ def _field_agent_binding(field):
1799
+ """Contract C3's `field.automation` bag, DERIVED from the column rather than typed beside it.
1800
+
1801
+ β›”β›” THIS IS DERIVED ON PURPOSE AND IT IS THE ONE DESIGN CALL IN T51 WORTH ARGUING WITH.
1802
+ C3 as drafted has F *store* `{kind, table, field, trigger}` β€” but `field` is already
1803
+ `field['key']` and `trigger` is already `aiEnrich['trigger']`, so two of its four keys would
1804
+ be a second copy of a fact this same dict already carries. Two copies of one fact is
1805
+ [[one-question-two-normalizers]] with a guaranteed drift date: the first PATCH that changes
1806
+ the trigger through the enrichment editor and not through the automation editor.
1807
+ Computing it inside the SINGLE validator both write doors run means they cannot diverge.
1808
+
1809
+ ⚠ `table` IS ABSENT, and that is the deviation E must know about: this function is handed one
1810
+ field dict and no table context, while `_clean_field(f) == f` is asserted by two gates over
1811
+ engine-seeded field lists β€” stamping the table key from a caller that happens to know it would
1812
+ make a stored field unequal to its own validator's output. E iterates tables to build the
1813
+ synthetic rows, so E already holds the key that would go here.
1814
+ """
1815
+ bag = (field or {}).get('aiEnrich')
1816
+ if not isinstance(bag, dict):
1817
+ return None
1818
+ return {'kind': 'field_agent', 'field': str((field or {}).get('key') or ''),
1819
+ 'trigger': dict(bag.get('trigger') or {'mode': 'manual'})}
1820
+
1821
+
1822
+ # ---------------------------------------------------------------------------------------------
1823
+ # PER-CELL PROVENANCE (wave 34, W34-T51 β€” "stamp it FROM DAY ONE")
1824
+ # ---------------------------------------------------------------------------------------------
1825
+ # β›”β›” IT CANNOT LIVE IN THE ROW, AND THAT IS MEASURED RATHER THAN ASSUMED. `add_row` builds its
1826
+ # row as `{k: str(v) for k, v in values.items() if k in valid}` where `valid` is the declared
1827
+ # field keys β€” a row dict is field-keys-only by construction, so a reserved `__ai__` key inside
1828
+ # one would be stripped on the way in. Widening that filter to admit a private key would weaken
1829
+ # the wall that stops a client inventing columns. So provenance is a SIBLING STRATUM of `rows`,
1830
+ # exactly as `fields` and `rows` are siblings today.
1831
+ #
1832
+ # β›” AND `stale` IS DERIVED, NEVER STORED. A stored `stale` flag needs a sweep to stay true, and a
1833
+ # sweep nobody runs is [[flag-shipped-without-its-writer]]. What IS stored is the INPUT HASH the
1834
+ # value was produced from; `ai_enrich_is_stale` compares it to today's inputs, so staleness is a
1835
+ # question asked at read time and can never be out of date.
1836
+
1837
+ #: The stratum's key inside a table document, beside `fields` and `rows`.
1838
+ AI_ENRICH_MARK_KEY = 'aiEnrich'
1839
+ #: What a mark can say. ⚠ `stale` is absent ON PURPOSE (see above) and so is `generating`: an
1840
+ #: in-flight run is a fact about a REQUEST, not about stored data, and persisting it would leave
1841
+ #: a cell stuck on "generating" forever the first time a process dies mid-run.
1842
+ AI_ENRICH_STATES = ('agent', 'human', 'error')
1843
+ #: A bound on the stratum, in the shape `row_limit` uses: this document is copied whole on every
1844
+ #: read, so a mark set that can outgrow the rows it describes is a store problem wearing a
1845
+ #: feature's name. One mark per (row, column) is the natural size; the cap is the guard against a
1846
+ #: column deleted without its marks.
1847
+ AI_ENRICH_MAX_MARKS = MAX_ROWS
1848
+ #
1849
+ # ⚠ THERE IS DELIBERATELY NO `drop_marks(column)`, AND THE ABSENCE IS THE DECISION. `delete_field`
1850
+ # leaves a deleted column's CELLS in the rows on purpose β€” an accidental delete stays recoverable,
1851
+ # and re-adding the column under the same key brings the values back. Marks must follow the same
1852
+ # rule or a restored column's agent-written values would come back looking human-authored, which
1853
+ # is the one distinction this whole stratum exists to make. A table that is deleted takes its
1854
+ # document (and therefore its marks) with it, so neither lifecycle leaks.
1855
+
1856
+
1857
+ def ai_enrich_input_hash(prompt, row, refs):
1858
+ """The fingerprint a stored value was produced FROM: the prompt plus the referenced cells.
1859
+
1860
+ ⚠ `refs` is passed in rather than re-parsed here, so the token vocabulary is owned by one
1861
+ module (`api/ai_enrich.py::prompt_refs`) instead of two. A hash built from a second parse
1862
+ would disagree with the runner's the first time the token syntax gains a form.
1863
+ """
1864
+ payload = [str(prompt or '')]
1865
+ for key in sorted(str(r) for r in (refs or ())):
1866
+ payload.append(f'{key}={(row or {}).get(key, "")}')
1867
+ return hashlib.sha256('\x1f'.join(payload).encode('utf-8')).hexdigest()[:32]
1868
+
1869
+
1870
+ def ai_enrich_marks(table_key, col_id, st=None):
1871
+ """`{row_id: mark}` for ONE column. Absent = a cell nobody has written yet."""
1872
+ stratum = (get(table_key, st) or {}).get(AI_ENRICH_MARK_KEY) or {}
1873
+ got = stratum.get(str(col_id))
1874
+ return dict(got) if isinstance(got, dict) else {}
1875
+
1876
+
1877
+ def ai_enrich_is_stale(mark, fresh_hash):
1878
+ """Was this value produced from inputs that have since changed?
1879
+
1880
+ A human edit is never stale β€” a person's own words do not go out of date because a
1881
+ neighbouring cell moved. Only an agent-written value can be.
1882
+ """
1883
+ if not isinstance(mark, dict) or mark.get('state') != 'agent':
1884
+ return False
1885
+ return bool(fresh_hash) and str(mark.get('hash') or '') != str(fresh_hash)
1886
+
1887
+
1888
+ def ai_enrich_human_authored(mark, has_value=False):
1889
+ """Did somebody other than the agent put this value here?
1890
+
1891
+ β›” TWO CASES, AND THE SECOND IS THE ONE THAT GETS MISSED. An explicit `human` mark is obvious.
1892
+ The other is a cell that holds a value with NO mark at all: a value typed, pasted or imported
1893
+ before the column became an enrichment column. The agent did not write it, so the agent does
1894
+ not own it. Without this arm, `overwrite: 'always'` would eat exactly the pre-existing work
1895
+ R8's *"we are helping the team put their work into the system"* is about.
1896
+
1897
+ ⚠ ONE PREDICATE, because `ai_enrich_may_write` and `api/ai_enrich.py::cell_state` both answer
1898
+ "is this the human's?" and a cell the UI labels human-written while the runner overwrites it
1899
+ is the worst of both ([[one-question-two-normalizers]]).
1900
+ """
1901
+ if isinstance(mark, dict) and mark.get('state') == 'human':
1902
+ return True
1903
+ return bool(has_value) and not (isinstance(mark, dict) and mark.get('state') == 'agent')
1904
+
1905
+
1906
+ def ai_enrich_may_write(mark, policy, fresh_hash, has_value=False):
1907
+ """May an AUTOMATIC run write this cell? The law, in one place.
1908
+
1909
+ β›” THE FIRST CLAUSE IS NOT CONFIGURABLE. A cell a person typed into is never overwritten by an
1910
+ agent, whatever `overwrite` says β€” that is why `human` is not one of the `AI_ENRICH_OVERWRITE`
1911
+ values, and why `always` means "always refresh what the AGENT owns" rather than "overwrite
1912
+ everything". A policy that could turn it off would make the column unsafe to hand to a team.
1913
+ ⚠ A MANUAL, per-cell run is a different act with a different door; this predicate governs the
1914
+ automatic paths (`on_change`, `schedule`, and a bulk "all rows" sweep).
1915
+ """
1916
+ if ai_enrich_human_authored(mark, has_value):
1917
+ return False
1918
+ if not has_value:
1919
+ return True # a blank cell is fillable under every policy
1920
+ if policy == 'always':
1921
+ return True
1922
+ if policy == 'stale':
1923
+ return ai_enrich_is_stale(mark, fresh_hash)
1924
+ return False # 'blank': an agent never replaces a value it wrote
1925
+
1926
+
1927
+ def stamp_ai_enrich(table_key, col_id, marks, st=None):
1928
+ """Write MANY marks for one column in ONE store update. Returns the number stored.
1929
+
1930
+ β›” BULK BY CONSTRUCTION, and the signature is what enforces it. Every write here is a
1931
+ read-modify-write of the whole tenant document, so a per-row stamp inside a run loop is the
1932
+ same shape as the 2,000-full-document-copies problem `add_rows` exists to avoid. There is
1933
+ deliberately no `stamp_one`.
1934
+ """
1935
+ col_id = str(col_id)
1936
+ clean = {}
1937
+ for row_id, mark in (marks or {}).items():
1938
+ if not isinstance(mark, dict):
1939
+ continue
1940
+ state = str(mark.get('state') or '')
1941
+ if state not in AI_ENRICH_STATES:
1942
+ continue
1943
+ entry = {'state': state, 'at': _dt.datetime.now(_dt.timezone.utc)
1944
+ .replace(microsecond=0).isoformat()}
1945
+ for key, cap in (('hash', 64), ('model', 60), ('error', 300)):
1946
+ got = str(mark.get(key) or '').strip()[:cap]
1947
+ if got:
1948
+ entry[key] = got
1949
+ tokens = mark.get('tokens')
1950
+ if isinstance(tokens, int) and not isinstance(tokens, bool) and tokens >= 0:
1951
+ entry['tokens'] = tokens
1952
+ clean[str(row_id)] = entry
1953
+ if not clean:
1954
+ return 0
1955
+
1956
+ def _set(cur):
1957
+ t = cur.get(str(table_key))
1958
+ if t is not None:
1959
+ col = t.setdefault(AI_ENRICH_MARK_KEY, {}).setdefault(col_id, {})
1960
+ col.update(clean)
1961
+ # The cap is enforced by DROPPING THE OLDEST, never by refusing the write: a run that
1962
+ # produced values and could not record where they came from would leave the cells
1963
+ # looking human-authored, which is the one state this stratum exists to distinguish.
1964
+ if len(col) > AI_ENRICH_MAX_MARKS:
1965
+ for rid in sorted(col, key=lambda r: str(col[r].get('at') or ''))[
1966
+ :len(col) - AI_ENRICH_MAX_MARKS]:
1967
+ col.pop(rid, None)
1968
+ return cur
1969
+
1970
+ _st(st).update(STORE_KEY, _set, flush='async')
1971
+ return len(clean)
1972
+
1973
+
1974
+ def note_human_edit(table_key, values, row_id, st=None):
1975
+ """A person typed into these cells: mark any `ai_enrich` column among them `human`.
1976
+
1977
+ β›” THE STAMP HAS TO HAPPEN AT THE WRITE DOOR, not at read time, because "did a human write
1978
+ this" is not recoverable from the value afterwards. Called by `routes_tables.patch_row`;
1979
+ a no-op (and cheap) for the overwhelming case where the edited columns are ordinary.
1980
+ """
1981
+ defn = get(table_key, st) or {}
1982
+ touched = {f['key'] for f in ai_enrich_fields(defn)} & {str(k) for k in (values or {})}
1983
+ for col_id in touched:
1984
+ stamp_ai_enrich(table_key, col_id, {str(row_id): {'state': 'human'}}, st=st)
1985
+ return sorted(touched)
1986
+
1987
+
1988
  def _clean_field(raw, previous=None):
1989
  """One field dict β†’ the stored shape, or None. The single validator for create AND patch, so
1990
  a column cannot be typed one way on the way in and another on the way back."""
 
2220
  out['formula'] = fx
2221
  elif ftype == 'formula':
2222
  return None
2223
+ # ⭐⭐ WAVE-34 (owner ruling R13) β€” THE AI ENRICHMENT BAG. Posture and inheritance copied from
2224
+ # `formula` two lines up, deliberately and for the same reason: the bag defines the column, so
2225
+ # the pairing is enforced both ways, and an inherited bag is only inherited while the type
2226
+ # still wants it (retyping an enrichment column to text must not resurrect its prompt through
2227
+ # `prev` and then refuse the whole write, which is the bug the `prev_type == ftype` guard on
2228
+ # `link`/`rollup`/`formula` exists to record).
2229
+ enrich_raw = (raw.get('aiEnrich') if 'aiEnrich' in raw
2230
+ else (prev.get('aiEnrich') if prev_type == ftype else None))
2231
+ if enrich_raw is not None:
2232
+ ae = _clean_ai_enrich(enrich_raw)
2233
+ if ae is None or ftype != 'ai_enrich':
2234
+ return None
2235
+ out['aiEnrich'] = ae
2236
+ elif ftype == 'ai_enrich':
2237
+ return None
2238
+ # C3 (wave 34): the Agents module reads field agents off this bag. DERIVED here, inside the
2239
+ # one validator both write doors run, so the binding can never drift from the config it
2240
+ # describes β€” see `_field_agent_binding` for why it is computed rather than stored beside it.
2241
+ # β›” It is written AFTER the generic `automation` passthrough above, so a caller cannot hand us
2242
+ # a `field_agent` bag that disagrees with the column. Every OTHER automation bag is untouched.
2243
+ if ftype == 'ai_enrich':
2244
+ out['automation'] = _field_agent_binding(out)
2245
  return out
2246
 
2247
 
 
2437
  lives in `aios_grid._clean_automation` β€” this guards the WRITE doors only.)"""
2438
  if not isinstance(bag, dict):
2439
  return True # no bag, no law β€” an ordinary column
2440
+ # ⭐⭐ WAVE-34 (R13 / contract C3) β€” A FIELD AGENT IS BOUND BY ITS OWN COLUMN, NOT BY A FLOW,
2441
+ # and without this arm the whole feature is unsavable. `_clean_field` stamps
2442
+ # `{kind:'field_agent', ...}` on every `ai_enrich` column; `add_field` then asks this
2443
+ # predicate, which read `flowId` and only `flowId` β€” so every AI enrichment column would have
2444
+ # been refused at the create door by the guard for a different feature that happens to share
2445
+ # the `automation` key. ⚠ The binding is REAL, not waived: the bag names the column it belongs
2446
+ # to, and `_clean_field` derives that name from the field itself, so it cannot name a column
2447
+ # that does not exist the way a stale `flowId` can name a deleted flow.
2448
+ if bag.get('kind') == 'field_agent':
2449
+ return bool(str(bag.get('field') or '').strip())
2450
  flow = str(bag.get('flowId') or '').strip()
2451
  if not flow:
2452
  return False
 
2752
  return True
2753
 
2754
 
2755
+ def patch_many_cells(table_key, updates, st=None):
2756
+ """Write cells across MANY rows in ONE store update. Returns the number of rows touched.
2757
+
2758
+ β›” WHY THIS EXISTS RATHER THAN A LOOP OVER `patch_cells`, which is the same argument
2759
+ `add_rows` makes one screen up: every write here is a read-modify-write of the WHOLE tenant
2760
+ document, so filling a 1,000-row enrichment column row by row is 1,000 full-document copies
2761
+ under one lock on the single uvicorn process this product runs. One `update()` writes them
2762
+ all.
2763
+
2764
+ ⚠ `updates` is `{row_id: {field_key: value}}`, and a row id that does not exist is SKIPPED
2765
+ rather than created: this is a cell writer, not an insert door, and inventing a row here would
2766
+ let a stale plan resurrect a record somebody deleted mid-run.
2767
+ """
2768
+ if not is_user_table(table_key, st):
2769
+ return 0
2770
+ have = set((get(table_key, st) or {}).get('rows') or {})
2771
+ clean = {str(r): {str(k): str(v) for k, v in (cells or {}).items()}
2772
+ for r, cells in (updates or {}).items()
2773
+ if str(r) in have and isinstance(cells, dict) and cells}
2774
+ if not clean:
2775
+ return 0
2776
+
2777
+ def _set(cur):
2778
+ t = cur.get(str(table_key))
2779
+ if t is not None:
2780
+ rows = t.setdefault('rows', {})
2781
+ for rid, cells in clean.items():
2782
+ if rid in rows:
2783
+ rows[rid].update(cells)
2784
+ return cur
2785
+
2786
+ _st(st).update(STORE_KEY, _set, flush='sync')
2787
+ return len(clean)
2788
+
2789
+
2790
  def patch_link_cell(table_key, row_id, fkey, value, st=None):
2791
  """Persist one user-picked Link cell in the shared row and return its canonical id string.
2792
 
web/src/assistant/AssistantPage.tsx CHANGED
@@ -1,20 +1,81 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
 
3
  import { QUERY_OPEN_EVENT, signal } from "../apiContract";
4
  import { FolderMark } from "../customer-grid/icons";
5
  import { queryCitationLabel } from "../customer-grid/queryPreview";
6
  import { retryEmit } from "../inbox/inboxModel";
 
7
  import { DbIcon } from "../shell/dbFrame";
8
- import { databaseEntries, QUERY_ROUTE } from "../shell/nav";
9
  import type { NavEntry } from "../shell/nav";
10
  import {
11
- deleteThread, fetchQueries, submitChat, type QueryCitation, type QueryIndex,
12
- type QueryMessage, type SavedQuery,
13
  } from "../query/queryApi";
14
  import "./assistant.css";
15
 
 
 
 
 
 
 
 
 
 
16
  export interface AssistantPageProps { granted: NavEntry[]; }
17
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
18
  const KIND_LABEL: Record<string, string> = {
19
  grid: "table", list: "list", chart: "chart", kanban: "board", calendar: "calendar",
20
  timeseries: "time series", map: "map",
@@ -30,17 +91,42 @@ const KIND_LABEL: Record<string, string> = {
30
  * whatever the server sent β€” an unknown key falls through to itself rather than being hidden, so a
31
  * provider added server-side appears here the day it is added instead of the day someone
32
  * remembers to edit this file [[a-flag-can-ship-without-its-writer]].
 
 
 
 
 
 
 
 
33
  */
34
  const MODEL_LABEL: Record<string, string> = {
35
  auto: "Auto", cerebras: "Cerebras", groq: "Groq", openrouter: "OpenRouter",
36
- anthropic: "Claude", openai: "OpenAI",
37
  };
38
  const modelLabel = (key: string) => MODEL_LABEL[key] ?? key;
39
 
40
- /** Route to the exact Query-owned artefact. C owns the destination wiring, not this page. */
41
- export function openBuiltView(qid: string): () => void {
 
 
 
 
 
 
 
 
 
 
42
  if (typeof window === "undefined") return () => {};
43
- if (window.location.hash.replace(/^#\/?/, "") !== QUERY_ROUTE) window.location.hash = `#/${QUERY_ROUTE}`;
 
 
 
 
 
 
 
 
44
  return retryEmit(() => signal(QUERY_OPEN_EVENT, { qid }));
45
  }
46
 
@@ -54,10 +140,25 @@ export function CitationLine({ citation }: { citation: QueryCitation }) {
54
  return <a className="as-citation" href={citation.href}>{queryCitationLabel(citation)}</a>;
55
  }
56
 
57
- export function AssistantMessage({ message, view, citations, onPreview }: {
 
 
 
 
 
 
 
 
 
 
 
58
  message: QueryMessage; view?: SavedQuery; citations: QueryCitation[]; onPreview: (id: string) => void;
 
 
59
  }) {
60
  const [openSources, setOpenSources] = useState(false);
 
 
61
  const matching = citations.filter((citation) => (message.citationIds || []).includes(citation.id));
62
  if (message.role === "user") {
63
  return (
@@ -85,6 +186,38 @@ export function AssistantMessage({ message, view, citations, onPreview }: {
85
  <span className="as-preview-meta">{`Open the ${KIND_LABEL[view.kind] ?? view.kind} in Query`}</span>
86
  </button>
87
  ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
88
  {matching.length ? (
89
  <div className="as-sources">
90
  <button type="button" className="as-sources-btn" aria-expanded={openSources}
@@ -103,7 +236,8 @@ export function AssistantMessage({ message, view, citations, onPreview }: {
103
  }
104
 
105
  function initialIndex(): QueryIndex {
106
- return { threads: [], messages: [], views: [], citations: [], models: ["auto"], sources: [] };
 
107
  }
108
 
109
  /**
@@ -147,8 +281,14 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
147
  const [problem, setProblem] = useState("");
148
  const [loaded, setLoaded] = useState(false);
149
  const [confirmThread, setConfirmThread] = useState("");
 
 
 
 
150
  const openCancel = useRef<(() => void) | null>(null);
151
  const foot = useRef<HTMLDivElement | null>(null);
 
 
152
 
153
  const databases = useMemo(() => databaseEntries(granted).filter((entry) => entry.kind !== "group"), [granted]);
154
  const labels = useMemo(() => new Map(databases.map((entry) => [entry.key, entry.label])), [databases]);
@@ -171,12 +311,24 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
171
  const messages = index.messages.filter((row) => row.threadId === activeThread);
172
  const viewById = useMemo(() => new Map(index.views.map((view) => [view.id, view])), [index.views]);
173
 
 
 
 
 
 
 
 
174
  const reload = useCallback(async () => {
175
  const result = await fetchQueries();
176
  setLoaded(true);
177
  if (result.ok) {
178
  setIndex(result.value);
179
- setModel((current) => result.value.models.includes(current) ? current : "auto");
 
 
 
 
 
180
  }
181
  }, []);
182
 
@@ -190,6 +342,62 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
190
  // The conversation reads bottom-up, like every chat the owner named.
191
  useEffect(() => { foot.current?.scrollIntoView({ block: "end" }); }, [messages.length, busy]);
192
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
193
  const newChat = useCallback(() => {
194
  setActiveThread(""); setQuestion(""); setProblem("");
195
  }, []);
@@ -202,8 +410,8 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
202
  const permitted = databases.map((entry) => entry.key);
203
  const sources = (row.sources || []).filter((key) => permitted.includes(key));
204
  if (sources.length) { setSelected(sources); setTarget(sources[0]); }
205
- if (row.model && index.models.includes(row.model)) setModel(row.model);
206
- }, [databases, index.models, index.threads]);
207
 
208
  const toggleSource = useCallback((key: string) => {
209
  if (blockedReason(key)) return;
@@ -214,20 +422,48 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
214
  });
215
  }, [blockedReason]);
216
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
217
  const ask = useCallback(async () => {
218
  const text = question.trim();
219
  if (!text || !target || !selected.includes(target) || busy) return;
220
- setBusy(true); setProblem("");
 
 
 
 
 
 
 
 
221
  const result = await submitChat({ question: text, database: target, sources: selected,
222
  ...(activeThread ? { threadId: activeThread } : {}), model });
223
  setBusy(false);
224
- if (!result.ok) { setProblem(result.message); return; }
225
- setQuestion("");
 
 
 
 
226
  setActiveThread(result.value.thread.id);
227
  setIndex((current) => ({
228
  ...current,
229
  threads: [result.value.thread, ...current.threads.filter((row) => row.id !== result.value.thread.id)],
230
- messages: [...current.messages, result.value.userMessage, result.value.message],
 
 
231
  views: result.value.view ? [result.value.view, ...current.views.filter((row) => row.id !== result.value.view?.id)] : current.views,
232
  citations: [...current.citations, ...result.value.citations],
233
  }));
@@ -246,11 +482,26 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
246
  setConfirmThread("");
247
  }, []);
248
 
249
- const preview = useCallback((qid: string) => {
250
- openCancel.current?.();
251
- openCancel.current = openBuiltView(qid);
 
 
 
 
 
 
 
 
 
252
  }, []);
253
 
 
 
 
 
 
 
254
  const empty = messages.length === 0;
255
  const composer = (
256
  <div className="as-composer">
@@ -274,9 +525,15 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
274
  <span className="as-pick-label">Model</span>
275
  <select className="as-pick-select" value={model} aria-label="Model"
276
  onChange={(event) => setModel(event.currentTarget.value)}>
277
- {index.models.map((choice) => (
278
- <option key={choice} value={choice}>{modelLabel(choice)}</option>
279
- ))}
 
 
 
 
 
 
280
  </select>
281
  </label>
282
  {/* ⚠ ONLY WHEN THE ANSWER IS AMBIGUOUS. Every generated view names ONE database (R3);
@@ -338,6 +595,23 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
338
  <div className="as-chat" aria-label="AI assistant">
339
  {/* Airtable's panel: one door to a new chat, then the history. */}
340
  <aside className="as-history">
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  <button type="button" className="as-new" onClick={newChat}>
342
  <svg viewBox="0 0 16 16" aria-hidden="true" width="14" height="14">
343
  <path d="M8 3.5v9M3.5 8h9" />
@@ -367,9 +641,27 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
367
  <p className="as-thread-empty">No chats yet.</p>
368
  ) : null}
369
  </nav>
 
 
370
  </aside>
371
  <main className="as-conversation">
372
- {empty ? (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  <div className="as-opening">
374
  <h1 className="as-opening-h">How can I help?</h1>
375
  <p className="as-opening-p">
@@ -385,10 +677,16 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
385
  {messages.map((message) => (
386
  <AssistantMessage key={message.id} message={message}
387
  view={message.viewId ? viewById.get(message.viewId) : undefined}
388
- citations={index.citations} onPreview={preview} />
 
 
389
  ))}
 
 
 
 
390
  {busy ? (
391
- <p className="as-working" role="status">
392
  <span className="lp-spin" aria-hidden="true" />
393
  </p>
394
  ) : null}
 
1
+ import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
2
 
3
  import { QUERY_OPEN_EVENT, signal } from "../apiContract";
4
  import { FolderMark } from "../customer-grid/icons";
5
  import { queryCitationLabel } from "../customer-grid/queryPreview";
6
  import { retryEmit } from "../inbox/inboxModel";
7
+ import { queryGroups, QueryRail } from "../query/queryParts";
8
  import { DbIcon } from "../shell/dbFrame";
9
+ import { ASSISTANT_ROUTE, databaseEntries, QUERY_ROUTE, routeKeyOf } from "../shell/nav";
10
  import type { NavEntry } from "../shell/nav";
11
  import {
12
+ deleteQuery, deleteThread, fetchQueries, rateMessage, submitChat, type QueryCitation,
13
+ type QueryIndex, type QueryMessage, type SavedQuery,
14
  } from "../query/queryApi";
15
  import "./assistant.css";
16
 
17
+ /**
18
+ * ⭐ LAZY ON PURPOSE. `QueryPage` imports `CustomerGrid`, the largest module in the client; a
19
+ * static import here would put the whole grid in the chat bundle, so opening the Assistant to ask
20
+ * one question would download the surface it is merged WITH but not showing. The list itself
21
+ * comes from `queryParts`, which imports no grid, so the left panel renders in Query mode before
22
+ * this chunk arrives.
23
+ */
24
+ const QueryWorkspace = lazy(() => import("../query/QueryPage"));
25
+
26
  export interface AssistantPageProps { granted: NavEntry[]; }
27
 
28
+ /**
29
+ * ⭐⭐ R14 / CONTRACT C2 β€” ONE MODULE, ONE ROUTE, TWO LISTS. Owner, 2026-08-16: *"Combine the AI
30
+ * assistant module AND the Query module. In the secondary navigation at the top, just above
31
+ * '+ New chat', an easily accessible toggle between Chat / Query; the list below then shows either
32
+ * Chats or Queries."*
33
+ *
34
+ * The toggle changes WHICH LIST the left panel shows, never which route you are on: `#/assistant`
35
+ * serves both, and `#/query` redirects here carrying `?mode=query`.
36
+ */
37
+ export type AssistantMode = "chat" | "query";
38
+ const MODE_KEY = "aios-assistant-mode";
39
+ const isMode = (value: unknown): value is AssistantMode => value === "chat" || value === "query";
40
+
41
+ /**
42
+ * The hash wins on arrival, the stored choice answers a reload.
43
+ *
44
+ * `?mode=query` is the REDIRECT'S INSTRUCTION β€” somebody followed a `#/query` link or bookmark β€”
45
+ * and a preference stored days ago must not override the link just clicked. Everything else reads
46
+ * `localStorage`, which is what makes the choice survive a reload.
47
+ *
48
+ * ⚠ THE TOGGLE STILL DOES NOT WRITE THE HASH, AND THE REASON HAS CHANGED β€” corrected here rather
49
+ * than left, because a stale comment that later readers quote is this wave's own worst defect.
50
+ * It USED to be a hard constraint: `Shell.tsx::read` took the whole remainder of the hash as the
51
+ * route key, so writing `#/assistant?mode=query` resolved to no route and landed on Home. B's
52
+ * `routeKeyOf` splits on `?` now (NOTE B-8), so writing it would be safe. It is still not written,
53
+ * for a smaller reason: which LIST a panel shows is a preference, not a location, and the hash is
54
+ * B's to own. The arrival parameter is consumed once, below, and stored.
55
+ * Pure and exported so the decision can be checked without a browser.
56
+ */
57
+ export function initialMode(hash: string, stored: string | null): AssistantMode {
58
+ const mark = hash.indexOf("?");
59
+ if (mark >= 0) {
60
+ const asked = new URLSearchParams(hash.slice(mark + 1)).get("mode");
61
+ if (isMode(asked)) return asked;
62
+ }
63
+ return isMode(stored) ? stored : "chat";
64
+ }
65
+
66
+ /** A private window refuses storage; that is a lost preference, never an error the reader sees. */
67
+ function readMode(): AssistantMode {
68
+ if (typeof window === "undefined") return "chat";
69
+ let stored: string | null = null;
70
+ try { stored = window.localStorage.getItem(MODE_KEY); } catch { stored = null; }
71
+ return initialMode(window.location.hash, stored);
72
+ }
73
+
74
+ function storeMode(mode: AssistantMode): void {
75
+ if (typeof window === "undefined") return;
76
+ try { window.localStorage.setItem(MODE_KEY, mode); } catch { /* see readMode */ }
77
+ }
78
+
79
  const KIND_LABEL: Record<string, string> = {
80
  grid: "table", list: "list", chart: "chart", kanban: "board", calendar: "calendar",
81
  timeseries: "time series", map: "map",
 
91
  * whatever the server sent β€” an unknown key falls through to itself rather than being hidden, so a
92
  * provider added server-side appears here the day it is added instead of the day someone
93
  * remembers to edit this file [[a-flag-can-ship-without-its-writer]].
94
+ *
95
+ * β›” `anthropic: "Claude"` AND `openai: "OpenAI"` ARE GONE, and the reason is worth keeping because
96
+ * the ticket that removed them described the defect backwards. They were never OFFERED: the picker
97
+ * renders `index.models`, which is `model_choices()` = `[auto, *QUERY_PROVIDER_ORDER]`, so no
98
+ * reader ever saw "Claude" in this control. They were labels for providers that do not exist in
99
+ * the platform ladder, i.e. dead weight, and the fall-through above already covers a provider
100
+ * added server-side. The REAL defect the ticket named lives one layer down and is fixed by
101
+ * `modelStatus`: a model that IS offered and whose key this deployment does not hold.
102
  */
103
  const MODEL_LABEL: Record<string, string> = {
104
  auto: "Auto", cerebras: "Cerebras", groq: "Groq", openrouter: "OpenRouter",
 
105
  };
106
  const modelLabel = (key: string) => MODEL_LABEL[key] ?? key;
107
 
108
+ /**
109
+ * Route to the exact Query-owned artefact. C owns the destination wiring, not this page.
110
+ *
111
+ * ⭐ `hosted` is R14's one change and it is a NAVIGATION suppressor, not a second path: when this
112
+ * surface is itself showing Query, there is nowhere to route TO β€” the workspace is already mounted
113
+ * in the main column β€” and setting the hash would send the reader to the standalone route we just
114
+ * merged away from. The qid contract is unchanged either way, which is the point: the same public
115
+ * hand-off answers an external link and an in-page click, so Query keeps ONE selection door.
116
+ * ⚠ The retry ladder still earns its keep here: the hosted workspace is lazy, so the event can
117
+ * fire before its chunk has finished loading and its listener exists.
118
+ */
119
+ export function openBuiltView(qid: string, hosted = false): () => void {
120
  if (typeof window === "undefined") return () => {};
121
+ // β›” `routeKeyOf`, NOT `hash.replace(...)` (B's NOTE B-14). Since T15 the hash is
122
+ // `#/assistant?mode=query`, so the whole-remainder idiom yields the key `assistant?mode=query`,
123
+ // which is never `query` β€” the guard was permanently true and its intent ("do not navigate, we
124
+ // are already there") had become unsatisfiable by any hash. Latent rather than live, because the
125
+ // one caller passes `hosted`, and fixed rather than left because a guard that cannot be
126
+ // satisfied is the kind a later reader trusts.
127
+ if (!hosted && routeKeyOf(window.location.hash) !== QUERY_ROUTE) {
128
+ window.location.hash = `#/${QUERY_ROUTE}`;
129
+ }
130
  return retryEmit(() => signal(QUERY_OPEN_EVENT, { qid }));
131
  }
132
 
 
140
  return <a className="as-citation" href={citation.href}>{queryCitationLabel(citation)}</a>;
141
  }
142
 
143
+ /** One glyph, drawn once and turned over for the other. R20 asked for thumbs; these are thumbs. */
144
+ function ThumbIcon({ down = false }: { down?: boolean }) {
145
+ return (
146
+ <svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"
147
+ {...(down ? { transform: "rotate(180)" } : {})}>
148
+ <path d="M5.6 14.2V6.9h1.5l2.2-4.3a1 1 0 0 1 1.8.9l-.7 2.6h2.4a1 1 0 0 1 1 1.3l-1.1 4.6a1.5 1.5 0 0 1-1.5 1.1H5.6z" />
149
+ <path d="M5.6 6.9H2.6v7.3h3" />
150
+ </svg>
151
+ );
152
+ }
153
+
154
+ export function AssistantMessage({ message, view, citations, onPreview, onRate }: {
155
  message: QueryMessage; view?: SavedQuery; citations: QueryCitation[]; onPreview: (id: string) => void;
156
+ /** Optional so the message can be rendered outside a live conversation without a rating door. */
157
+ onRate?: (id: string, rating: "up" | "down" | null, reason?: string) => void;
158
  }) {
159
  const [openSources, setOpenSources] = useState(false);
160
+ const [askWhy, setAskWhy] = useState(false);
161
+ const [why, setWhy] = useState("");
162
  const matching = citations.filter((citation) => (message.citationIds || []).includes(citation.id));
163
  if (message.role === "user") {
164
  return (
 
186
  <span className="as-preview-meta">{`Open the ${KIND_LABEL[view.kind] ?? view.kind} in Query`}</span>
187
  </button>
188
  ) : null}
189
+ {/* ⚠ The thumbs are NOT a survey: the server reads a thumbs-down back into the next turn of
190
+ this thread. That is why a down asks for one short reason and why both are clearable. */}
191
+ {onRate ? (
192
+ <div className="as-rate">
193
+ <button type="button" aria-label="Helpful" aria-pressed={message.rating === "up"}
194
+ className={"as-rate-btn" + (message.rating === "up" ? " is-on" : "")}
195
+ onClick={() => { setAskWhy(false); onRate(message.id, message.rating === "up" ? null : "up"); }}>
196
+ <ThumbIcon />
197
+ </button>
198
+ <button type="button" aria-label="Not helpful" aria-pressed={message.rating === "down"}
199
+ className={"as-rate-btn" + (message.rating === "down" ? " is-on" : "")}
200
+ onClick={() => {
201
+ if (message.rating === "down") { setAskWhy(false); onRate(message.id, null); return; }
202
+ setAskWhy(true); onRate(message.id, "down");
203
+ }}>
204
+ <ThumbIcon down />
205
+ </button>
206
+ {message.rating === "down" && !askWhy && message.ratingReason ? (
207
+ <span className="as-rate-why-said">{message.ratingReason}</span>
208
+ ) : null}
209
+ </div>
210
+ ) : null}
211
+ {onRate && askWhy && message.rating === "down" ? (
212
+ <form className="as-rate-why" onSubmit={(event) => {
213
+ event.preventDefault(); setAskWhy(false); onRate(message.id, "down", why.trim());
214
+ }}>
215
+ <input className="as-rate-input" value={why} aria-label="What was wrong"
216
+ placeholder="What was wrong? Optional."
217
+ onChange={(event) => setWhy(event.currentTarget.value)} />
218
+ <button type="submit" className="as-rate-send">Send</button>
219
+ </form>
220
+ ) : null}
221
  {matching.length ? (
222
  <div className="as-sources">
223
  <button type="button" className="as-sources-btn" aria-expanded={openSources}
 
236
  }
237
 
238
  function initialIndex(): QueryIndex {
239
+ return { threads: [], messages: [], views: [], citations: [], models: ["auto"],
240
+ modelStatus: [], sources: [] };
241
  }
242
 
243
  /**
 
281
  const [problem, setProblem] = useState("");
282
  const [loaded, setLoaded] = useState(false);
283
  const [confirmThread, setConfirmThread] = useState("");
284
+ const [mode, setMode] = useState<AssistantMode>(readMode);
285
+ const [activeQuery, setActiveQuery] = useState("");
286
+ /** Bumped on delete so the hosted workspace's own copy of the list cannot go stale behind us. */
287
+ const [queryRefresh, setQueryRefresh] = useState(0);
288
  const openCancel = useRef<(() => void) | null>(null);
289
  const foot = useRef<HTMLDivElement | null>(null);
290
+ /** A counter, not a random id: the optimistic message only has to be unique within this page. */
291
+ const pending = useRef(0);
292
 
293
  const databases = useMemo(() => databaseEntries(granted).filter((entry) => entry.kind !== "group"), [granted]);
294
  const labels = useMemo(() => new Map(databases.map((entry) => [entry.key, entry.label])), [databases]);
 
311
  const messages = index.messages.filter((row) => row.threadId === activeThread);
312
  const viewById = useMemo(() => new Map(index.views.map((view) => [view.id, view])), [index.views]);
313
 
314
+ /** Offered is not callable: a model whose key this deployment does not hold says so, and why. */
315
+ const modelBlocked = useCallback((key: string) => {
316
+ const row = index.modelStatus.find((item) => item.model === key);
317
+ return row && row.available === false
318
+ ? row.reason || "this model is not available on this deployment" : "";
319
+ }, [index.modelStatus]);
320
+
321
  const reload = useCallback(async () => {
322
  const result = await fetchQueries();
323
  setLoaded(true);
324
  if (result.ok) {
325
  setIndex(result.value);
326
+ // ⚠ Falls back to Auto when the SELECTED model is offered but not callable, not only when it
327
+ // has left the list: a stored thread can restore a model whose key was removed since, and a
328
+ // disabled option the picker cannot clear is a control that refuses its own value.
329
+ const callable = (key: string) => result.value.models.includes(key)
330
+ && result.value.modelStatus.every((row) => row.model !== key || row.available);
331
+ setModel((current) => callable(current) ? current : "auto");
332
  }
333
  }, []);
334
 
 
342
  // The conversation reads bottom-up, like every chat the owner named.
343
  useEffect(() => { foot.current?.scrollIntoView({ block: "end" }); }, [messages.length, busy]);
344
 
345
+ /**
346
+ * The `?mode=` the redirect carried has been read into state; drop it so a toggle made later is
347
+ * not overridden by a stale parameter on the next reload. `replaceState` does NOT fire
348
+ * `hashchange`, so the shell never re-resolves the route and nothing flickers.
349
+ *
350
+ * β›” AND IT IS STORED IN THE SAME BREATH, WHICH IS THE HALF THAT WAS MISSING. `useState(readMode)`
351
+ * reads the hash and writes NOTHING; only `pickMode` persists. So a reader who arrived in Query
352
+ * by following a `#/query` link, never touching the toggle, would have the parameter stripped
353
+ * here and then land back in Chat on the next reload, restored from a choice made days earlier.
354
+ * Following the link IS choosing.
355
+ */
356
+ useEffect(() => {
357
+ if (typeof window === "undefined" || !window.location.hash.includes("?")) return;
358
+ storeMode(mode);
359
+ window.history.replaceState(null, "", `#/${ASSISTANT_ROUTE}`);
360
+ // `mode` is read once, at mount, on purpose: this effect exists to consume the arrival
361
+ // parameter, not to mirror every later toggle (`pickMode` already does that).
362
+ // eslint-disable-next-line react-hooks/exhaustive-deps
363
+ }, []);
364
+
365
+ const pickMode = useCallback((next: AssistantMode) => {
366
+ setMode(next); storeMode(next); setProblem("");
367
+ }, []);
368
+
369
+ /** The saved Query views, grouped exactly as the standalone page groups them (ONE function). */
370
+ const queryList = useMemo(() => queryGroups(index.views, databases), [index.views, databases]);
371
+
372
+ const selectQuery = useCallback((qid: string) => {
373
+ setActiveQuery(qid);
374
+ openCancel.current?.();
375
+ openCancel.current = openBuiltView(qid, true);
376
+ }, []);
377
+
378
+ /** A renamed or duplicated artefact, merged in place, and the hosted workspace told to re-read. */
379
+ const upsertQuery = useCallback((view: SavedQuery) => {
380
+ setIndex((current) => ({
381
+ ...current, views: [view, ...current.views.filter((row) => row.id !== view.id)],
382
+ }));
383
+ setQueryRefresh((count) => count + 1);
384
+ }, []);
385
+
386
+ const removeQuery = useCallback(async (qid: string) => {
387
+ const result = await deleteQuery(qid);
388
+ if (!result.ok) { setProblem(result.message); return; }
389
+ setIndex((current) => ({ ...current, views: current.views.filter((row) => row.id !== qid) }));
390
+ setActiveQuery((current) => (current === qid ? "" : current));
391
+ setQueryRefresh((count) => count + 1);
392
+ }, []);
393
+
394
+ /** Arriving in Query with nothing chosen selects the newest view of the first database. */
395
+ useEffect(() => {
396
+ if (mode !== "query" || activeQuery) return;
397
+ const first = queryList[0]?.views[0];
398
+ if (first) selectQuery(first.id);
399
+ }, [mode, activeQuery, queryList, selectQuery]);
400
+
401
  const newChat = useCallback(() => {
402
  setActiveThread(""); setQuestion(""); setProblem("");
403
  }, []);
 
410
  const permitted = databases.map((entry) => entry.key);
411
  const sources = (row.sources || []).filter((key) => permitted.includes(key));
412
  if (sources.length) { setSelected(sources); setTarget(sources[0]); }
413
+ if (row.model && index.models.includes(row.model) && !modelBlocked(row.model)) setModel(row.model);
414
+ }, [databases, index.models, index.threads, modelBlocked]);
415
 
416
  const toggleSource = useCallback((key: string) => {
417
  if (blockedReason(key)) return;
 
422
  });
423
  }, [blockedReason]);
424
 
425
+ /**
426
+ * ⭐⭐ R16 β€” ENTER GOES TO THE CHAT, NOT TO A WAIT. Owner: *"Pressing enter goes immediately to
427
+ * the chat view showing that it is loading; there is a thinking animation."*
428
+ *
429
+ * This used to POST and only then touch `index`, so for the whole model call the reader sat on
430
+ * the opening screen looking at their own unsent sentence with nothing moving. The message is
431
+ * appended FIRST, which is what flips `empty` and hands the surface to the stream; `busy` then
432
+ * draws the app's one loading mark under it.
433
+ *
434
+ * β›” THE INVERSE IS CAPTURED BEFORE THE WRITE. An optimistic append that is not reconciled on
435
+ * failure leaves a message on screen the server never received, and the next turn replays it to
436
+ * the model as if it had [[undo-capture-before-the-write]]. The typed text goes back in the
437
+ * composer too: losing somebody's sentence to a failed request is the second injury.
438
+ */
439
  const ask = useCallback(async () => {
440
  const text = question.trim();
441
  if (!text || !target || !selected.includes(target) || busy) return;
442
+ const pendingId = `pending_${(pending.current += 1)}`;
443
+ const optimistic: QueryMessage = {
444
+ id: pendingId, threadId: activeThread, role: "user", content: text,
445
+ createdAt: new Date().toISOString(), targetDatabase: target,
446
+ sources: selected, requestedModel: model,
447
+ };
448
+ const withoutPending = (rows: QueryMessage[]) => rows.filter((row) => row.id !== pendingId);
449
+ setBusy(true); setProblem(""); setQuestion("");
450
+ setIndex((current) => ({ ...current, messages: [...current.messages, optimistic] }));
451
  const result = await submitChat({ question: text, database: target, sources: selected,
452
  ...(activeThread ? { threadId: activeThread } : {}), model });
453
  setBusy(false);
454
+ if (!result.ok) {
455
+ setIndex((current) => ({ ...current, messages: withoutPending(current.messages) }));
456
+ setQuestion(text);
457
+ setProblem(result.message);
458
+ return;
459
+ }
460
  setActiveThread(result.value.thread.id);
461
  setIndex((current) => ({
462
  ...current,
463
  threads: [result.value.thread, ...current.threads.filter((row) => row.id !== result.value.thread.id)],
464
+ // The server's own copy of the question replaces the optimistic one, so a thread never holds
465
+ // two of the same turn and the id the rest of the page keys on is the durable one.
466
+ messages: [...withoutPending(current.messages), result.value.userMessage, result.value.message],
467
  views: result.value.view ? [result.value.view, ...current.views.filter((row) => row.id !== result.value.view?.id)] : current.views,
468
  citations: [...current.citations, ...result.value.citations],
469
  }));
 
482
  setConfirmThread("");
483
  }, []);
484
 
485
+ /**
486
+ * The rating goes to the server and the SERVER'S copy comes back, rather than the page patching
487
+ * its own guess: the reason is normalised and cleared there, and a rating that only looked
488
+ * applied is exactly the shape this module keeps being bitten by.
489
+ */
490
+ const rate = useCallback(async (id: string, rating: "up" | "down" | null, reason?: string) => {
491
+ const result = await rateMessage(id, rating, reason);
492
+ if (!result.ok) { setProblem(result.message); return; }
493
+ setIndex((current) => ({
494
+ ...current,
495
+ messages: current.messages.map((row) => (row.id === id ? result.value : row)),
496
+ }));
497
  }, []);
498
 
499
+ /** A built view opens WHERE THE READER ALREADY IS: the toggle flips, the list follows. */
500
+ const preview = useCallback((qid: string) => {
501
+ pickMode("query");
502
+ selectQuery(qid);
503
+ }, [pickMode, selectQuery]);
504
+
505
  const empty = messages.length === 0;
506
  const composer = (
507
  <div className="as-composer">
 
525
  <span className="as-pick-label">Model</span>
526
  <select className="as-pick-select" value={model} aria-label="Model"
527
  onChange={(event) => setModel(event.currentTarget.value)}>
528
+ {index.models.map((choice) => {
529
+ const blocked = modelBlocked(choice);
530
+ return (
531
+ <option key={choice} value={choice} disabled={!!blocked}
532
+ title={blocked || undefined}>
533
+ {blocked ? `${modelLabel(choice)} (not configured)` : modelLabel(choice)}
534
+ </option>
535
+ );
536
+ })}
537
  </select>
538
  </label>
539
  {/* ⚠ ONLY WHEN THE ANSWER IS AMBIGUOUS. Every generated view names ONE database (R3);
 
595
  <div className="as-chat" aria-label="AI assistant">
596
  {/* Airtable's panel: one door to a new chat, then the history. */}
597
  <aside className="as-history">
598
+ {/* C2: two values, directly ABOVE "+ New chat". It changes which LIST shows, not the route. */}
599
+ <div className="as-mode-toggle" role="tablist" aria-label="Chat or Query">
600
+ {(["chat", "query"] as const).map((value) => (
601
+ <button type="button" key={value} role="tab" aria-selected={mode === value}
602
+ className={"as-mode" + (mode === value ? " is-on" : "")}
603
+ onClick={() => pickMode(value)}>
604
+ {value === "chat" ? "Chat" : "Query"}
605
+ </button>
606
+ ))}
607
+ </div>
608
+ {mode === "query" ? (
609
+ /* The SAME rail the standalone page renders; only its host changed, so the differences
610
+ are CSS in assistant.css and nothing here. */
611
+ <QueryRail groups={queryList} activeId={activeQuery} onSelect={selectQuery}
612
+ onDelete={(id) => void removeQuery(id)} onChanged={upsertQuery} />
613
+ ) : (
614
+ <>
615
  <button type="button" className="as-new" onClick={newChat}>
616
  <svg viewBox="0 0 16 16" aria-hidden="true" width="14" height="14">
617
  <path d="M8 3.5v9M3.5 8h9" />
 
641
  <p className="as-thread-empty">No chats yet.</p>
642
  ) : null}
643
  </nav>
644
+ </>
645
+ )}
646
  </aside>
647
  <main className="as-conversation">
648
+ {mode === "query" ? (
649
+ queryList.length ? (
650
+ <Suspense fallback={<p className="as-working" role="status">
651
+ <span className="lp-spin" aria-hidden="true" />
652
+ </p>}>
653
+ <QueryWorkspace granted={granted} hostedRail refreshToken={queryRefresh}
654
+ selectedId={activeQuery} />
655
+ </Suspense>
656
+ ) : (
657
+ <div className="as-opening">
658
+ <h1 className="as-opening-h">No Query views yet</h1>
659
+ <p className="as-opening-p">
660
+ Ask a question in Chat. When the assistant builds a view, it is saved here.
661
+ </p>
662
+ </div>
663
+ )
664
+ ) : empty ? (
665
  <div className="as-opening">
666
  <h1 className="as-opening-h">How can I help?</h1>
667
  <p className="as-opening-p">
 
677
  {messages.map((message) => (
678
  <AssistantMessage key={message.id} message={message}
679
  view={message.viewId ? viewById.get(message.viewId) : undefined}
680
+ citations={index.citations} onPreview={preview}
681
+ onRate={message.role === "assistant" && !message.id.startsWith("pending_")
682
+ ? rate : undefined} />
683
  ))}
684
+ {/* R16's thinking animation is the app's ONE loading mark, not a second spinner
685
+ vocabulary: `.lp-spin` is the same mark every surface uses and C is repainting
686
+ it in the brand purple this wave. The label is for screen readers only, since
687
+ an aria-hidden spinner inside a status region announces nothing. */}
688
  {busy ? (
689
+ <p className="as-working" role="status" aria-label="The assistant is thinking">
690
  <span className="lp-spin" aria-hidden="true" />
691
  </p>
692
  ) : null}
web/src/assistant/assistant.css CHANGED
@@ -40,6 +40,60 @@
40
  overflow: hidden;
41
  }
42
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
43
  .as-new {
44
  flex: 0 0 auto;
45
  display: flex;
@@ -285,6 +339,80 @@
285
  .as-preview-name { color: var(--lp-ink); font-size: var(--lp-fs-xs); font-weight: 600; }
286
  .as-preview-meta { color: var(--lp-blue-solid); font-size: var(--lp-fs-2xs); font-weight: 600; }
287
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
288
  .as-sources { margin-top: 8px; }
289
 
290
  .as-sources-btn {
 
40
  overflow: hidden;
41
  }
42
 
43
+ /* ── R14 / C2: the Chat / Query toggle, directly above New chat ───────────── */
44
+
45
+ .as-mode-toggle {
46
+ flex: 0 0 auto;
47
+ display: flex;
48
+ gap: 2px;
49
+ margin-bottom: 10px;
50
+ padding: 2px;
51
+ border-radius: var(--lp-r-md);
52
+ background: var(--lp-surface-2);
53
+ }
54
+
55
+ .as-mode {
56
+ flex: 1 1 0;
57
+ min-width: 0;
58
+ min-height: 26px;
59
+ padding: 4px 8px;
60
+ border: 0;
61
+ border-radius: 6px;
62
+ background: transparent;
63
+ color: var(--lp-muted);
64
+ font: inherit;
65
+ font-size: var(--lp-fs-2xs);
66
+ font-weight: 600;
67
+ cursor: pointer;
68
+ }
69
+
70
+ .as-mode:hover { color: var(--lp-ink); }
71
+ .as-mode.is-on { background: var(--lp-surface); color: var(--lp-ink); }
72
+
73
+ /* The Query list is the SAME `QueryRail` the standalone page renders β€” only its host changed.
74
+ These are the overrides a rail needs when it is nested inside a panel that is already one rail
75
+ wide: it must FILL that panel rather than claim a second `--lp-rail-w` beside it, and it must
76
+ not draw a second divider against the one `.as-history` already has.
77
+ `.qy-rail-ask` goes because it links to the Assistant, which is the surface it is now inside. */
78
+ .as-history .qy-rail {
79
+ flex: 1 1 auto;
80
+ width: auto;
81
+ min-width: 0;
82
+ height: auto;
83
+ min-height: 0;
84
+ padding: 0;
85
+ border-right: 0;
86
+ }
87
+
88
+ .as-history .qy-rail-ask { display: none; }
89
+
90
+ /* Query fills the conversation column while the toggle is on Query. */
91
+ .as-conversation > .shell-db-frame,
92
+ .as-conversation > .qy-empty {
93
+ flex: 1 1 auto;
94
+ min-height: 0;
95
+ }
96
+
97
  .as-new {
98
  flex: 0 0 auto;
99
  display: flex;
 
339
  .as-preview-name { color: var(--lp-ink); font-size: var(--lp-fs-xs); font-weight: 600; }
340
  .as-preview-meta { color: var(--lp-blue-solid); font-size: var(--lp-fs-2xs); font-weight: 600; }
341
 
342
+ /* ── R20: the thumbs, which the server reads back into the next turn ───────── */
343
+
344
+ .as-rate {
345
+ display: flex;
346
+ align-items: center;
347
+ gap: 4px;
348
+ margin-top: 10px;
349
+ }
350
+
351
+ .as-rate-btn {
352
+ width: 26px;
353
+ height: 26px;
354
+ display: inline-flex;
355
+ align-items: center;
356
+ justify-content: center;
357
+ border: 0;
358
+ border-radius: 6px;
359
+ background: transparent;
360
+ color: var(--lp-muted);
361
+ cursor: pointer;
362
+ }
363
+
364
+ .as-rate-btn svg {
365
+ fill: none;
366
+ stroke: currentColor;
367
+ stroke-width: 1.3;
368
+ stroke-linecap: round;
369
+ stroke-linejoin: round;
370
+ }
371
+
372
+ .as-rate-btn:hover { background: var(--lp-surface-2); color: var(--lp-ink); }
373
+ .as-rate-btn.is-on { background: var(--lp-blue-tint); color: var(--lp-blue-solid); }
374
+
375
+ .as-rate-why-said {
376
+ margin-left: 4px;
377
+ color: var(--lp-muted);
378
+ font-size: var(--lp-fs-2xs);
379
+ }
380
+
381
+ .as-rate-why {
382
+ display: flex;
383
+ gap: 6px;
384
+ margin-top: 6px;
385
+ }
386
+
387
+ .as-rate-input {
388
+ flex: 1 1 auto;
389
+ min-width: 0;
390
+ padding: 6px 10px;
391
+ border: 1px solid var(--lp-line);
392
+ border-radius: var(--lp-r-md);
393
+ background: var(--lp-surface);
394
+ color: var(--lp-ink);
395
+ font: inherit;
396
+ font-size: var(--lp-fs-2xs);
397
+ }
398
+
399
+ .as-rate-input:focus { outline: none; border-color: var(--lp-blue-solid); }
400
+
401
+ .as-rate-send {
402
+ flex: 0 0 auto;
403
+ padding: 6px 12px;
404
+ border: 1px solid var(--lp-line);
405
+ border-radius: var(--lp-r-md);
406
+ background: var(--lp-surface);
407
+ color: var(--lp-ink);
408
+ font: inherit;
409
+ font-size: var(--lp-fs-2xs);
410
+ font-weight: 600;
411
+ cursor: pointer;
412
+ }
413
+
414
+ .as-rate-send:hover { background: var(--lp-surface-2); }
415
+
416
  .as-sources { margin-top: 8px; }
417
 
418
  .as-sources-btn {
web/src/automation/AutomationBuilder.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/automation/AutomationChat.tsx ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // automation/AutomationChat.tsx β€” the Chat view of the Agent module (R17).
3
+ //
4
+ // Owner, ruling R17: *"The Agent module needs a Chat view beside the builder, where the user asks
5
+ // the AI to build the automation."*
6
+ //
7
+ // β›”β›” WHAT THIS IS NOT: a second way to save an automation. `POST /automations/draft` writes
8
+ // NOTHING and says so on the wire (`saved: false`); accepting a draft is the ordinary
9
+ // `POST /automations` that the Canvas already uses. That is R3's *"indistinguishable from a
10
+ // hand-built one"* achieved by there being ONE write path, not two that look alike. If a future
11
+ // change makes this component write directly, that property is gone and nothing will report it.
12
+ //
13
+ // β›” AND `dropped` IS RENDERED, ALWAYS. The route runs the real save-door validator over the
14
+ // model's answer and diffs it, because `clean_actions` discards an unrecognised config key and
15
+ // still answers 200. Every entry is something the reader was SHOWN that will not survive the
16
+ // save. Hiding it would make the Canvas disagree with the transcript that produced it, which is
17
+ // the exact complaint D-75 was opened for.
18
+ // ---------------------------------------------------------------------------
19
+ import { useCallback, useRef, useState } from "react";
20
+
21
+ import type { AutomationDraft } from "./automationApi";
22
+ import { AutomationError, draftAutomation } from "./automationApi";
23
+ import "./automation.css";
24
+
25
+ /**
26
+ * One turn. `error` is a KIND of message rather than a separate banner, because a failed turn is
27
+ * part of the conversation a person is having: they will rephrase and try again, and a toast that
28
+ * has already faded cannot be re-read while they do.
29
+ */
30
+ interface Turn {
31
+ who: "you" | "agent";
32
+ text: string;
33
+ error?: boolean;
34
+ dropped?: string[];
35
+ }
36
+
37
+ export interface AutomationChatProps {
38
+ /** Applied to the Canvas when the reader accepts. Returns once the write has been attempted. */
39
+ onAccept: (draft: AutomationDraft["draft"]) => Promise<void> | void;
40
+ /** The automation being edited, for the empty state's sentence. */
41
+ name?: string;
42
+ }
43
+
44
+ /** How many steps a draft may carry before the accept bar states the number rather than implying
45
+ * it. Not a limit β€” a limit here would be a second, quieter copy of the server's. */
46
+ const MANY_STEPS = 6;
47
+
48
+ export default function AutomationChat({ onAccept, name }: AutomationChatProps) {
49
+ const [turns, setTurns] = useState<Turn[]>([]);
50
+ const [text, setText] = useState("");
51
+ const [busy, setBusy] = useState(false);
52
+ const [pending, setPending] = useState<AutomationDraft["draft"] | null>(null);
53
+ const [applying, setApplying] = useState(false);
54
+ const logRef = useRef<HTMLDivElement | null>(null);
55
+
56
+ const scrollDown = useCallback(() => {
57
+ // The transcript grows at the bottom, so a reader who has just sent something expects to be
58
+ // there. `requestAnimationFrame` because the row is appended in the same commit.
59
+ requestAnimationFrame(() => {
60
+ const el = logRef.current;
61
+ if (el) el.scrollTop = el.scrollHeight;
62
+ });
63
+ }, []);
64
+
65
+ const ask = useCallback(async () => {
66
+ const prompt = text.trim();
67
+ if (!prompt || busy) return;
68
+ /*
69
+ * ⭐ THE USER'S LINE GOES UP FIRST AND THE COMPOSER CLEARS FIRST. Waiting for the server to
70
+ * echo it is what makes a chat feel broken: the reader presses enter, the text sits in the
71
+ * box, and they press it again. Same reasoning as R16's clause for the Assistant.
72
+ * ⚠ The optimistic row is never RECONCILED away on failure β€” it genuinely happened, they
73
+ * genuinely said it β€” so the failure arrives as the NEXT turn rather than by deleting theirs.
74
+ */
75
+ setTurns((t) => [...t, { who: "you", text: prompt }]);
76
+ setText("");
77
+ setBusy(true);
78
+ scrollDown();
79
+ try {
80
+ const res = await draftAutomation(prompt);
81
+ const actions = res.draft?.actions || [];
82
+ const label = res.draft?.name ? `"${res.draft.name}"` : "a flow";
83
+ setTurns((t) => [...t, {
84
+ who: "agent",
85
+ text: actions.length
86
+ ? `I drafted ${label} with ${actions.length} step${actions.length === 1 ? "" : "s"}. `
87
+ + "Nothing is saved yet. Read it below and put it on the Canvas if it is right."
88
+ : `I could not turn that into steps. Try naming the database and what should happen.`,
89
+ // ⚠ BOTH CHANNELS, and they are different questions. `dropped` is what the SAVE-DOOR
90
+ // validator discarded from the model's answer; `notes` is what the ROUTE corrected or
91
+ // could not keep (a trigger this deployment does not have, steps past the draft cap).
92
+ // Rendering only one of them shows a person half of what changed under them.
93
+ // β›” `dropped` IS `{kind, keys}`, NOT A SENTENCE. Spreading it into a string list
94
+ // compiled and would have painted `[object Object]` on the one line that exists to say
95
+ // what changed under the reader. It is turned into words HERE, once.
96
+ ...(res.dropped?.length || res.notes?.length
97
+ ? {
98
+ dropped: [
99
+ ...(res.dropped || []).map(
100
+ (d) => `on the ${d.kind} step: ${(d.keys || []).join(", ")}`
101
+ ),
102
+ ...(res.notes || []),
103
+ ],
104
+ }
105
+ : {}),
106
+ }]);
107
+ setPending(actions.length ? res.draft : null);
108
+ } catch (e) {
109
+ /*
110
+ * β›” THE SERVER'S OWN SENTENCE, NEVER A GENERIC ONE. The route answers 400 with one plain
111
+ * sentence saying WHY it refused (no prompt, nothing draftable, a kind this deployment
112
+ * cannot run) and that sentence is the only useful thing on the screen. Replacing it with
113
+ * "something went wrong" would throw away the one part a person can act on.
114
+ */
115
+ setTurns((t) => [...t, {
116
+ who: "agent",
117
+ error: true,
118
+ text: e instanceof AutomationError
119
+ ? e.message
120
+ : "The agent service did not answer. Try again in a moment.",
121
+ }]);
122
+ setPending(null);
123
+ } finally {
124
+ setBusy(false);
125
+ scrollDown();
126
+ }
127
+ }, [text, busy, scrollDown]);
128
+
129
+ const accept = useCallback(async () => {
130
+ if (!pending || applying) return;
131
+ setApplying(true);
132
+ try {
133
+ await onAccept(pending);
134
+ setPending(null);
135
+ setTurns((t) => [...t, {
136
+ who: "agent",
137
+ text: "Put it on the Canvas. Edit it there like any other agent.",
138
+ }]);
139
+ } catch (e) {
140
+ setTurns((t) => [...t, {
141
+ who: "agent",
142
+ error: true,
143
+ text: e instanceof AutomationError ? e.message : "That draft could not be applied.",
144
+ }]);
145
+ } finally {
146
+ setApplying(false);
147
+ scrollDown();
148
+ }
149
+ }, [pending, applying, onAccept, scrollDown]);
150
+
151
+ const steps = pending?.actions?.length || 0;
152
+
153
+ return (
154
+ <div className="autoc-chat">
155
+ <div className="autoc-log" ref={logRef}>
156
+ {!turns.length ? (
157
+ <p className="autoc-empty">
158
+ Describe what {name ? `"${name}"` : "this agent"} should do and the assistant will
159
+ draft the steps. Name the database and what should happen, for example: when a new row
160
+ is added, look up its website and fill in the company size.
161
+ </p>
162
+ ) : null}
163
+ {turns.map((t, i) => (
164
+ <div key={i} className="autoc-turn">
165
+ <div
166
+ className={
167
+ "autoc-msg " + (t.error ? "is-error" : t.who === "you" ? "is-you" : "is-agent")
168
+ }
169
+ >
170
+ {t.text}
171
+ </div>
172
+ {/* R6's second sentence, on screen: what the validator would not keep, named. */}
173
+ {t.dropped?.length ? (
174
+ <div className="autoc-dropped">
175
+ Some of what it asked for will not be kept:
176
+ <ul>
177
+ {t.dropped.map((d, j) => (
178
+ <li key={j}>{d}</li>
179
+ ))}
180
+ </ul>
181
+ </div>
182
+ ) : null}
183
+ </div>
184
+ ))}
185
+ {busy ? (
186
+ <div className="autoc-pending" role="status">
187
+ {/* ⚠ `.lp-spin` is the ONE loading mark in this application and it is purple as of
188
+ this wave. A second spinner vocabulary in a new surface is how an app stops
189
+ having one. */}
190
+ <span className="lp-spin" aria-hidden="true" />
191
+ Drafting the steps
192
+ </div>
193
+ ) : null}
194
+ </div>
195
+
196
+ {pending ? (
197
+ <div className="autoc-accept">
198
+ <span className="autoc-accept-note">
199
+ {steps} step{steps === 1 ? "" : "s"} drafted
200
+ {steps > MANY_STEPS ? ", which is a long flow. Read it before you accept." : "."}{" "}
201
+ Nothing is saved until you put it on the Canvas.
202
+ </span>
203
+ <button
204
+ type="button"
205
+ className="auto-btn"
206
+ disabled={applying}
207
+ onClick={() => setPending(null)}
208
+ >
209
+ Discard
210
+ </button>
211
+ <button
212
+ type="button"
213
+ className="auto-btn is-primary"
214
+ disabled={applying}
215
+ onClick={() => void accept()}
216
+ >
217
+ {applying ? "Adding" : "Put it on the Canvas"}
218
+ </button>
219
+ </div>
220
+ ) : null}
221
+
222
+ <div className="autoc-compose">
223
+ <textarea
224
+ className="autoc-input"
225
+ rows={2}
226
+ value={text}
227
+ placeholder="Describe what this agent should do"
228
+ aria-label="Describe what this agent should do"
229
+ disabled={busy}
230
+ onChange={(e) => setText(e.target.value)}
231
+ onKeyDown={(e) => {
232
+ // Enter sends; shift+enter is a newline. The composer is two rows tall because these
233
+ // descriptions are sentences, not search terms.
234
+ if (e.key === "Enter" && !e.shiftKey) {
235
+ e.preventDefault();
236
+ void ask();
237
+ }
238
+ }}
239
+ />
240
+ <button
241
+ type="button"
242
+ className="auto-btn is-primary"
243
+ disabled={busy || !text.trim()}
244
+ onClick={() => void ask()}
245
+ >
246
+ Send
247
+ </button>
248
+ </div>
249
+ </div>
250
+ );
251
+ }
web/src/automation/AutomationDetail.tsx CHANGED
@@ -23,7 +23,18 @@
23
  import { useCallback, useEffect, useState } from "react";
24
 
25
  import AutomationBuilder from "./AutomationBuilder";
 
 
 
 
 
26
  import AutomationFind from "./AutomationFind";
 
 
 
 
 
 
27
  import AutomationTrigger from "./AutomationTrigger";
28
  import type {
29
  ActionCatalogRow,
@@ -338,6 +349,42 @@ export default function AutomationDetail({
338
  * setting, and `aria-pressed` on each would tell a screen reader about two independent
339
  * toggles that can both be off β€” a state this control cannot express.
340
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
341
  const panelTabs = (
342
  <div className="auto-paneltabs" role="radiogroup" aria-label="Right-hand panel">
343
  {([["properties", "Properties"], ["runs", "Run history"]] as const).map(([k, label]) => (
@@ -556,7 +603,7 @@ export default function AutomationDetail({
556
  const heldNote = (): string => {
557
  const held = heldCount();
558
  if (held < 1) return "";
559
- return `Saved β€” ${held} condition${held === 1 ? " was" : "s were"} not saved yet, `
560
  + `${held === 1 ? "it needs" : "they need"} a value.`;
561
  };
562
 
@@ -815,7 +862,7 @@ export default function AutomationDetail({
815
  setMessage(
816
  held < 1
817
  ? "Started. The node dots follow it."
818
- : `Started β€” running without ${held} condition${held === 1 ? "" : "s"} that still `
819
  + `${held === 1 ? "needs" : "need"} a value.`
820
  );
821
  await onSaved(automation.id);
@@ -1127,7 +1174,9 @@ export default function AutomationDetail({
1127
  </select>
1128
  </div>
1129
  <div className="auto-field">
1130
- <label htmlFor="auto-fieldkey">Automation column</label>
 
 
1131
  <select
1132
  id="auto-fieldkey"
1133
  className="auto-input"
@@ -1306,9 +1355,7 @@ export default function AutomationDetail({
1306
  automation's. See the dated amendment under C-DETAIL: the contract's premise was wrong
1307
  on that point and the consequence (no name in the Board view) is booked, not patched.
1308
  */}
1309
- <div className="auto-head-title">
1310
- <span className="autox-view is-on">Builder</span>
1311
- </div>
1312
  <div className="auto-head-actions">
1313
  {/*
1314
  ⭐ W24-W1 β€” WHAT IT IS DOING, beside the button that started it. This is the whole of
@@ -1352,7 +1399,23 @@ export default function AutomationDetail({
1352
  >
1353
  {saving ? "Saving…" : "Save"}
1354
  </button>
1355
- <button type="button" className="auto-btn is-danger" onClick={() => void remove()}>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1356
  Delete
1357
  </button>
1358
  </div>
@@ -1382,6 +1445,38 @@ export default function AutomationDetail({
1382
  engine's own steps AND what the owner added, from data the rail already had) and there
1383
  is no longer a state in which the reader gets it by accident.
1384
  */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1385
  <>
1386
  <AutomationBuilder
1387
  automation={automation}
@@ -1491,15 +1586,51 @@ export default function AutomationDetail({
1491
  answers "how many" and the summary answers "roughly what"; neither
1492
  could ever answer "why", which is the only question somebody opens this
1493
  panel with. These are the vendor's own sentences, not our paraphrase.
1494
- ⚠ Rendered unconditionally rather than behind the drill-down: the drill
1495
- is a paid-ish round trip that lists ROWS, and a reason you have to go
1496
- looking for is most of the way back to not having it. */}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1497
  {r.notes?.length ? (
1498
- <ul className="auto-run-notes">
1499
- {r.notes.map((n, i) => (
1500
- <li key={i}>{n}</li>
1501
- ))}
1502
- </ul>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1503
  ) : null}
1504
  {drillFor === r.ts && drill ? (
1505
  <div className="auto-drill">
@@ -1539,6 +1670,7 @@ export default function AutomationDetail({
1539
  </>
1540
  </aside>
1541
  </>
 
1542
  </div>
1543
  </div>
1544
  );
 
23
  import { useCallback, useEffect, useState } from "react";
24
 
25
  import AutomationBuilder from "./AutomationBuilder";
26
+ // ⭐ WAVE 34 Β· W34-T41/T42 (R17) β€” the Chat view. A STATIC import, deliberately, unlike
27
+ // `AutomationSurface`'s `lazy()` of this file: that one exists because the editor is 88 KB behind
28
+ // a rail that must paint first, while this component is small and is reached by a tab click on a
29
+ // surface already loaded. A second lazy boundary here would buy a spinner, not a page.
30
+ import AutomationChat from "./AutomationChat";
31
  import AutomationFind from "./AutomationFind";
32
+ // ⚠ IMPORTED HERE TOO, and not only by `AutomationChat`. This file's own run-log disclosure
33
+ // (`.autor-*`, W34-T45) lives in that sheet, and relying on a SIBLING component's import to pull
34
+ // it in would mean the rules arrive only while that sibling is in the bundle. Today it always is
35
+ // (a static import); the day somebody makes the chat lazy, the run log would silently lose its
36
+ // styling with nothing red anywhere. CSS imports are idempotent, so the cost is zero.
37
+ import "./automation.css";
38
  import AutomationTrigger from "./AutomationTrigger";
39
  import type {
40
  ActionCatalogRow,
 
349
  * setting, and `aria-pressed` on each would tell a screen reader about two independent
350
  * toggles that can both be off β€” a state this control cannot express.
351
  */
352
+ /**
353
+ * ⭐⭐ WAVE 34 Β· W34-T41 / R17 β€” CANVAS | CHAT, the surface's own view switch.
354
+ *
355
+ * Owner: *"The Agent module needs a Chat view beside the builder, where the user asks the AI to
356
+ * build the automation. Rename Builder to Canvas."*
357
+ *
358
+ * β›” THERE WAS NO SWITCH TO RENAME. `.auto-head-title` held a DEAD
359
+ * `<span className="autox-view is-on">Builder</span>` β€” no state, no handler, no second arm β€”
360
+ * left behind when `AutomationBoard.tsx` was deleted (its stub is still commented in
361
+ * `automation_engine.py`). The ticket's own `how:` calls it "a MODE inside AutomationSurface",
362
+ * which has not been true for two waves. So this is built, not renamed.
363
+ * ⭐ AND IT NEEDS NO NEW CSS. `.autox-views` was a complete rule with ZERO call sites and
364
+ * `.autox-view` (with `:hover`, `.is-on`, `:focus-visible`) had exactly one, the dead span β€” the
365
+ * strip was styled a wave before it was cancelled. Reusing it also means the two arms cannot
366
+ * drift from the rest of the module's chrome.
367
+ * ⚠ SAME RADIOGROUP REASONING as `panelTabs` below, for the same reason: two values of ONE
368
+ * setting, so `aria-pressed` on each would describe two toggles that can both be off.
369
+ */
370
+ const [view, setView] = useState<"canvas" | "chat">("canvas");
371
+ const viewTabs = (
372
+ <div className="autox-views" role="radiogroup" aria-label="Editor view">
373
+ {([["canvas", "Canvas"], ["chat", "Chat"]] as const).map(([k, label]) => (
374
+ <button
375
+ key={k}
376
+ type="button"
377
+ role="radio"
378
+ aria-checked={view === k}
379
+ className={"autox-view" + (view === k ? " is-on" : "")}
380
+ onClick={() => setView(k)}
381
+ >
382
+ {label}
383
+ </button>
384
+ ))}
385
+ </div>
386
+ );
387
+
388
  const panelTabs = (
389
  <div className="auto-paneltabs" role="radiogroup" aria-label="Right-hand panel">
390
  {([["properties", "Properties"], ["runs", "Run history"]] as const).map(([k, label]) => (
 
603
  const heldNote = (): string => {
604
  const held = heldCount();
605
  if (held < 1) return "";
606
+ return `Saved. ${held} condition${held === 1 ? " was" : "s were"} not saved yet, `
607
  + `${held === 1 ? "it needs" : "they need"} a value.`;
608
  };
609
 
 
862
  setMessage(
863
  held < 1
864
  ? "Started. The node dots follow it."
865
+ : `Started. Running without ${held} condition${held === 1 ? "" : "s"} that still `
866
  + `${held === 1 ? "needs" : "need"} a value.`
867
  );
868
  await onSaved(automation.id);
 
1174
  </select>
1175
  </div>
1176
  <div className="auto-field">
1177
+ {/* ⭐ WAVE 34 Β· R12 β€” "Automation column" became "Agent column". It names
1178
+ the column THIS one writes into, so it takes the singular. */}
1179
+ <label htmlFor="auto-fieldkey">Agent column</label>
1180
  <select
1181
  id="auto-fieldkey"
1182
  className="auto-input"
 
1355
  automation's. See the dated amendment under C-DETAIL: the contract's premise was wrong
1356
  on that point and the consequence (no name in the Board view) is booked, not patched.
1357
  */}
1358
+ <div className="auto-head-title">{viewTabs}</div>
 
 
1359
  <div className="auto-head-actions">
1360
  {/*
1361
  ⭐ W24-W1 β€” WHAT IT IS DOING, beside the button that started it. This is the whole of
 
1399
  >
1400
  {saving ? "Saving…" : "Save"}
1401
  </button>
1402
+ {/* ⭐⭐ WAVE 34 Β· CONTRACTS C3 + C4 β€” A SYSTEM AGENT SAYS SO ON THE ITEM, NOT ON THE
1403
+ CLICK. The server refuses `409 system_agent` whatever this renders, so the wall is
1404
+ never the disabling β€” but leaving a live-looking Delete that answers with a telling
1405
+ off is the exact shape `InboxPage` records as a defect in its own words: "you pressed
1406
+ this and were told off" rather than "this is unavailable". The button carries the
1407
+ server's reason as its tooltip, so the explanation is in the same place as the
1408
+ refusal it explains. */}
1409
+ <button
1410
+ type="button"
1411
+ className="auto-btn is-danger"
1412
+ disabled={!!automation.system}
1413
+ {...(automation.system
1414
+ ? { title: "This agent is part of the workspace setup. Open its column or its "
1415
+ + "connector to change it." }
1416
+ : {})}
1417
+ onClick={() => void remove()}
1418
+ >
1419
  Delete
1420
  </button>
1421
  </div>
 
1445
  engine's own steps AND what the owner added, from data the rail already had) and there
1446
  is no longer a state in which the reader gets it by accident.
1447
  */}
1448
+ {view === "chat" ? (
1449
+ /*
1450
+ * ⭐⭐ WAVE 34 Β· W34-T42 / R17 β€” the Chat view, and it writes through the SAME door.
1451
+ *
1452
+ * β›” `onAccept` IS `writeDefinition`, the function the Canvas's own Save calls. It is not
1453
+ * "similar to" it: R3 asks for a drafted automation to be indistinguishable from a
1454
+ * hand-built one, and the only way to guarantee that is for there to be one write path.
1455
+ * `POST /automations/draft` deliberately saves nothing, so this is where a draft becomes
1456
+ * real, and it becomes real by the ordinary route.
1457
+ * ⚠ SWITCHING TABS UNMOUNTS THE BUILDER, and the cost is named rather than hidden: the
1458
+ * builder's own local state (which step card is selected, its scroll position) is lost on
1459
+ * the round trip. Everything a person has TYPED is safe β€” `name`, `touched`, the cron
1460
+ * text and every panel value are this component's state, held above both views, so a
1461
+ * half-edited name survives a visit to Chat and back. Keeping the builder mounted behind
1462
+ * `hidden` would preserve the selection too, at the price of two live editors of one
1463
+ * automation; the selection is the cheaper thing to lose.
1464
+ */
1465
+ <AutomationChat
1466
+ name={automation.name}
1467
+ onAccept={async (draft) => {
1468
+ await writeDefinition({
1469
+ ...(draft.name ? { name: draft.name } : {}),
1470
+ // ⚠ `draft.trigger` IS A BARE KEY STRING on this route, not `{key}`. Written the
1471
+ // other way it compiles, reads `undefined`, and every accepted flow lands with no
1472
+ // trigger and never fires, with nothing red anywhere. See the type's own note.
1473
+ ...(draft.trigger ? { trigger: { key: draft.trigger } } : {}),
1474
+ flow: { actions: draft.actions || [] },
1475
+ });
1476
+ setView("canvas");
1477
+ }}
1478
+ />
1479
+ ) : (
1480
  <>
1481
  <AutomationBuilder
1482
  automation={automation}
 
1586
  answers "how many" and the summary answers "roughly what"; neither
1587
  could ever answer "why", which is the only question somebody opens this
1588
  panel with. These are the vendor's own sentences, not our paraphrase.
1589
+
1590
+ ⭐⭐ WAVE 34 Β· W34-T45 / R21 β€” "Run logs are too wordy. Cut them down."
1591
+ MEASURED BEFORE CUTTING, because the ticket asks for that and because the
1592
+ obvious target was the wrong one: `summary` is capped at 400 chars and in
1593
+ practice reads "3 new, 2 updated", while `notes` is capped at 300 chars x
1594
+ 25 entries = **up to 7,500 characters per run, rendered unconditionally,
1595
+ over MAX_RUNS = 20 runs**. The notes are the wordiness; the summary is
1596
+ not. Nothing was cut from the server.
1597
+
1598
+ β›” AND THE OLD COMMENT HERE WAS RIGHT, WHICH IS WHY THIS IS A SPLIT AND
1599
+ NOT A COLLAPSE. It read: *"a reason you have to go looking for is most of
1600
+ the way back to not having it"* β€” true of a run that FAILED or hit a cap,
1601
+ and the exact case R6's standing rule protects (a limit that cannot be
1602
+ removed must be REPORTED). It is not true of the twentieth consecutive
1603
+ successful run, whose notes nobody reads and which is what makes the
1604
+ panel a wall of text. So: still unconditional when it MATTERS, one click
1605
+ away when it does not.
1606
+
1607
+ ⚠ THE PREDICATE IS THE CODEBASE'S OWN LIMIT SIGNAL, NOT A TEXT HEURISTIC.
1608
+ `counts.capped` is already what the row above paints `is-warn` for, so
1609
+ "did this run hit a limit" has one answer in one place. Sniffing the note
1610
+ STRINGS for words like "limit" would be a second, quieter definition that
1611
+ drifts the first time a vendor rewords a sentence. */}
1612
  {r.notes?.length ? (
1613
+ !r.ok || !!r.counts?.capped ? (
1614
+ <ul className="auto-run-notes">
1615
+ {r.notes.map((n, i) => (
1616
+ <li key={i}>{n}</li>
1617
+ ))}
1618
+ </ul>
1619
+ ) : (
1620
+ /* Native `<details>`: keyboard-reachable and screen-reader-announced for
1621
+ free, and the count is IN the summary so the disclosure says how much
1622
+ is behind it rather than being an anonymous chevron. */
1623
+ <details className="autor-notes">
1624
+ <summary className="autor-notes-toggle">
1625
+ {r.notes.length} note{r.notes.length === 1 ? "" : "s"}
1626
+ </summary>
1627
+ <ul className="auto-run-notes">
1628
+ {r.notes.map((n, i) => (
1629
+ <li key={i}>{n}</li>
1630
+ ))}
1631
+ </ul>
1632
+ </details>
1633
+ )
1634
  ) : null}
1635
  {drillFor === r.ts && drill ? (
1636
  <div className="auto-drill">
 
1670
  </>
1671
  </aside>
1672
  </>
1673
+ )}
1674
  </div>
1675
  </div>
1676
  );
web/src/automation/AutomationFind.tsx CHANGED
@@ -589,7 +589,7 @@ export default function AutomationFind({
589
  title={
590
  cats.length
591
  ? "Values this workspace has actually seen, most-seen first. Adds to the list."
592
- : "No category values have been seen in your data yet β€” type one."
593
  }
594
  value=""
595
  disabled={!cats.length}
 
589
  title={
590
  cats.length
591
  ? "Values this workspace has actually seen, most-seen first. Adds to the list."
592
+ : "No category values have been seen in your data yet. Type one."
593
  }
594
  value=""
595
  disabled={!cats.length}
web/src/automation/AutomationSurface.tsx CHANGED
@@ -1,806 +1,814 @@
1
- // ---------------------------------------------------------------------------
2
- // automation/AutomationSurface.tsx β€” the Automation surface (contract C-AUTONAV).
3
- //
4
- // β›” THE CONTRACT THIS FILE GUARANTEES, verbatim: this module path, a DEFAULT
5
- // export, and NO PROPS. The shell mounts `<AutomationSurface />` and hands it
6
- // nothing; it fetches its own data from `/api/v1/automations`. That is the whole
7
- // interface, and it is stated here because the mount line lives in a file this
8
- // session does not own β€” a named export would cost a cross-session round trip.
9
- //
10
- // "Automation replaces the Views rail" (owner) is implemented the way the wave
11
- // scout established: the secondary rail is NOT a shell concept β€” each surface
12
- // owns its own. So this renders a sibling of `.cg-views` built from the same
13
- // `--lp-rail-w` tokens and the same fold behaviour, and the Views rail is
14
- // untouched.
15
- // ---------------------------------------------------------------------------
16
- import type { KeyboardEvent as ReactKeyboardEvent } from "react";
17
- import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
18
-
19
- import type { AutomationOpenDetail } from "../apiContract";
20
- import { AUTOMATION_OPEN_EVENT } from "../apiContract";
21
- // ⭐ WAVE 26 Β· ITEM 18 / R14 + C7 β€” THE LOOPABLE LOOP MARK, CONSUMED, NEVER REDRAWN.
22
- // The owner's reason is the mark's own geometry: *"these automations are basically loops."*
23
- // C7 says consume the existing icon component and do not inline a new SVG path, and
24
- // `shell/Brand.tsx`'s header says why in more detail than a rule could: the mark's one source of
25
- // truth is generated, it has already been hand-redrawn once, and the copy silently painted LAST
26
- // WAVE'S BRAND while a comment asserted parity. So this imports the component that POINTS at the
27
- // generated artifact. [[loopable-nav-logo-toggle]] β€” one element paints the mark.
28
- import { Mark } from "../shell/Brand";
29
- import type { Automation, AutomationList } from "./automationApi";
30
- import {
31
- AutomationError,
32
- cachedAutomations,
33
- createAutomation,
34
- listAutomations,
35
- liveStepOf,
36
- patchAutomation,
37
- rememberAutomation,
38
- } from "./automationApi";
39
-
40
- /**
41
- * ⭐⭐ WAVE 30 Β· T21 (owner items 4 + 5) β€” THE EDITOR STOPS SHIPPING INSIDE THE LIST.
42
- *
43
- * β›” THIS `lazy()` IS THE TICKET. `AutomationDetail` was a STATIC import, and it statically pulls
44
- * `AutomationBuilder` (which in turn pulls `CondBuilder`, `PresetPlan` and `TriggerPicker`),
45
- * `AutomationFind` and `AutomationTrigger` β€” so the whole editor was linked into the one chunk a
46
- * person must download before the RAIL can paint a list of names. MEASURED at 88,771 B, against
47
- * `HomePage` at 4,814 and `ConnectorsPage` at 3,551. That is the "Automation takes forever to
48
- * load" complaint, and it had survived two waves because nothing asserted a number.
49
- *
50
- * β›” AND IT NEEDS ITS OWN `<Suspense>`, NOT `Shell.tsx`'s. `Lazily` already wraps this whole
51
- * surface, but a Suspense boundary catches ANY suspending descendant β€” so without an inner
52
- * boundary, opening an automation would suspend the boundary that owns the RAIL and blank the
53
- * list you just clicked in. The inner one keeps the rail painted while the editor streams in.
54
- * `fallback={null}` is deliberate and is `Lazily`'s own choice, not a shortcut: the work area is
55
- * simply not there for the moment the chunk is in flight, which is what every other route in this
56
- * product already does. ⚠ A skeleton here would be a NEW loading screen in the same wave that
57
- * exists to remove one (T22).
58
- * ⚠ Default export, checked: `Shell.tsx:53` records that `lazy()` over a NAMED export fails at
59
- * runtime, on click, and only for that one surface β€” `AutomationDetail` exports default.
60
- */
61
- const AutomationDetail = lazy(() => import("./AutomationDetail"));
62
-
63
- /**
64
- * The run poll's cadence (item 6). Named constants because they are a MEASURED trade-off, not a
65
- * taste: `POLL_MAX_MS` is the longest a finished run can still look live, and it is the only
66
- * cost the backoff has.
67
- */
68
- const POLL_MIN_MS = 2500;
69
- const POLL_MAX_MS = 8000;
70
-
71
- /*
72
- * ⭐ WAVE 26 Β· ITEM 18 / R14 β€” `stateTitle` STOOD HERE AND IS DELETED WITH THE DOT IT DESCRIBED.
73
- *
74
- * It composed the dot's tooltip ("Running now β€” <step>", "Last run failed β€” <summary>"), so it had
75
- * exactly one reader and no reason to outlive it. Deleting the render and keeping the composer is
76
- * how a file accumulates functions that look live and are not β€” and `noUnusedLocals` would have
77
- * caught this one, which is not a reason to lean on it: the NEXT such function might still have a
78
- * second caller and compile fine.
79
- *
80
- * β›” WHY THE TOOLTIP WAS NOT MOVED ONTO THE MARK INSTEAD. R14 is "same mark on every automation;
81
- * no status colour". Hanging "Last run failed" off a mark that is deliberately status-free just
82
- * moves the status channel into `title`/`aria-label`, where it is worse: invisible to the eye,
83
- * announced to a screen reader, and contradicting the visual. The run state has a home one click
84
- * away β€” the Builder's own "Last run succeeded / failed" lines, which R13 explicitly keeps.
85
- *
86
- * ⚠ `stateOf` ITSELF IS UNTOUCHED in `automationApi.ts`. `Shell.tsx:64/1095` imports it and
87
- * `liveStepOf` calls it β€” deleting the export to tidy up this file would break a fence I do not own.
88
- */
89
-
90
- /**
91
- * The rail's second line β€” ONE fact, not two.
92
- *
93
- * ⚠ It carried "Next 2026-08-04 06:00 · last 2026-08-04 00:20", and the rail is `--lp-rail-w`
94
- * wide, so it rendered as "Next 2026-08-04 06:00 Β· l…" β€” a second line whose only complete word
95
- * was "Next". Two facts that both truncate are worth less than one that fits. The forward-looking
96
- * one wins when a schedule exists (it is the question the rail answers: does this run itself?),
97
- * the last run otherwise. The full picture is one click away in the editor's run history, and the
98
- * row's `title` already says both.
99
- *
100
- * Caught by READING the screenshot: the DOM was correct and the CSS ellipsis was doing exactly
101
- * its job. [[ui-invisible-to-assertions]] β€” judge the pixels even when everything is green.
102
- */
103
- function railSubtitle(a: Automation): string {
104
- /*
105
- * ⭐ WAVE 24 (owner item 6, "Run once now is laggy / looks stuck") β€” WHILE IT IS RUNNING,
106
- * THE ONE FACT WORTH THE LINE IS WHAT IT IS DOING.
107
- *
108
- * MEASURED, not guessed (`scratchpad/perf_automations.py`, mailbox D-5): the engine has been
109
- * publishing a live step the whole time (`status.step`, `routes_automation.py:50-51`) and NO
110
- * SURFACE HAS EVER RENDERED IT. So a `discover_instagram` run parked in its legitimate
111
- * `BD_FILTER_WAIT = 120 s` vendor wait and a genuinely hung thread were pixel-identical: a
112
- * pulsing blue dot and a subtitle still reciting the schedule. "Looks stuck" was not a
113
- * performance problem β€” the poll costs 23 ms β€” it was the product declining to say.
114
- *
115
- * It takes the line rather than joining it, for the reason this function's note already
116
- * gives: the rail is `--lp-rail-w` wide and two facts that both truncate are worth less than
117
- * one that fits. The schedule is still one click away and the dot's `title` carries both.
118
- */
119
- const step = liveStepOf(a);
120
- if (step) return step;
121
- if (a.schedule?.enabled) return a.nextRunAt ? `Next ${a.nextRunAt}` : "Scheduled";
122
- /*
123
- * ⭐ WAVE 26 Β· ITEM 19 / R13 β€” `Last run 2026-08-06 13:58` IS DELETED FROM THIS LINE.
124
- *
125
- * β›” SCOPE, because R13 draws a line that is easy to over-read: the ruling names THIS secondary
126
- * line under the automation's name. The Builder's "Last run succeeded / failed" RESULT lines
127
- * (`AutomationBuilder.tsx:1676`, `:1679`) are a different surface and are explicitly NOT in
128
- * scope β€” they are the answer to "Run once now", so deleting them would leave a button with no
129
- * outcome.
130
- *
131
- * "Manual only" STAYS, and the distinction is the ruling's own: a dated run record is a
132
- * changing FACT ABOUT THE PAST, while "this one has no schedule" is a stable property of the
133
- * automation β€” the same question `Next …`/`Scheduled` answers for its siblings. Dropping it too
134
- * would leave a manual automation with a blank second line and nothing saying why.
135
- */
136
- return "Manual only";
137
- }
138
-
139
- /*
140
- * β›” W23-W5's LISTENER IS AT MODULE SCOPE, AND THAT IS THE POINT β€” not a stylistic choice.
141
- *
142
- * The frame's click-through sets `window.location.hash = "#/automation"` and signals on the
143
- * VERY NEXT LINE (Shell.tsx:1647-1650, and its comment is right that the order matters). But a
144
- * hash write does not mount anything synchronously: `hashchange` is delivered as a task, the
145
- * router state then updates, React renders, and only THEN does a component effect subscribe.
146
- * A listener registered in `useEffect` therefore misses every click that arrives from another
147
- * page β€” which is every click, since a reader looking at Alerts is by definition not already
148
- * on this surface. The event would dispatch into nothing and every gate would stay green:
149
- * exactly the wave-20 item-25 failure the contract's own note describes, reproduced one layer
150
- * down.
151
- *
152
- * This module is imported statically by the shell, so this listener exists from app start. It
153
- * LATCHES the request; the component consumes the latch when it mounts and hears live events
154
- * while it is mounted. A request nobody claims is dropped on the next one β€” the latch is a
155
- * one-slot mailbox, never a queue.
156
- */
157
- let pendingOpen: AutomationOpenDetail | null = null;
158
- const openSubscribers = new Set<(detail: AutomationOpenDetail) => void>();
159
-
160
- if (typeof window !== "undefined") {
161
- window.addEventListener(AUTOMATION_OPEN_EVENT, (event) => {
162
- const detail = (event as CustomEvent<AutomationOpenDetail>).detail;
163
- if (!detail?.autoId) return;
164
- if (openSubscribers.size) {
165
- for (const notify of openSubscribers) notify(detail);
166
- return;
167
- }
168
- pendingOpen = detail;
169
- });
170
- }
171
-
172
- /*
173
- * β›” THE `AUTOMATION_CREATE_EVENT` LISTENER STOOD HERE AND IS DELETED WITH ITS SIGNALLER
174
- * (wave 25 item 5a, ruling R8) β€” latch, subscriber set and all.
175
- *
176
- * Wave 24 built it to close the opposite defect: the event was declared, signalled from
177
- * `Shell.tsx`, and consumed NOWHERE, so all three "Automated database" doors navigated to
178
- * `#/automation` and then did nothing, in production, with every gate green. R8 now deletes those
179
- * three doors β€” creating a database and pointing an automation at it are two acts β€” and they were
180
- * the event's only signaller.
181
- *
182
- * β›” SO THIS SIDE GOES TOO, IN THE SAME CHANGE. A listener with no signaller is the same defect
183
- * read from the other end: it compiles, it costs nothing at runtime, and it reads to the next
184
- * person as a live channel. The rule that catches both is now DERIVED rather than remembered β€”
185
- * `verify_automation_ui.py` enumerates every `AUTOMATION_*_EVENT` in `apiContract.ts` and demands
186
- * a signal site AND a listener site for each.
187
- *
188
- * ⚠ NOTHING ABOUT CREATING AN AUTOMATION IS LOST. `createAndOpen` is untouched and still has two
189
- * doors, both on this surface: the rail's button and the empty state's. What is gone is the claim
190
- * that a database can be created BY asking for an automation.
191
- */
192
-
193
- /**
194
- * A default name for a brand-new automation, and it has to be one the server ACCEPTS.
195
- *
196
- * MEASURED against the live route rather than assumed (`scratchpad/probe_names.py`): a create
197
- * with no name is a 400 (`name the automation`), and duplicate names are ALLOWED. So the name
198
- * cannot be omitted, and a fixed literal would stack "New automation" three deep in a rail that
199
- * sorts by name with nothing to tell the rows apart. Numbering from the existing list is a
200
- * client-side convenience over a server that does not care: if two tabs race, the loser gets a
201
- * duplicate name, which is legal, visible and renameable β€” never an error the user has to read.
202
- */
203
- function nextAutomationName(existing: Automation[]): string {
204
- const taken = new Set(existing.map((a) => (a.name || "").trim().toLowerCase()));
205
- for (let n = existing.length + 1; ; n += 1) {
206
- const candidate = `Automation ${n}`;
207
- if (!taken.has(candidate.toLowerCase())) return candidate;
208
- }
209
- }
210
-
211
- export default function AutomationSurface() {
212
- /* ⭐ T22 β€” SEEDED FROM THE CLIENT MEMO, so a revisit inside the freshness window never passes
213
- through `data === null` and therefore never paints the skeleton. The lazy initialiser runs
214
- once, on mount, BEFORE the first paint β€” `useState(cachedAutomations())` would call it on
215
- every render instead, which is the same value at needless cost. A cold start still returns
216
- null and still gets the skeleton: it is made rare, not removed (see `automationApi.ts`). */
217
- const [data, setData] = useState<AutomationList | null>(() => cachedAutomations());
218
- const [error, setError] = useState("");
219
- const [activeId, setActiveId] = useState<string>("");
220
- const [railShut, setRailShut] = useState(false);
221
- const [busy, setBusy] = useState("");
222
- /**
223
- * ⭐ WAVE 24 / C-CREATE β€” a create is now a ROUND TRIP, not a local wizard, so the door has to
224
- * say it is busy. Without this a second click while the POST is in flight makes a second
225
- * automation, and the server allows duplicate names, so the user gets two rows that look
226
- * identical and no error to explain either of them.
227
- */
228
- const [creatingNow, setCreatingNow] = useState(false);
229
- /** An open request waiting for the list to arrive (see the resolver below). */
230
- const [openRequest, setOpenRequest] = useState<AutomationOpenDetail | null>(null);
231
- /* β›” `createRequest` LEFT WITH THE EVENT THAT SET IT (wave 25, R8) β€” see the tombstone above. */
232
- const listRef = useRef<HTMLDivElement | null>(null);
233
-
234
- /**
235
- * β›” C14 LEG 2 β€” THE ID THE USER LAST ASKED FOR, and it is a ref because it has to be
236
- * written SYNCHRONOUSLY, inside the click, before any promise that was already in flight
237
- * can resolve. State would not do: a resolution racing React's commit would read the
238
- * previous value, which is the exact window this guard exists to close.
239
- */
240
- const wantedId = useRef("");
241
-
242
- /**
243
- * ⭐ THE ONLY PLACE THE RAIL'S SELECTION MOVES ON PURPOSE. Every deliberate change of
244
- * automation β€” a rail click, a create, a delete β€” goes through here, so "what the user
245
- * asked for" and "what is on screen" are written in one statement and cannot drift apart.
246
- * A resolution that wants to steer the rail compares itself against `wantedId` instead.
247
- */
248
- const select = useCallback((id: string, _stage = "") => {
249
- wantedId.current = id;
250
- setActiveId(id);
251
- }, []);
252
-
253
- /**
254
- * ⭐⭐ W33 (C-18, from lane C) β€” WHICH PANE AN INBOX ITEM ASKED FOR, and it was DROPPED here.
255
- *
256
- * β›” THE PAYLOAD WAS DECLARED, TRANSPORTED, AND IGNORED AT THE LAST HOP.
257
- * `routes_alerts.py` sets `tab: "runs"` on a notification's target, `inboxModel.routeForTarget`
258
- * forwards it and `Shell.tsx` signals `{autoId, tab}` β€” and this file read `openRequest.tab`
259
- * NOWHERE. So a person clicking *"12 new creators found"* to see the RUN landed on Properties,
260
- * which is [[flag-shipped-without-its-writer]] with the writer present and the READER missing.
261
- * ⚠ NOT FIXED BY DELETING `tab` FROM THE CONTRACT, which was the other option C offered: the
262
- * field is the only thing that carries "you clicked a run, so show runs", and an inbox that
263
- * lands you on the wrong pane is the complaint, not the field.
264
- * ⚠ IT IS A ONE-SHOT REQUEST, NOT A CONTROLLED VALUE. Forcing the pane on every render would
265
- * take the pane control away from the person the moment after they used it β€” so this is
266
- * consumed by `AutomationDetail` as an INITIAL value keyed on the automation, and cleared here
267
- * with the request that carried it.
268
- */
269
- const [openTab, setOpenTab] = useState<"properties" | "runs" | "">("");
270
-
271
- /**
272
- * β›” C14 LEG 3 (the paint half) β€” A STALE READ MAY NOT REPAINT.
273
- *
274
- * Every call takes the next generation; only the newest one is allowed to `setData`. The
275
- * defect this closes is not cosmetic: a poll issued BEFORE a delete resolves AFTER it, and
276
- * a list that still contains the deleted automation puts a row back in the rail that the
277
- * user has just watched disappear. Same for a toggle, and same for a create.
278
- *
279
- * ⚠ THE RETURN VALUE IS NOT GUARDED, deliberately. The caller asked a question and gets
280
- * its own answer β€” coupling the two would mean a create whose `load()` was overtaken by a
281
- * poll silently failed to select the automation it had just made.
282
- */
283
- const gen = useRef(0);
284
-
285
- const load = useCallback(async (abort?: AbortSignal) => {
286
- const mine = ++gen.current;
287
- try {
288
- const next = await listAutomations(abort);
289
- if (mine === gen.current) {
290
- setData(next);
291
- setError("");
292
- }
293
- return next;
294
- } catch (e) {
295
- if ((e as Error)?.name === "AbortError") return null;
296
- if (mine === gen.current)
297
- setError(
298
- e instanceof AutomationError
299
- ? e.message
300
- : "The automation service did not answer."
301
- );
302
- return null;
303
- }
304
- }, []);
305
-
306
- /**
307
- * ⭐⭐ WAVE 31 Β· T31 β€” one freshly-written automation replaces its own row, with no round trip.
308
- *
309
- * ⚠ IT TAKES THE GENERATION, and that is not decoration. `load`'s guard exists so a stale READ
310
- * cannot repaint; a WRITE response is by definition newer than any read already in flight, so it
311
- * must win the same race rather than sit outside it. Without the bump, a `GET /automations`
312
- * issued before the save could resolve after the merge and paint the pre-save row back β€” the
313
- * exact ghost `gen` was introduced for, arriving through the new door.
314
- *
315
- * ⚠ Returns FALSE when there is nothing to merge into (a cold surface has no memo), and the
316
- * caller then does a real load. Painting a one-row list would be worse than a round trip.
317
- */
318
- const mergeSaved = useCallback((fresh: Automation) => {
319
- const next = rememberAutomation(fresh);
320
- if (!next) return false;
321
- gen.current += 1;
322
- setData(next);
323
- setError("");
324
- return true;
325
- }, []);
326
-
327
- useEffect(() => {
328
- const ac = new AbortController();
329
- void load(ac.signal);
330
- return () => ac.abort();
331
- }, [load]);
332
-
333
- /**
334
- * ⭐⭐ WAVE 24 / C-CREATE (a) β€” CREATE AND OPEN. This REPLACED the three-step wizard.
335
- *
336
- * R6 is what makes it possible: `plain` is a real kind with no machine graph nodes, and it is
337
- * what `POST /automations` stores when the body names none. So "new automation" stopped being
338
- * a question ("which of three kinds?" β€” two of which can no longer be created at all) and
339
- * became what it says: a new automation, open, on its trigger picker. Choosing a SOURCE is now
340
- * picking the `ig_profile_match` trigger, which is where that choice belongs.
341
- *
342
- * β›” THE GUARD IS A REF, not the `creatingNow` state, and this file already carries the scar
343
- * that explains why (C14 leg 2): a state read inside a click closure is the value from the
344
- * last render, so two fast clicks both see `false` and both POST. Duplicate names are legal
345
- * server-side, so the user would get two identical rows and no error. The ref is written
346
- * synchronously, inside the click, before anything can await.
347
- */
348
- const creatingRef = useRef(false);
349
- const createAndOpen = useCallback(async () => {
350
- if (creatingRef.current) return;
351
- creatingRef.current = true;
352
- setCreatingNow(true);
353
- try {
354
- const res = await createAutomation({
355
- name: nextAutomationName(data?.automations || []),
356
- });
357
- const id = res?.automation?.id || "";
358
- // The create flow is the one caller allowed to name a different id (C14 leg 2) β€” the
359
- // automation did not exist when the click happened, so there is nothing to race with.
360
- const next = await load();
361
- if (id && next) select(id);
362
- } catch (e) {
363
- setError(
364
- e instanceof AutomationError ? e.message : "The automation was not created."
365
- );
366
- } finally {
367
- creatingRef.current = false;
368
- setCreatingNow(false);
369
- }
370
- }, [data, load, select]);
371
-
372
- // A run is a background thread on the server, so the surface has to ASK whether it
373
- // finished. Polling only while something is actually running keeps an idle surface
374
- // silent β€” a fixed interval would be a request every few seconds forever, for a page
375
- // whose contents change a handful of times a day.
376
- //
377
- // β›” C14 LEG 3 (the abort half). It used to call `load()` bare β€” no signal, nothing to
378
- // cancel β€” so a request the interval had already issued kept going after the effect that
379
- // owned it was gone. One controller per effect run, aborted with the interval, means a poll
380
- // cannot outlive the condition that justified it. The generation guard inside `load` covers
381
- // the rest: a response that survives the abort still cannot repaint over a newer one.
382
- //
383
- // ⭐ WAVE 24 (item 6, C-PERF) β€” THE INTERVAL BECAME A BACKOFF, and the measurement is why it is
384
- // a SMALL change rather than the big one the contract's hypothesis 2 asked for.
385
- //
386
- // MEASURED (`scratchpad/perf_automations.py`, mailbox D-5): this poll costs ~23 ms and 24 KB
387
- // for a ten-automation tenant, and the engine work the hypothesis blamed β€” a `graph()` rebuild
388
- // per automation β€” is **41 Β΅s each**, i.e. under 4% of the request. The payload was never the
389
- // problem, so nothing here gets a cheaper endpoint.
390
- //
391
- // What the numbers DO indict is the aggregate: a discovery run legitimately blocks up to
392
- // `BD_FILTER_WAIT` = 120 s, and two independent 2.5 s polls across this file and the detail
393
- // spend ~96 requests and ~2.3 MB over that window β€” in the same single process the run itself
394
- // is a thread in. So the delay grows 2.5 β†’ 5 β†’ 8 s and stops there.
395
- //
396
- // ⚠ NOT "to learn nothing", and the distinction is the whole justification. That WAS true when
397
- // this file rendered no live step: the poll's only observable effect was a dot that had already
398
- // been pulsing for two minutes. It stopped being true in this same change β€” `liveStepOf` now
399
- // paints `status.step`, so a poll carries the one fact worth having. The backoff is therefore
400
- // NOT "stop asking a pointless question"; it is FEWER ROUND TRIPS FOR THE SAME INFORMATION, on
401
- // a step text that changes every few seconds at most, not every 2.5.
402
- // The cap is deliberately low: it bounds how long a FINISHED run can still look live, which is
403
- // the only thing a backoff can make worse, and 8 s of that is worth ~β…” fewer requests.
404
- //
405
- // β›” THE KEY IS THE RUNNING SET, NOT A BOOLEAN, and that is what makes the reset correct: the
406
- // effect re-runs β€” and the delay drops back to 2.5 s β€” the moment a run starts or finishes, so
407
- // a user who clicks Run now gets the fast cadence again instead of inheriting the tail of the
408
- // previous run's backoff. A bare `anyRunning` boolean cannot see the second run start.
409
- // A step text changing does NOT re-key it, so the interval never thrashes.
410
- //
411
- // C14 LEG 3 (the abort half) is unchanged and still load-bearing: one controller per effect
412
- // run, aborted with the timer, so a poll cannot outlive the condition that justified it. The
413
- // generation guard inside `load` covers the rest.
414
- const runningKey = (data?.automations || [])
415
- .filter((a) => a.running)
416
- .map((a) => a.id)
417
- .sort()
418
- .join(",");
419
- useEffect(() => {
420
- if (!runningKey) return undefined;
421
- const ac = new AbortController();
422
- let delay = POLL_MIN_MS;
423
- let timer = 0;
424
- const tick = () => {
425
- void load(ac.signal);
426
- delay = Math.min(delay * 2, POLL_MAX_MS);
427
- timer = window.setTimeout(tick, delay);
428
- };
429
- timer = window.setTimeout(tick, delay);
430
- return () => {
431
- window.clearTimeout(timer);
432
- ac.abort();
433
- };
434
- }, [runningKey, load]);
435
-
436
- // ── W23-W5, the surface's half: hear the request, then answer it when we CAN ──────────
437
- useEffect(() => {
438
- const notify = (detail: AutomationOpenDetail) => setOpenRequest(detail);
439
- openSubscribers.add(notify);
440
- if (pendingOpen) {
441
- const latched = pendingOpen;
442
- pendingOpen = null;
443
- setOpenRequest(latched);
444
- }
445
- return () => {
446
- openSubscribers.delete(notify);
447
- };
448
- }, []);
449
-
450
- /* β›” THE C-CREATE(b) SUBSCRIBE EFFECT AND ITS RESOLVER STOOD HERE (wave 25, R8). They heard the
451
- create event and, once the list had arrived, called `createAndOpen` β€” the "wait for the list
452
- or every automation is named Automation 1" note lives on in `nextAutomationName`, which is
453
- still numbered off `existing`. With no signaller there is nothing to hear. */
454
-
455
- /**
456
- * ⚠ THE REQUEST OUTLIVES THE FETCH, and it has to. The reader clicks a notification from
457
- * another page, so this surface is mounting WITH AN EMPTY LIST β€” "select it if it is in the
458
- * list" would drop every real click and keep only the one case where the reader was already
459
- * here. So the request is held until `data` exists, and only then answered.
460
- *
461
- * An automation the reader can no longer open does NOTHING (the contract's own words): the
462
- * request is cleared either way, so a stale id cannot sit here re-firing against every
463
- * subsequent list.
464
- */
465
- useEffect(() => {
466
- if (!openRequest || !data) return;
467
- const found = (data.automations || []).some((a) => a.id === openRequest.autoId);
468
- if (found) {
469
- select(openRequest.autoId, openRequest.stageId || "");
470
- // C-18: the pane the notification asked for, read at the one moment it means something.
471
- const want = String((openRequest as { tab?: string }).tab || "");
472
- setOpenTab(want === "runs" || want === "properties" ? want : "");
473
- }
474
- setOpenRequest(null);
475
- }, [openRequest, data, select]);
476
-
477
- const items = data?.automations || [];
478
- const active = items.find((a) => a.id === activeId) || null;
479
-
480
- /**
481
- * ⭐⭐ WAVE 32 Β· T43 (owner item 2) β€” TURNING AN AUTOMATION ON OR OFF STOPS TAKING FOREVER.
482
- *
483
- * Owner: turning an automation on or off "takes forever". β›” THE TICKET'S HYPOTHESIS WAS THE
484
- * SERVER AND IT IS NOT β€” MEASURED. `engine.patch` for a schedule-only body takes ONE
485
- * `automations` bucket read, ZERO `user_tables` reads and ~1 ms against a 2.8 MB fixture, and
486
- * `GET /automations` is one read too since W30-T12. The cost was never the document shape
487
- * (D-179/D-175's family): it was that this handler paid **two sequential round trips** for one
488
- * click, and painted nothing until BOTH had landed. The second one re-downloaded the entire
489
- * rail payload β€” 88,771 B measured, see the note at the top of this file β€” to learn one boolean
490
- * the first response had already returned.
491
- *
492
- * β›” AND THE FIX ALREADY EXISTED IN THIS FILE, POINTED AT A DIFFERENT BUTTON. `mergeSaved` is
493
- * W31-T31, built for the detail pane's saves, with its own note about the write response being
494
- * newer than any read in flight. The rail's own switch never got it β€” a fix applied to one of
495
- * two twins, which is this wave's recurring shape.
496
- *
497
- * Three states, in order, and the middle one is what the owner actually asked for:
498
- * 1. FLIP IT NOW. The switch is the user's instruction, not a question; painting it after a
499
- * network round trip is what "takes forever" describes even when the round trip is fast.
500
- * 2. adopt the server's own answer when it lands, which carries the derived fields an
501
- * optimistic row cannot invent (`nextRunAt`, the status line).
502
- * 3. put the OLD row back if the write failed, beside the error β€” an optimistic switch that
503
- * stays on after a refusal is a lie, and a worse one than a slow switch.
504
- *
505
- * ⚠ `mergeSaved` TAKES THE GENERATION, which is what stops [[refetch-eats-its-own-write]] here:
506
- * a `GET /automations` issued before the click can still resolve after it, and without the bump
507
- * it would paint the pre-toggle row back over both the optimistic flip and the confirmation.
508
- * ⚠ `busy` is no longer what makes the switch look right β€” it stays only to keep a second click
509
- * from racing the first.
510
- */
511
- const toggleEnabled = async (a: Automation) => {
512
- const flipped: Automation = {
513
- ...a,
514
- schedule: { ...a.schedule, enabled: !a.schedule.enabled },
515
- };
516
- setBusy(a.id);
517
- const painted = mergeSaved(flipped);
518
- try {
519
- const { automation: fresh } = await patchAutomation(a.id, {
520
- schedule: { cron: a.schedule.cron, enabled: !a.schedule.enabled },
521
- });
522
- // A cold surface has no memo to merge into (`mergeSaved` answers false), and a one-row
523
- // repaint would be worse than a round trip β€” the same rule `onSaved` follows.
524
- if (!fresh || !mergeSaved(fresh)) await load();
525
- } catch (e) {
526
- if (painted) mergeSaved(a);
527
- setError(e instanceof AutomationError ? e.message : "That change was not saved.");
528
- } finally {
529
- setBusy("");
530
- }
531
- };
532
-
533
- const onRailKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
534
- if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
535
- const rows = Array.from(
536
- listRef.current?.querySelectorAll<HTMLButtonElement>(".auto-row-main") || []
537
- );
538
- if (!rows.length) return;
539
- event.preventDefault();
540
- const at = rows.indexOf(document.activeElement as HTMLButtonElement);
541
- const next = event.key === "ArrowDown" ? (at + 1) % rows.length
542
- : (at - 1 + rows.length) % rows.length;
543
- rows[next < 0 ? 0 : next]?.focus();
544
- };
545
-
546
- return (
547
- <div className="auto-surface">
548
- <aside
549
- className={"auto-rail" + (railShut ? " is-collapsed" : "")}
550
- aria-label="Automations"
551
- >
552
- {/* The same three-bars fold control the Views rail carries β€” the two rails are
553
- one idea, so they must not fold with two different affordances. */}
554
- <div className="auto-rail-top">
555
- <button
556
- type="button"
557
- className="cg-rail-toggle"
558
- aria-label={railShut ? "Expand automations" : "Minimize automations"}
559
- aria-expanded={!railShut}
560
- title={railShut ? "Expand automations" : "Minimize automations"}
561
- onClick={() => setRailShut((v) => !v)}
562
- >
563
- <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
564
- <path
565
- d="M2.5 4.4h11M2.5 8h11M2.5 11.6h11"
566
- stroke="currentColor"
567
- strokeWidth="1.35"
568
- strokeLinecap="round"
569
- />
570
- </svg>
571
- </button>
572
- </div>
573
-
574
- <div className="auto-create">
575
- <button
576
- type="button"
577
- className="auto-create-btn"
578
- disabled={creatingNow}
579
- onClick={() => void createAndOpen()}
580
- >
581
- <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
582
- <path
583
- d="M8 3.4v9.2M3.4 8h9.2"
584
- stroke="currentColor"
585
- strokeWidth="1.5"
586
- strokeLinecap="round"
587
- />
588
- </svg>
589
- New automation
590
- </button>
591
- </div>
592
-
593
- <div className="auto-list" ref={listRef} onKeyDown={onRailKey}>
594
- {items.map((a) => {
595
- return (
596
- <div
597
- key={a.id}
598
- className={"auto-row" + (a.id === activeId ? " is-active" : "")}
599
- >
600
- <button
601
- type="button"
602
- className="auto-row-main"
603
- onClick={() => select(a.id)}
604
- >
605
- {/* ⭐ ITEM 18 / R14 β€” THE LOOP MARK, WHERE THE STATUS DOT WAS. Same mark on
606
- every row, no status colour: the owner's point is that an automation IS a
607
- loop, not that this one is green. The `.auto-loopmark` wrapper is what sizes
608
- it β€” `.lp-mark` is shell-owned CSS and outside this session's region, so the
609
- box is mine and the mark is theirs. */}
610
- <span className="auto-loopmark">
611
- <Mark size={15} />
612
- </span>
613
- <span className="auto-row-text">
614
- <span className="auto-row-name">{a.name}</span>
615
- <span className="auto-row-desc">{railSubtitle(a)}</span>
616
- </span>
617
- </button>
618
- <button
619
- type="button"
620
- className={"auto-row-switch" + (a.schedule?.enabled ? " is-on" : "")}
621
- disabled={busy === a.id}
622
- aria-pressed={!!a.schedule?.enabled}
623
- title={
624
- a.schedule?.enabled
625
- ? `Scheduled: ${a.schedule.cron}. Click to pause.`
626
- : "Not scheduled. Click to enable."
627
- }
628
- onClick={() => void toggleEnabled(a)}
629
- >
630
- <span className="auto-row-switch-knob" />
631
- </button>
632
- </div>
633
- );
634
- })}
635
- {/* ONE LINE (R13). It used to describe the kinds β€” "one reads a public web page…
636
- the other fills an automation column…" β€” which was two sentences, wrong by the
637
- time a third kind shipped, and printed inside a `--lp-rail-w` column. What the
638
- kinds are belongs to the create form, which is one click away and lists all of
639
- them from the server. */}
640
- {/* ⭐ WAVE 29 (W29-T01) β€” A LOADING RAIL IS NOT AN EMPTY ONE. `data` is null until the
641
- first `listAutomations()` resolves, and this branch read only `items.length`, so
642
- every visit to Automation asserted "No automations yet." to a person who has
643
- several β€” then replaced it with their list. Half of owner item 5's "takes a while
644
- to appear" is that sentence: the wait is real, but being told you own nothing is
645
- what makes it read as broken rather than slow. `data === null` is the one state
646
- that means NOBODY HAS LOOKED, and it gets skeleton rows, not a claim. */}
647
- {data === null && !error ? (
648
- <div className="auto-rail-loading" aria-hidden="true">
649
- <span className="auto-rail-skel" />
650
- <span className="auto-rail-skel" />
651
- <span className="auto-rail-skel" />
652
- </div>
653
- ) : null}
654
- {data !== null && !items.length && !error ? (
655
- <p className="auto-rail-empty">No automations yet.</p>
656
- ) : null}
657
- </div>
658
- </aside>
659
-
660
- <section className="auto-main">
661
- {error ? (
662
- <div className="auto-banner is-error" role="alert">
663
- {error}
664
- </div>
665
- ) : null}
666
- {data && !data.storeAvailable ? (
667
- <div className="auto-banner is-warn" role="status">
668
- The tenant store is unavailable, so nothing can be saved right now.
669
- </div>
670
- ) : null}
671
-
672
- {/*
673
- β›” THE `creating` BRANCH IS GONE (wave 24, C-CREATE a / owner items 3 + 4).
674
- `AutomationCreate` used to render here β€” a three-step wizard whose first step asked
675
- which DATABASE to write into (an existing one, a new one, or one the automation itself
676
- would create) and whose second asked which of three KINDS.
677
- R6 retires that question at the root: two of the three kinds can no longer be created
678
- at all, and the third is now reached by picking a trigger. A new automation is created
679
- the moment it is asked for (`createAndOpen`) and opens on its own Builder, so there is
680
- no intermediate face left to render and no `creating` state to hold.
681
- ⭐ WAVE 25 (R8) retires the LAST of that vocabulary: the third answer was the
682
- "automated database", and it is gone from every door in the product. This button is now
683
- one of exactly two ways to make an automation, and both are on this surface.
684
- The import went with it β€” it is what would break `npx tsc -b` for the WHOLE client the
685
- moment session B deletes the file, which is why this deletion is sequenced first.
686
- */}
687
- {/* T21: the editor's own Suspense boundary β€” see the `lazy()` above for why it cannot be
688
- `Shell.tsx`'s. `fallback={null}`, matching `Lazily`. */}
689
- {active ? (
690
- <Suspense fallback={null}>
691
- <AutomationDetail
692
- key={active.id}
693
- automation={active}
694
- /* C-18: which pane the Inbox asked for, or "" for the ordinary open. Consumed ONCE,
695
- as an initial value β€” see `openTab`'s note on why it is not controlled. */
696
- openTab={openTab}
697
- /* β›” `kinds={data?.kinds || []}` LEFT HERE WITH THE PROP IT FED (wave 25, D-57) β€”
698
- both halves in one change, because either alone is a `tsc` error. */
699
- cronPresets={data?.cronPresets || []}
700
- paidReady={!!data?.paidReady}
701
- discover={data?.discover}
702
- // The trigger vocabulary (C3). Forwarded as-is β€” absent stays absent, so the
703
- // trigger face can tell "the server offered nothing" from "the server offered
704
- // an empty list" rather than collapsing both into a picker with no options.
705
- triggers={data?.triggers}
706
- // C4's action menu + the builder's ceilings. Forwarded as-is for the same reason
707
- // `triggers` is: absent must stay absent, so the builder can tell "the server
708
- // offered nothing" from "the server offered an empty list".
709
- catalog={data?.actionsCatalog}
710
- vocab={data?.flow}
711
- // `?? null` and never `|| {enabled:false}`: an absent tick bit is "the server did
712
- // not say", which Step 1 prints as its own sentence. Defaulting it here would
713
- // turn a missing field into a claim about production (C6 amendment #1).
714
- tick={data?.tick ?? null}
715
- /*
716
- * ⭐ W24-W1 (item 6) β€” WHAT THIS RUN IS DOING, on the one surface that survives in
717
- * BOTH views. REQUIRED on the far side deliberately: an optional prop that nobody
718
- * passes degrades to "the feature does not exist", which is indistinguishable from
719
- * "it was never built" β€” and this whole item exists because a live step text rode
720
- * the wire for three waves with nothing rendering it.
721
- * `liveStepOf` returns the step ONLY while the automation is running; a stale step
722
- * from a finished run is a worse answer than none.
723
- */
724
- liveStep={liveStepOf(active)}
725
- /*
726
- * β›” C14 LEG 2 β€” THE STALE-ID WRITE-BACK, and this is the ghost's second cause.
727
- *
728
- * The detail calls `onSaved(automation.id)` from six places (save, the trigger
729
- * picker, a schedule change, a node switch, a card move, a run). Each closure
730
- * captures the automation it was mounted for, so a save that resolves AFTER the
731
- * user has clicked a different row used to call `setActiveId(the OLD id)` β€” the
732
- * rail jumped back, the detail remounted, and what the user saw was the previous
733
- * automation reappearing over the one they had just opened. It looked like a
734
- * rendering bug; it was a resolution steering the selection.
735
- *
736
- * A resolution may no longer steer anything. It reloads the list β€” that part was
737
- * always right β€” and it re-asserts the selection ONLY when its id is still the one
738
- * the user asked for, which makes the write a no-op in the good case and nothing
739
- * at all in the bad one.
740
- */
741
- onSaved={async (id, fresh) => {
742
- // ⭐⭐ WAVE 31 Β· T31 β€” CORRECT THE ONE ROW WE WERE JUST TOLD ABOUT.
743
- // `patchAutomation` and `toggleNode` return the updated definition; re-reading the
744
- // whole list to learn it is the second of the two round trips "Saving…" used to
745
- // span. `mergeSaved` keeps the memo and the component in step (see
746
- // `automationApi.rememberAutomation`) and takes the generation, because a write
747
- // response is newer than any read still in flight β€” the same rule `load` follows,
748
- // pointed the other way.
749
- if (fresh && mergeSaved(fresh)) {
750
- if (id && id === wantedId.current) select(id);
751
- return;
752
- }
753
- const next = await load();
754
- if (id && next && id === wantedId.current) select(id);
755
- }}
756
- onDeleted={async () => {
757
- select("");
758
- await load();
759
- }}
760
- />
761
- </Suspense>
762
- ) : (
763
- /*
764
- * THE EMPTY STATE IS ONE LINE AND A BUTTON (owner ruling R13 β€” "never
765
- * over-explain", now a DESIGN.md law).
766
- *
767
- * It was a heading, a three-sentence paragraph and a 130-word bulleted list
768
- * describing all three kinds. Every word of it was true and none of it was READ:
769
- * an empty state is passed through, not studied, and the person looking at it has
770
- * already decided to make an automation. The kinds are described where the choice
771
- * is actually made β€” the create form lists them FROM THE SERVER, so that copy also
772
- * cannot go stale the way this list had (it described two kinds after a third
773
- * shipped).
774
- *
775
- * The button is here rather than only in the rail because this pane is where the
776
- * eye is; a create affordance the user has to go find is the same defect as the
777
- * paragraph, spent differently.
778
- */
779
- <div className="autob-empty">
780
- {/*
781
- β›” NO TITLE HERE ANY MORE (C13, owner item 2). This pane carried an `h1`
782
- reading "Automations" at 20px/600 β€” a THIRD title treatment on a page that
783
- also had the header's editable 16px/700 input, against every database page's
784
- single 16px/650 `shell-db-name`. The shell now wraps this branch in the same
785
- `shell-db-frame` + `DbHead` a database gets (wiring W23-W1), so the page's name
786
- is drawn once, by the one component that draws every other page's name. A
787
- stand-in restyled to match would have been a second copy of the same fact,
788
- free to drift the day the header moves.
789
- */}
790
- <p className="autob-empty-line">
791
- A job this workspace runs for you, on demand or on a schedule.
792
- </p>
793
- <button
794
- type="button"
795
- className="auto-btn is-primary"
796
- disabled={creatingNow}
797
- onClick={() => void createAndOpen()}
798
- >
799
- New automation
800
- </button>
801
- </div>
802
- )}
803
- </section>
804
- </div>
805
- );
806
- }
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // automation/AutomationSurface.tsx β€” the Automation surface (contract C-AUTONAV).
3
+ //
4
+ // β›” THE CONTRACT THIS FILE GUARANTEES, verbatim: this module path, a DEFAULT
5
+ // export, and NO PROPS. The shell mounts `<AutomationSurface />` and hands it
6
+ // nothing; it fetches its own data from `/api/v1/automations`. That is the whole
7
+ // interface, and it is stated here because the mount line lives in a file this
8
+ // session does not own β€” a named export would cost a cross-session round trip.
9
+ //
10
+ // "Automation replaces the Views rail" (owner) is implemented the way the wave
11
+ // scout established: the secondary rail is NOT a shell concept β€” each surface
12
+ // owns its own. So this renders a sibling of `.cg-views` built from the same
13
+ // `--lp-rail-w` tokens and the same fold behaviour, and the Views rail is
14
+ // untouched.
15
+ // ---------------------------------------------------------------------------
16
+ import type { KeyboardEvent as ReactKeyboardEvent } from "react";
17
+ import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
18
+
19
+ import type { AutomationOpenDetail } from "../apiContract";
20
+ import { AUTOMATION_OPEN_EVENT } from "../apiContract";
21
+ // ⭐ WAVE 26 Β· ITEM 18 / R14 + C7 β€” THE LOOPABLE LOOP MARK, CONSUMED, NEVER REDRAWN.
22
+ // The owner's reason is the mark's own geometry: *"these automations are basically loops."*
23
+ // C7 says consume the existing icon component and do not inline a new SVG path, and
24
+ // `shell/Brand.tsx`'s header says why in more detail than a rule could: the mark's one source of
25
+ // truth is generated, it has already been hand-redrawn once, and the copy silently painted LAST
26
+ // WAVE'S BRAND while a comment asserted parity. So this imports the component that POINTS at the
27
+ // generated artifact. [[loopable-nav-logo-toggle]] β€” one element paints the mark.
28
+ import { Mark } from "../shell/Brand";
29
+ import type { Automation, AutomationList } from "./automationApi";
30
+ import {
31
+ AutomationError,
32
+ cachedAutomations,
33
+ createAutomation,
34
+ listAutomations,
35
+ liveStepOf,
36
+ patchAutomation,
37
+ rememberAutomation,
38
+ } from "./automationApi";
39
+
40
+ /**
41
+ * ⭐⭐ WAVE 30 Β· T21 (owner items 4 + 5) β€” THE EDITOR STOPS SHIPPING INSIDE THE LIST.
42
+ *
43
+ * β›” THIS `lazy()` IS THE TICKET. `AutomationDetail` was a STATIC import, and it statically pulls
44
+ * `AutomationBuilder` (which in turn pulls `CondBuilder`, `PresetPlan` and `TriggerPicker`),
45
+ * `AutomationFind` and `AutomationTrigger` β€” so the whole editor was linked into the one chunk a
46
+ * person must download before the RAIL can paint a list of names. MEASURED at 88,771 B, against
47
+ * `HomePage` at 4,814 and `ConnectorsPage` at 3,551. That is the "Automation takes forever to
48
+ * load" complaint, and it had survived two waves because nothing asserted a number.
49
+ *
50
+ * β›” AND IT NEEDS ITS OWN `<Suspense>`, NOT `Shell.tsx`'s. `Lazily` already wraps this whole
51
+ * surface, but a Suspense boundary catches ANY suspending descendant β€” so without an inner
52
+ * boundary, opening an automation would suspend the boundary that owns the RAIL and blank the
53
+ * list you just clicked in. The inner one keeps the rail painted while the editor streams in.
54
+ * `fallback={null}` is deliberate and is `Lazily`'s own choice, not a shortcut: the work area is
55
+ * simply not there for the moment the chunk is in flight, which is what every other route in this
56
+ * product already does. ⚠ A skeleton here would be a NEW loading screen in the same wave that
57
+ * exists to remove one (T22).
58
+ * ⚠ Default export, checked: `Shell.tsx:53` records that `lazy()` over a NAMED export fails at
59
+ * runtime, on click, and only for that one surface β€” `AutomationDetail` exports default.
60
+ */
61
+ const AutomationDetail = lazy(() => import("./AutomationDetail"));
62
+
63
+ /**
64
+ * The run poll's cadence (item 6). Named constants because they are a MEASURED trade-off, not a
65
+ * taste: `POLL_MAX_MS` is the longest a finished run can still look live, and it is the only
66
+ * cost the backoff has.
67
+ */
68
+ const POLL_MIN_MS = 2500;
69
+ const POLL_MAX_MS = 8000;
70
+
71
+ /*
72
+ * ⭐ WAVE 26 Β· ITEM 18 / R14 β€” `stateTitle` STOOD HERE AND IS DELETED WITH THE DOT IT DESCRIBED.
73
+ *
74
+ * It composed the dot's tooltip (the running step, or the last run's failure summary), so it had
75
+ * exactly one reader and no reason to outlive it. Deleting the render and keeping the composer is
76
+ * how a file accumulates functions that look live and are not β€” and `noUnusedLocals` would have
77
+ * caught this one, which is not a reason to lean on it: the NEXT such function might still have a
78
+ * second caller and compile fine.
79
+ *
80
+ * β›” WHY THE TOOLTIP WAS NOT MOVED ONTO THE MARK INSTEAD. R14 is "same mark on every automation;
81
+ * no status colour". Hanging "Last run failed" off a mark that is deliberately status-free just
82
+ * moves the status channel into `title`/`aria-label`, where it is worse: invisible to the eye,
83
+ * announced to a screen reader, and contradicting the visual. The run state has a home one click
84
+ * away β€” the Builder's own "Last run succeeded / failed" lines, which R13 explicitly keeps.
85
+ *
86
+ * ⚠ `stateOf` ITSELF IS UNTOUCHED in `automationApi.ts`. `Shell.tsx:64/1095` imports it and
87
+ * `liveStepOf` calls it β€” deleting the export to tidy up this file would break a fence I do not own.
88
+ */
89
+
90
+ /**
91
+ * The rail's second line β€” ONE fact, not two.
92
+ *
93
+ * ⚠ It carried "Next 2026-08-04 06:00 · last 2026-08-04 00:20", and the rail is `--lp-rail-w`
94
+ * wide, so it rendered as "Next 2026-08-04 06:00 Β· l…" β€” a second line whose only complete word
95
+ * was "Next". Two facts that both truncate are worth less than one that fits. The forward-looking
96
+ * one wins when a schedule exists (it is the question the rail answers: does this run itself?),
97
+ * the last run otherwise. The full picture is one click away in the editor's run history, and the
98
+ * row's `title` already says both.
99
+ *
100
+ * Caught by READING the screenshot: the DOM was correct and the CSS ellipsis was doing exactly
101
+ * its job. [[ui-invisible-to-assertions]] β€” judge the pixels even when everything is green.
102
+ */
103
+ function railSubtitle(a: Automation): string {
104
+ /*
105
+ * ⭐ WAVE 24 (owner item 6, "Run once now is laggy / looks stuck") β€” WHILE IT IS RUNNING,
106
+ * THE ONE FACT WORTH THE LINE IS WHAT IT IS DOING.
107
+ *
108
+ * MEASURED, not guessed (`scratchpad/perf_automations.py`, mailbox D-5): the engine has been
109
+ * publishing a live step the whole time (`status.step`, `routes_automation.py:50-51`) and NO
110
+ * SURFACE HAS EVER RENDERED IT. So a `discover_instagram` run parked in its legitimate
111
+ * `BD_FILTER_WAIT = 120 s` vendor wait and a genuinely hung thread were pixel-identical: a
112
+ * pulsing blue dot and a subtitle still reciting the schedule. "Looks stuck" was not a
113
+ * performance problem β€” the poll costs 23 ms β€” it was the product declining to say.
114
+ *
115
+ * It takes the line rather than joining it, for the reason this function's note already
116
+ * gives: the rail is `--lp-rail-w` wide and two facts that both truncate are worth less than
117
+ * one that fits. The schedule is still one click away and the dot's `title` carries both.
118
+ */
119
+ const step = liveStepOf(a);
120
+ if (step) return step;
121
+ if (a.schedule?.enabled) return a.nextRunAt ? `Next ${a.nextRunAt}` : "Scheduled";
122
+ /*
123
+ * ⭐ WAVE 26 Β· ITEM 19 / R13 β€” `Last run 2026-08-06 13:58` IS DELETED FROM THIS LINE.
124
+ *
125
+ * β›” SCOPE, because R13 draws a line that is easy to over-read: the ruling names THIS secondary
126
+ * line under the automation's name. The Builder's "Last run succeeded / failed" RESULT lines
127
+ * (`AutomationBuilder.tsx:1676`, `:1679`) are a different surface and are explicitly NOT in
128
+ * scope β€” they are the answer to "Run once now", so deleting them would leave a button with no
129
+ * outcome.
130
+ *
131
+ * "Manual only" STAYS, and the distinction is the ruling's own: a dated run record is a
132
+ * changing FACT ABOUT THE PAST, while "this one has no schedule" is a stable property of the
133
+ * automation β€” the same question `Next …`/`Scheduled` answers for its siblings. Dropping it too
134
+ * would leave a manual automation with a blank second line and nothing saying why.
135
+ */
136
+ return "Manual only";
137
+ }
138
+
139
+ /*
140
+ * β›” W23-W5's LISTENER IS AT MODULE SCOPE, AND THAT IS THE POINT β€” not a stylistic choice.
141
+ *
142
+ * The frame's click-through sets `window.location.hash = "#/automation"` and signals on the
143
+ * VERY NEXT LINE (Shell.tsx:1647-1650, and its comment is right that the order matters). But a
144
+ * hash write does not mount anything synchronously: `hashchange` is delivered as a task, the
145
+ * router state then updates, React renders, and only THEN does a component effect subscribe.
146
+ * A listener registered in `useEffect` therefore misses every click that arrives from another
147
+ * page β€” which is every click, since a reader looking at Alerts is by definition not already
148
+ * on this surface. The event would dispatch into nothing and every gate would stay green:
149
+ * exactly the wave-20 item-25 failure the contract's own note describes, reproduced one layer
150
+ * down.
151
+ *
152
+ * This module is imported statically by the shell, so this listener exists from app start. It
153
+ * LATCHES the request; the component consumes the latch when it mounts and hears live events
154
+ * while it is mounted. A request nobody claims is dropped on the next one β€” the latch is a
155
+ * one-slot mailbox, never a queue.
156
+ */
157
+ let pendingOpen: AutomationOpenDetail | null = null;
158
+ const openSubscribers = new Set<(detail: AutomationOpenDetail) => void>();
159
+
160
+ if (typeof window !== "undefined") {
161
+ window.addEventListener(AUTOMATION_OPEN_EVENT, (event) => {
162
+ const detail = (event as CustomEvent<AutomationOpenDetail>).detail;
163
+ if (!detail?.autoId) return;
164
+ if (openSubscribers.size) {
165
+ for (const notify of openSubscribers) notify(detail);
166
+ return;
167
+ }
168
+ pendingOpen = detail;
169
+ });
170
+ }
171
+
172
+ /*
173
+ * β›” THE `AUTOMATION_CREATE_EVENT` LISTENER STOOD HERE AND IS DELETED WITH ITS SIGNALLER
174
+ * (wave 25 item 5a, ruling R8) β€” latch, subscriber set and all.
175
+ *
176
+ * Wave 24 built it to close the opposite defect: the event was declared, signalled from
177
+ * `Shell.tsx`, and consumed NOWHERE, so all three "Automated database" doors navigated to
178
+ * `#/automation` and then did nothing, in production, with every gate green. R8 now deletes those
179
+ * three doors β€” creating a database and pointing an automation at it are two acts β€” and they were
180
+ * the event's only signaller.
181
+ *
182
+ * β›” SO THIS SIDE GOES TOO, IN THE SAME CHANGE. A listener with no signaller is the same defect
183
+ * read from the other end: it compiles, it costs nothing at runtime, and it reads to the next
184
+ * person as a live channel. The rule that catches both is now DERIVED rather than remembered β€”
185
+ * `verify_automation_ui.py` enumerates every `AUTOMATION_*_EVENT` in `apiContract.ts` and demands
186
+ * a signal site AND a listener site for each.
187
+ *
188
+ * ⚠ NOTHING ABOUT CREATING AN AUTOMATION IS LOST. `createAndOpen` is untouched and still has two
189
+ * doors, both on this surface: the rail's button and the empty state's. What is gone is the claim
190
+ * that a database can be created BY asking for an automation.
191
+ */
192
+
193
+ /**
194
+ * A default name for a brand-new automation, and it has to be one the server ACCEPTS.
195
+ *
196
+ * MEASURED against the live route rather than assumed (`scratchpad/probe_names.py`): a create
197
+ * with no name is a 400 (`name the automation`), and duplicate names are ALLOWED. So the name
198
+ * cannot be omitted, and a fixed literal would stack "New automation" three deep in a rail that
199
+ * sorts by name with nothing to tell the rows apart. Numbering from the existing list is a
200
+ * client-side convenience over a server that does not care: if two tabs race, the loser gets a
201
+ * duplicate name, which is legal, visible and renameable β€” never an error the user has to read.
202
+ */
203
+ function nextAutomationName(existing: Automation[]): string {
204
+ const taken = new Set(existing.map((a) => (a.name || "").trim().toLowerCase()));
205
+ for (let n = existing.length + 1; ; n += 1) {
206
+ // ⭐ WAVE 34 Β· R12 β€” "Agent N", not "Automation N". ⚠ THIS ONE IS STORED, so it is the only
207
+ // string in the rename that leaves a visible seam: automations created before today keep
208
+ // "Automation 3" as their NAME until somebody renames them. That is correct and deliberate β€”
209
+ // a name is a person's own text, and rewriting stored names to match a caption change would
210
+ // edit user data to tidy a word. The seam is in the rail for a while and then gone.
211
+ const candidate = `Agent ${n}`;
212
+ if (!taken.has(candidate.toLowerCase())) return candidate;
213
+ }
214
+ }
215
+
216
+ export default function AutomationSurface() {
217
+ /* ⭐ T22 β€” SEEDED FROM THE CLIENT MEMO, so a revisit inside the freshness window never passes
218
+ through `data === null` and therefore never paints the skeleton. The lazy initialiser runs
219
+ once, on mount, BEFORE the first paint β€” `useState(cachedAutomations())` would call it on
220
+ every render instead, which is the same value at needless cost. A cold start still returns
221
+ null and still gets the skeleton: it is made rare, not removed (see `automationApi.ts`). */
222
+ const [data, setData] = useState<AutomationList | null>(() => cachedAutomations());
223
+ const [error, setError] = useState("");
224
+ const [activeId, setActiveId] = useState<string>("");
225
+ const [railShut, setRailShut] = useState(false);
226
+ const [busy, setBusy] = useState("");
227
+ /**
228
+ * ⭐ WAVE 24 / C-CREATE β€” a create is now a ROUND TRIP, not a local wizard, so the door has to
229
+ * say it is busy. Without this a second click while the POST is in flight makes a second
230
+ * automation, and the server allows duplicate names, so the user gets two rows that look
231
+ * identical and no error to explain either of them.
232
+ */
233
+ const [creatingNow, setCreatingNow] = useState(false);
234
+ /** An open request waiting for the list to arrive (see the resolver below). */
235
+ const [openRequest, setOpenRequest] = useState<AutomationOpenDetail | null>(null);
236
+ /* β›” `createRequest` LEFT WITH THE EVENT THAT SET IT (wave 25, R8) β€” see the tombstone above. */
237
+ const listRef = useRef<HTMLDivElement | null>(null);
238
+
239
+ /**
240
+ * β›” C14 LEG 2 β€” THE ID THE USER LAST ASKED FOR, and it is a ref because it has to be
241
+ * written SYNCHRONOUSLY, inside the click, before any promise that was already in flight
242
+ * can resolve. State would not do: a resolution racing React's commit would read the
243
+ * previous value, which is the exact window this guard exists to close.
244
+ */
245
+ const wantedId = useRef("");
246
+
247
+ /**
248
+ * ⭐ THE ONLY PLACE THE RAIL'S SELECTION MOVES ON PURPOSE. Every deliberate change of
249
+ * automation β€” a rail click, a create, a delete β€” goes through here, so "what the user
250
+ * asked for" and "what is on screen" are written in one statement and cannot drift apart.
251
+ * A resolution that wants to steer the rail compares itself against `wantedId` instead.
252
+ */
253
+ const select = useCallback((id: string, _stage = "") => {
254
+ wantedId.current = id;
255
+ setActiveId(id);
256
+ }, []);
257
+
258
+ /**
259
+ * ⭐⭐ W33 (C-18, from lane C) β€” WHICH PANE AN INBOX ITEM ASKED FOR, and it was DROPPED here.
260
+ *
261
+ * β›” THE PAYLOAD WAS DECLARED, TRANSPORTED, AND IGNORED AT THE LAST HOP.
262
+ * `routes_alerts.py` sets `tab: "runs"` on a notification's target, `inboxModel.routeForTarget`
263
+ * forwards it and `Shell.tsx` signals `{autoId, tab}` β€” and this file read `openRequest.tab`
264
+ * NOWHERE. So a person clicking *"12 new creators found"* to see the RUN landed on Properties,
265
+ * which is [[flag-shipped-without-its-writer]] with the writer present and the READER missing.
266
+ * ⚠ NOT FIXED BY DELETING `tab` FROM THE CONTRACT, which was the other option C offered: the
267
+ * field is the only thing that carries "you clicked a run, so show runs", and an inbox that
268
+ * lands you on the wrong pane is the complaint, not the field.
269
+ * ⚠ IT IS A ONE-SHOT REQUEST, NOT A CONTROLLED VALUE. Forcing the pane on every render would
270
+ * take the pane control away from the person the moment after they used it β€” so this is
271
+ * consumed by `AutomationDetail` as an INITIAL value keyed on the automation, and cleared here
272
+ * with the request that carried it.
273
+ */
274
+ const [openTab, setOpenTab] = useState<"properties" | "runs" | "">("");
275
+
276
+ /**
277
+ * β›” C14 LEG 3 (the paint half) β€” A STALE READ MAY NOT REPAINT.
278
+ *
279
+ * Every call takes the next generation; only the newest one is allowed to `setData`. The
280
+ * defect this closes is not cosmetic: a poll issued BEFORE a delete resolves AFTER it, and
281
+ * a list that still contains the deleted automation puts a row back in the rail that the
282
+ * user has just watched disappear. Same for a toggle, and same for a create.
283
+ *
284
+ * ⚠ THE RETURN VALUE IS NOT GUARDED, deliberately. The caller asked a question and gets
285
+ * its own answer β€” coupling the two would mean a create whose `load()` was overtaken by a
286
+ * poll silently failed to select the automation it had just made.
287
+ */
288
+ const gen = useRef(0);
289
+
290
+ const load = useCallback(async (abort?: AbortSignal) => {
291
+ const mine = ++gen.current;
292
+ try {
293
+ const next = await listAutomations(abort);
294
+ if (mine === gen.current) {
295
+ setData(next);
296
+ setError("");
297
+ }
298
+ return next;
299
+ } catch (e) {
300
+ if ((e as Error)?.name === "AbortError") return null;
301
+ if (mine === gen.current)
302
+ setError(
303
+ e instanceof AutomationError
304
+ ? e.message
305
+ : "The automation service did not answer."
306
+ );
307
+ return null;
308
+ }
309
+ }, []);
310
+
311
+ /**
312
+ * ⭐⭐ WAVE 31 Β· T31 β€” one freshly-written automation replaces its own row, with no round trip.
313
+ *
314
+ * ⚠ IT TAKES THE GENERATION, and that is not decoration. `load`'s guard exists so a stale READ
315
+ * cannot repaint; a WRITE response is by definition newer than any read already in flight, so it
316
+ * must win the same race rather than sit outside it. Without the bump, a `GET /automations`
317
+ * issued before the save could resolve after the merge and paint the pre-save row back β€” the
318
+ * exact ghost `gen` was introduced for, arriving through the new door.
319
+ *
320
+ * ⚠ Returns FALSE when there is nothing to merge into (a cold surface has no memo), and the
321
+ * caller then does a real load. Painting a one-row list would be worse than a round trip.
322
+ */
323
+ const mergeSaved = useCallback((fresh: Automation) => {
324
+ const next = rememberAutomation(fresh);
325
+ if (!next) return false;
326
+ gen.current += 1;
327
+ setData(next);
328
+ setError("");
329
+ return true;
330
+ }, []);
331
+
332
+ useEffect(() => {
333
+ const ac = new AbortController();
334
+ void load(ac.signal);
335
+ return () => ac.abort();
336
+ }, [load]);
337
+
338
+ /**
339
+ * ⭐⭐ WAVE 24 / C-CREATE (a) β€” CREATE AND OPEN. This REPLACED the three-step wizard.
340
+ *
341
+ * R6 is what makes it possible: `plain` is a real kind with no machine graph nodes, and it is
342
+ * what `POST /automations` stores when the body names none. So "new automation" stopped being
343
+ * a question ("which of three kinds?" β€” two of which can no longer be created at all) and
344
+ * became what it says: a new automation, open, on its trigger picker. Choosing a SOURCE is now
345
+ * picking the `ig_profile_match` trigger, which is where that choice belongs.
346
+ *
347
+ * β›” THE GUARD IS A REF, not the `creatingNow` state, and this file already carries the scar
348
+ * that explains why (C14 leg 2): a state read inside a click closure is the value from the
349
+ * last render, so two fast clicks both see `false` and both POST. Duplicate names are legal
350
+ * server-side, so the user would get two identical rows and no error. The ref is written
351
+ * synchronously, inside the click, before anything can await.
352
+ */
353
+ const creatingRef = useRef(false);
354
+ const createAndOpen = useCallback(async () => {
355
+ if (creatingRef.current) return;
356
+ creatingRef.current = true;
357
+ setCreatingNow(true);
358
+ try {
359
+ const res = await createAutomation({
360
+ name: nextAutomationName(data?.automations || []),
361
+ });
362
+ const id = res?.automation?.id || "";
363
+ // The create flow is the one caller allowed to name a different id (C14 leg 2) β€” the
364
+ // automation did not exist when the click happened, so there is nothing to race with.
365
+ const next = await load();
366
+ if (id && next) select(id);
367
+ } catch (e) {
368
+ setError(
369
+ e instanceof AutomationError ? e.message : "The automation was not created."
370
+ );
371
+ } finally {
372
+ creatingRef.current = false;
373
+ setCreatingNow(false);
374
+ }
375
+ }, [data, load, select]);
376
+
377
+ // A run is a background thread on the server, so the surface has to ASK whether it
378
+ // finished. Polling only while something is actually running keeps an idle surface
379
+ // silent β€” a fixed interval would be a request every few seconds forever, for a page
380
+ // whose contents change a handful of times a day.
381
+ //
382
+ // β›” C14 LEG 3 (the abort half). It used to call `load()` bare β€” no signal, nothing to
383
+ // cancel β€” so a request the interval had already issued kept going after the effect that
384
+ // owned it was gone. One controller per effect run, aborted with the interval, means a poll
385
+ // cannot outlive the condition that justified it. The generation guard inside `load` covers
386
+ // the rest: a response that survives the abort still cannot repaint over a newer one.
387
+ //
388
+ // ⭐ WAVE 24 (item 6, C-PERF) β€” THE INTERVAL BECAME A BACKOFF, and the measurement is why it is
389
+ // a SMALL change rather than the big one the contract's hypothesis 2 asked for.
390
+ //
391
+ // MEASURED (`scratchpad/perf_automations.py`, mailbox D-5): this poll costs ~23 ms and 24 KB
392
+ // for a ten-automation tenant, and the engine work the hypothesis blamed β€” a `graph()` rebuild
393
+ // per automation β€” is **41 Β΅s each**, i.e. under 4% of the request. The payload was never the
394
+ // problem, so nothing here gets a cheaper endpoint.
395
+ //
396
+ // What the numbers DO indict is the aggregate: a discovery run legitimately blocks up to
397
+ // `BD_FILTER_WAIT` = 120 s, and two independent 2.5 s polls across this file and the detail
398
+ // spend ~96 requests and ~2.3 MB over that window β€” in the same single process the run itself
399
+ // is a thread in. So the delay grows 2.5 β†’ 5 β†’ 8 s and stops there.
400
+ //
401
+ // ⚠ NOT "to learn nothing", and the distinction is the whole justification. That WAS true when
402
+ // this file rendered no live step: the poll's only observable effect was a dot that had already
403
+ // been pulsing for two minutes. It stopped being true in this same change β€” `liveStepOf` now
404
+ // paints `status.step`, so a poll carries the one fact worth having. The backoff is therefore
405
+ // NOT "stop asking a pointless question"; it is FEWER ROUND TRIPS FOR THE SAME INFORMATION, on
406
+ // a step text that changes every few seconds at most, not every 2.5.
407
+ // The cap is deliberately low: it bounds how long a FINISHED run can still look live, which is
408
+ // the only thing a backoff can make worse, and 8 s of that is worth ~β…” fewer requests.
409
+ //
410
+ // β›” THE KEY IS THE RUNNING SET, NOT A BOOLEAN, and that is what makes the reset correct: the
411
+ // effect re-runs β€” and the delay drops back to 2.5 s β€” the moment a run starts or finishes, so
412
+ // a user who clicks Run now gets the fast cadence again instead of inheriting the tail of the
413
+ // previous run's backoff. A bare `anyRunning` boolean cannot see the second run start.
414
+ // A step text changing does NOT re-key it, so the interval never thrashes.
415
+ //
416
+ // C14 LEG 3 (the abort half) is unchanged and still load-bearing: one controller per effect
417
+ // run, aborted with the timer, so a poll cannot outlive the condition that justified it. The
418
+ // generation guard inside `load` covers the rest.
419
+ const runningKey = (data?.automations || [])
420
+ .filter((a) => a.running)
421
+ .map((a) => a.id)
422
+ .sort()
423
+ .join(",");
424
+ useEffect(() => {
425
+ if (!runningKey) return undefined;
426
+ const ac = new AbortController();
427
+ let delay = POLL_MIN_MS;
428
+ let timer = 0;
429
+ const tick = () => {
430
+ void load(ac.signal);
431
+ delay = Math.min(delay * 2, POLL_MAX_MS);
432
+ timer = window.setTimeout(tick, delay);
433
+ };
434
+ timer = window.setTimeout(tick, delay);
435
+ return () => {
436
+ window.clearTimeout(timer);
437
+ ac.abort();
438
+ };
439
+ }, [runningKey, load]);
440
+
441
+ // ── W23-W5, the surface's half: hear the request, then answer it when we CAN ──────────
442
+ useEffect(() => {
443
+ const notify = (detail: AutomationOpenDetail) => setOpenRequest(detail);
444
+ openSubscribers.add(notify);
445
+ if (pendingOpen) {
446
+ const latched = pendingOpen;
447
+ pendingOpen = null;
448
+ setOpenRequest(latched);
449
+ }
450
+ return () => {
451
+ openSubscribers.delete(notify);
452
+ };
453
+ }, []);
454
+
455
+ /* β›” THE C-CREATE(b) SUBSCRIBE EFFECT AND ITS RESOLVER STOOD HERE (wave 25, R8). They heard the
456
+ create event and, once the list had arrived, called `createAndOpen` β€” the "wait for the list
457
+ or every automation is named Automation 1" note lives on in `nextAutomationName`, which is
458
+ still numbered off `existing`. With no signaller there is nothing to hear. */
459
+
460
+ /**
461
+ * ⚠ THE REQUEST OUTLIVES THE FETCH, and it has to. The reader clicks a notification from
462
+ * another page, so this surface is mounting WITH AN EMPTY LIST β€” "select it if it is in the
463
+ * list" would drop every real click and keep only the one case where the reader was already
464
+ * here. So the request is held until `data` exists, and only then answered.
465
+ *
466
+ * An automation the reader can no longer open does NOTHING (the contract's own words): the
467
+ * request is cleared either way, so a stale id cannot sit here re-firing against every
468
+ * subsequent list.
469
+ */
470
+ useEffect(() => {
471
+ if (!openRequest || !data) return;
472
+ const found = (data.automations || []).some((a) => a.id === openRequest.autoId);
473
+ if (found) {
474
+ select(openRequest.autoId, openRequest.stageId || "");
475
+ // C-18: the pane the notification asked for, read at the one moment it means something.
476
+ const want = String((openRequest as { tab?: string }).tab || "");
477
+ setOpenTab(want === "runs" || want === "properties" ? want : "");
478
+ }
479
+ setOpenRequest(null);
480
+ }, [openRequest, data, select]);
481
+
482
+ const items = data?.automations || [];
483
+ const active = items.find((a) => a.id === activeId) || null;
484
+
485
+ /**
486
+ * ⭐⭐ WAVE 32 Β· T43 (owner item 2) β€” TURNING AN AUTOMATION ON OR OFF STOPS TAKING FOREVER.
487
+ *
488
+ * Owner: turning an automation on or off "takes forever". β›” THE TICKET'S HYPOTHESIS WAS THE
489
+ * SERVER AND IT IS NOT β€” MEASURED. `engine.patch` for a schedule-only body takes ONE
490
+ * `automations` bucket read, ZERO `user_tables` reads and ~1 ms against a 2.8 MB fixture, and
491
+ * `GET /automations` is one read too since W30-T12. The cost was never the document shape
492
+ * (D-179/D-175's family): it was that this handler paid **two sequential round trips** for one
493
+ * click, and painted nothing until BOTH had landed. The second one re-downloaded the entire
494
+ * rail payload β€” 88,771 B measured, see the note at the top of this file β€” to learn one boolean
495
+ * the first response had already returned.
496
+ *
497
+ * β›” AND THE FIX ALREADY EXISTED IN THIS FILE, POINTED AT A DIFFERENT BUTTON. `mergeSaved` is
498
+ * W31-T31, built for the detail pane's saves, with its own note about the write response being
499
+ * newer than any read in flight. The rail's own switch never got it β€” a fix applied to one of
500
+ * two twins, which is this wave's recurring shape.
501
+ *
502
+ * Three states, in order, and the middle one is what the owner actually asked for:
503
+ * 1. FLIP IT NOW. The switch is the user's instruction, not a question; painting it after a
504
+ * network round trip is what "takes forever" describes even when the round trip is fast.
505
+ * 2. adopt the server's own answer when it lands, which carries the derived fields an
506
+ * optimistic row cannot invent (`nextRunAt`, the status line).
507
+ * 3. put the OLD row back if the write failed, beside the error β€” an optimistic switch that
508
+ * stays on after a refusal is a lie, and a worse one than a slow switch.
509
+ *
510
+ * ⚠ `mergeSaved` TAKES THE GENERATION, which is what stops [[refetch-eats-its-own-write]] here:
511
+ * a `GET /automations` issued before the click can still resolve after it, and without the bump
512
+ * it would paint the pre-toggle row back over both the optimistic flip and the confirmation.
513
+ * ⚠ `busy` is no longer what makes the switch look right β€” it stays only to keep a second click
514
+ * from racing the first.
515
+ */
516
+ const toggleEnabled = async (a: Automation) => {
517
+ const flipped: Automation = {
518
+ ...a,
519
+ schedule: { ...a.schedule, enabled: !a.schedule.enabled },
520
+ };
521
+ setBusy(a.id);
522
+ const painted = mergeSaved(flipped);
523
+ try {
524
+ const { automation: fresh } = await patchAutomation(a.id, {
525
+ schedule: { cron: a.schedule.cron, enabled: !a.schedule.enabled },
526
+ });
527
+ // A cold surface has no memo to merge into (`mergeSaved` answers false), and a one-row
528
+ // repaint would be worse than a round trip β€” the same rule `onSaved` follows.
529
+ if (!fresh || !mergeSaved(fresh)) await load();
530
+ } catch (e) {
531
+ if (painted) mergeSaved(a);
532
+ setError(e instanceof AutomationError ? e.message : "That change was not saved.");
533
+ } finally {
534
+ setBusy("");
535
+ }
536
+ };
537
+
538
+ const onRailKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
539
+ if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
540
+ const rows = Array.from(
541
+ listRef.current?.querySelectorAll<HTMLButtonElement>(".auto-row-main") || []
542
+ );
543
+ if (!rows.length) return;
544
+ event.preventDefault();
545
+ const at = rows.indexOf(document.activeElement as HTMLButtonElement);
546
+ const next = event.key === "ArrowDown" ? (at + 1) % rows.length
547
+ : (at - 1 + rows.length) % rows.length;
548
+ rows[next < 0 ? 0 : next]?.focus();
549
+ };
550
+
551
+ return (
552
+ <div className="auto-surface">
553
+ <aside
554
+ className={"auto-rail" + (railShut ? " is-collapsed" : "")}
555
+ /* ⭐ WAVE 34 Β· R12 β€” an aria-label IS user-facing copy. A screen reader announces this
556
+ landmark by name, so leaving it "Automations" would have the module answer to two
557
+ different words depending on how you read the page. */
558
+ aria-label="Agents"
559
+ >
560
+ {/* The same three-bars fold control the Views rail carries β€” the two rails are
561
+ one idea, so they must not fold with two different affordances. */}
562
+ <div className="auto-rail-top">
563
+ <button
564
+ type="button"
565
+ className="cg-rail-toggle"
566
+ aria-label={railShut ? "Expand automations" : "Minimize automations"}
567
+ aria-expanded={!railShut}
568
+ title={railShut ? "Expand automations" : "Minimize automations"}
569
+ onClick={() => setRailShut((v) => !v)}
570
+ >
571
+ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
572
+ <path
573
+ d="M2.5 4.4h11M2.5 8h11M2.5 11.6h11"
574
+ stroke="currentColor"
575
+ strokeWidth="1.35"
576
+ strokeLinecap="round"
577
+ />
578
+ </svg>
579
+ </button>
580
+ </div>
581
+
582
+ <div className="auto-create">
583
+ <button
584
+ type="button"
585
+ className="auto-create-btn"
586
+ disabled={creatingNow}
587
+ onClick={() => void createAndOpen()}
588
+ >
589
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
590
+ <path
591
+ d="M8 3.4v9.2M3.4 8h9.2"
592
+ stroke="currentColor"
593
+ strokeWidth="1.5"
594
+ strokeLinecap="round"
595
+ />
596
+ </svg>
597
+ New agent
598
+ </button>
599
+ </div>
600
+
601
+ <div className="auto-list" ref={listRef} onKeyDown={onRailKey}>
602
+ {items.map((a) => {
603
+ return (
604
+ <div
605
+ key={a.id}
606
+ className={"auto-row" + (a.id === activeId ? " is-active" : "")}
607
+ >
608
+ <button
609
+ type="button"
610
+ className="auto-row-main"
611
+ onClick={() => select(a.id)}
612
+ >
613
+ {/* ⭐ ITEM 18 / R14 β€” THE LOOP MARK, WHERE THE STATUS DOT WAS. Same mark on
614
+ every row, no status colour: the owner's point is that an automation IS a
615
+ loop, not that this one is green. The `.auto-loopmark` wrapper is what sizes
616
+ it β€” `.lp-mark` is shell-owned CSS and outside this session's region, so the
617
+ box is mine and the mark is theirs. */}
618
+ <span className="auto-loopmark">
619
+ <Mark size={15} />
620
+ </span>
621
+ <span className="auto-row-text">
622
+ <span className="auto-row-name">{a.name}</span>
623
+ <span className="auto-row-desc">{railSubtitle(a)}</span>
624
+ </span>
625
+ </button>
626
+ <button
627
+ type="button"
628
+ className={"auto-row-switch" + (a.schedule?.enabled ? " is-on" : "")}
629
+ disabled={busy === a.id}
630
+ aria-pressed={!!a.schedule?.enabled}
631
+ title={
632
+ a.schedule?.enabled
633
+ ? `Scheduled: ${a.schedule.cron}. Click to pause.`
634
+ : "Not scheduled. Click to enable."
635
+ }
636
+ onClick={() => void toggleEnabled(a)}
637
+ >
638
+ <span className="auto-row-switch-knob" />
639
+ </button>
640
+ </div>
641
+ );
642
+ })}
643
+ {/* ONE LINE (R13). It used to describe the kinds β€” "one reads a public web page…
644
+ the other fills an automation column…" β€” which was two sentences, wrong by the
645
+ time a third kind shipped, and printed inside a `--lp-rail-w` column. What the
646
+ kinds are belongs to the create form, which is one click away and lists all of
647
+ them from the server. */}
648
+ {/* ⭐ WAVE 29 (W29-T01) β€” A LOADING RAIL IS NOT AN EMPTY ONE. `data` is null until the
649
+ first `listAutomations()` resolves, and this branch read only `items.length`, so
650
+ every visit to Automation asserted "No automations yet." to a person who has
651
+ several β€” then replaced it with their list. Half of owner item 5's "takes a while
652
+ to appear" is that sentence: the wait is real, but being told you own nothing is
653
+ what makes it read as broken rather than slow. `data === null` is the one state
654
+ that means NOBODY HAS LOOKED, and it gets skeleton rows, not a claim. */}
655
+ {data === null && !error ? (
656
+ <div className="auto-rail-loading" aria-hidden="true">
657
+ <span className="auto-rail-skel" />
658
+ <span className="auto-rail-skel" />
659
+ <span className="auto-rail-skel" />
660
+ </div>
661
+ ) : null}
662
+ {data !== null && !items.length && !error ? (
663
+ <p className="auto-rail-empty">No agents yet.</p>
664
+ ) : null}
665
+ </div>
666
+ </aside>
667
+
668
+ <section className="auto-main">
669
+ {error ? (
670
+ <div className="auto-banner is-error" role="alert">
671
+ {error}
672
+ </div>
673
+ ) : null}
674
+ {data && !data.storeAvailable ? (
675
+ <div className="auto-banner is-warn" role="status">
676
+ The tenant store is unavailable, so nothing can be saved right now.
677
+ </div>
678
+ ) : null}
679
+
680
+ {/*
681
+ β›” THE `creating` BRANCH IS GONE (wave 24, C-CREATE a / owner items 3 + 4).
682
+ `AutomationCreate` used to render here β€” a three-step wizard whose first step asked
683
+ which DATABASE to write into (an existing one, a new one, or one the automation itself
684
+ would create) and whose second asked which of three KINDS.
685
+ R6 retires that question at the root: two of the three kinds can no longer be created
686
+ at all, and the third is now reached by picking a trigger. A new automation is created
687
+ the moment it is asked for (`createAndOpen`) and opens on its own Builder, so there is
688
+ no intermediate face left to render and no `creating` state to hold.
689
+ ⭐ WAVE 25 (R8) retires the LAST of that vocabulary: the third answer was the
690
+ "automated database", and it is gone from every door in the product. This button is now
691
+ one of exactly two ways to make an automation, and both are on this surface.
692
+ The import went with it β€” it is what would break `npx tsc -b` for the WHOLE client the
693
+ moment session B deletes the file, which is why this deletion is sequenced first.
694
+ */}
695
+ {/* T21: the editor's own Suspense boundary β€” see the `lazy()` above for why it cannot be
696
+ `Shell.tsx`'s. `fallback={null}`, matching `Lazily`. */}
697
+ {active ? (
698
+ <Suspense fallback={null}>
699
+ <AutomationDetail
700
+ key={active.id}
701
+ automation={active}
702
+ /* C-18: which pane the Inbox asked for, or "" for the ordinary open. Consumed ONCE,
703
+ as an initial value β€” see `openTab`'s note on why it is not controlled. */
704
+ openTab={openTab}
705
+ /* β›” `kinds={data?.kinds || []}` LEFT HERE WITH THE PROP IT FED (wave 25, D-57) β€”
706
+ both halves in one change, because either alone is a `tsc` error. */
707
+ cronPresets={data?.cronPresets || []}
708
+ paidReady={!!data?.paidReady}
709
+ discover={data?.discover}
710
+ // The trigger vocabulary (C3). Forwarded as-is β€” absent stays absent, so the
711
+ // trigger face can tell "the server offered nothing" from "the server offered
712
+ // an empty list" rather than collapsing both into a picker with no options.
713
+ triggers={data?.triggers}
714
+ // C4's action menu + the builder's ceilings. Forwarded as-is for the same reason
715
+ // `triggers` is: absent must stay absent, so the builder can tell "the server
716
+ // offered nothing" from "the server offered an empty list".
717
+ catalog={data?.actionsCatalog}
718
+ vocab={data?.flow}
719
+ // `?? null` and never `|| {enabled:false}`: an absent tick bit is "the server did
720
+ // not say", which Step 1 prints as its own sentence. Defaulting it here would
721
+ // turn a missing field into a claim about production (C6 amendment #1).
722
+ tick={data?.tick ?? null}
723
+ /*
724
+ * ⭐ W24-W1 (item 6) β€” WHAT THIS RUN IS DOING, on the one surface that survives in
725
+ * BOTH views. REQUIRED on the far side deliberately: an optional prop that nobody
726
+ * passes degrades to "the feature does not exist", which is indistinguishable from
727
+ * "it was never built" β€” and this whole item exists because a live step text rode
728
+ * the wire for three waves with nothing rendering it.
729
+ * `liveStepOf` returns the step ONLY while the automation is running; a stale step
730
+ * from a finished run is a worse answer than none.
731
+ */
732
+ liveStep={liveStepOf(active)}
733
+ /*
734
+ * β›” C14 LEG 2 β€” THE STALE-ID WRITE-BACK, and this is the ghost's second cause.
735
+ *
736
+ * The detail calls `onSaved(automation.id)` from six places (save, the trigger
737
+ * picker, a schedule change, a node switch, a card move, a run). Each closure
738
+ * captures the automation it was mounted for, so a save that resolves AFTER the
739
+ * user has clicked a different row used to call `setActiveId(the OLD id)` β€” the
740
+ * rail jumped back, the detail remounted, and what the user saw was the previous
741
+ * automation reappearing over the one they had just opened. It looked like a
742
+ * rendering bug; it was a resolution steering the selection.
743
+ *
744
+ * A resolution may no longer steer anything. It reloads the list β€” that part was
745
+ * always right β€” and it re-asserts the selection ONLY when its id is still the one
746
+ * the user asked for, which makes the write a no-op in the good case and nothing
747
+ * at all in the bad one.
748
+ */
749
+ onSaved={async (id, fresh) => {
750
+ // ⭐⭐ WAVE 31 Β· T31 β€” CORRECT THE ONE ROW WE WERE JUST TOLD ABOUT.
751
+ // `patchAutomation` and `toggleNode` return the updated definition; re-reading the
752
+ // whole list to learn it is the second of the two round trips "Saving…" used to
753
+ // span. `mergeSaved` keeps the memo and the component in step (see
754
+ // `automationApi.rememberAutomation`) and takes the generation, because a write
755
+ // response is newer than any read still in flight β€” the same rule `load` follows,
756
+ // pointed the other way.
757
+ if (fresh && mergeSaved(fresh)) {
758
+ if (id && id === wantedId.current) select(id);
759
+ return;
760
+ }
761
+ const next = await load();
762
+ if (id && next && id === wantedId.current) select(id);
763
+ }}
764
+ onDeleted={async () => {
765
+ select("");
766
+ await load();
767
+ }}
768
+ />
769
+ </Suspense>
770
+ ) : (
771
+ /*
772
+ * THE EMPTY STATE IS ONE LINE AND A BUTTON (owner ruling R13 β€” "never
773
+ * over-explain", now a DESIGN.md law).
774
+ *
775
+ * It was a heading, a three-sentence paragraph and a 130-word bulleted list
776
+ * describing all three kinds. Every word of it was true and none of it was READ:
777
+ * an empty state is passed through, not studied, and the person looking at it has
778
+ * already decided to make an automation. The kinds are described where the choice
779
+ * is actually made β€” the create form lists them FROM THE SERVER, so that copy also
780
+ * cannot go stale the way this list had (it described two kinds after a third
781
+ * shipped).
782
+ *
783
+ * The button is here rather than only in the rail because this pane is where the
784
+ * eye is; a create affordance the user has to go find is the same defect as the
785
+ * paragraph, spent differently.
786
+ */
787
+ <div className="autob-empty">
788
+ {/*
789
+ β›” NO TITLE HERE ANY MORE (C13, owner item 2). This pane carried an `h1`
790
+ reading "Automations" at 20px/600 β€” a THIRD title treatment on a page that
791
+ also had the header's editable 16px/700 input, against every database page's
792
+ single 16px/650 `shell-db-name`. The shell now wraps this branch in the same
793
+ `shell-db-frame` + `DbHead` a database gets (wiring W23-W1), so the page's name
794
+ is drawn once, by the one component that draws every other page's name. A
795
+ stand-in restyled to match would have been a second copy of the same fact,
796
+ free to drift the day the header moves.
797
+ */}
798
+ <p className="autob-empty-line">
799
+ A job this workspace runs for you, on demand or on a schedule.
800
+ </p>
801
+ <button
802
+ type="button"
803
+ className="auto-btn is-primary"
804
+ disabled={creatingNow}
805
+ onClick={() => void createAndOpen()}
806
+ >
807
+ New agent
808
+ </button>
809
+ </div>
810
+ )}
811
+ </section>
812
+ </div>
813
+ );
814
+ }
web/src/automation/AutomationTrigger.tsx CHANGED
@@ -98,8 +98,8 @@ function needsLabel(needs: string): string {
98
  * declared-but-not-built ("coming soon", R2) which is waiting on us.
99
  */
100
  function optionLabel(t: TriggerOption): string {
101
- if (t.planned) return `${t.label} β€” coming soon`;
102
- return t.ready === false ? `${t.label} β€” needs setting up` : t.label;
103
  }
104
 
105
  /**
@@ -119,13 +119,13 @@ function tickNote(tick: TickState | null): { text: string; tone: string } {
119
  // fire" as though it had been measured.
120
  if (!tick || typeof tick.enabled !== "boolean") {
121
  return {
122
- text: "Whether this deployment runs schedules is not reported here yet β€” press Run now if you need it now.",
123
  tone: "is-unknown",
124
  };
125
  }
126
  if (!tick.enabled) {
127
  return {
128
- text: "Schedules won't fire on this deployment β€” ask your admin to switch the scheduler on. Run now still works.",
129
  tone: "is-off",
130
  };
131
  }
@@ -365,7 +365,7 @@ export default function AutomationTrigger({
365
  <div className="autob-needs">
366
  <span className="autob-needs-text">
367
  {connected
368
- ? "Connected β€” this trigger is still being switched on for this deployment."
369
  : "This trigger is not set up yet."}
370
  </span>
371
  {!connected && startUrl ? (
@@ -392,7 +392,7 @@ export default function AutomationTrigger({
392
  ? tick && tick.enabled === false
393
  ? `It would run next at ${nextRunAt}, once a scheduler is running.`
394
  : `Next run ${nextRunAt}.`
395
- : "Schedules start from the moment they are switched on β€” turning on a daily job after today's time does not fire it today."
396
  : current === "manual"
397
  ? "It runs when you press Run now, and nothing else starts it."
398
  : /*
 
98
  * declared-but-not-built ("coming soon", R2) which is waiting on us.
99
  */
100
  function optionLabel(t: TriggerOption): string {
101
+ if (t.planned) return `${t.label}. Coming soon`;
102
+ return t.ready === false ? `${t.label}. Needs setting up` : t.label;
103
  }
104
 
105
  /**
 
119
  // fire" as though it had been measured.
120
  if (!tick || typeof tick.enabled !== "boolean") {
121
  return {
122
+ text: "Whether this deployment runs schedules is not reported here yet. Press Run now if you need it now.",
123
  tone: "is-unknown",
124
  };
125
  }
126
  if (!tick.enabled) {
127
  return {
128
+ text: "Schedules won't fire on this deployment. Ask your admin to switch the scheduler on. Run now still works.",
129
  tone: "is-off",
130
  };
131
  }
 
365
  <div className="autob-needs">
366
  <span className="autob-needs-text">
367
  {connected
368
+ ? "Connected. This trigger is still being switched on for this deployment."
369
  : "This trigger is not set up yet."}
370
  </span>
371
  {!connected && startUrl ? (
 
392
  ? tick && tick.enabled === false
393
  ? `It would run next at ${nextRunAt}, once a scheduler is running.`
394
  : `Next run ${nextRunAt}.`
395
+ : "Schedules start from the moment they are switched on. Turning on a daily job after today's time does not fire it today."
396
  : current === "manual"
397
  ? "It runs when you press Run now, and nothing else starts it."
398
  : /*
web/src/automation/automation.css ADDED
@@ -0,0 +1,176 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ---------------------------------------------------------------------------
2
+ automation/automation.css β€” the Agent module's OWN stylesheet.
3
+
4
+ ⭐⭐ WAVE 34 Β· W34-T41 β€” THIS FILE IS NEW, AND ITS EXISTENCE IS THE POINT.
5
+
6
+ Until today this module had NO scoped CSS at all: every `auto-*`, `autob-*` and `autox-*` rule
7
+ lived in the shared global `src/index.css`, which the wave PRD assigns to session C. That made
8
+ any new rule here a cross-fence ask, for styling nobody else can see β€” the PRD's own remedy is
9
+ "put NEW rules in your own module stylesheet (`automation/*.css`)", and this is that file.
10
+
11
+ β›” WHAT DOES NOT BELONG HERE: an edit to an existing `auto-*` rule. Those still live in
12
+ `index.css` and are still C's. This file holds rules that did not exist before, so the two can
13
+ never disagree about one selector.
14
+
15
+ ⚠ EVERY COLOUR IS A TOKEN. No literal hex, for the reason the palette block in `index.css`
16
+ records: a hand-copied brand value silently paints last wave's brand while a comment asserts
17
+ parity. Light and dark both come free that way.
18
+ --------------------------------------------------------------------------- */
19
+
20
+ /* ── the Chat view (R17) ──────────────────────────────────────────────────── */
21
+
22
+ /* Mirrors `.autox-flow`'s box so switching Canvas <-> Chat does not move the frame around it:
23
+ same flex growth, same min-height:0 (without which a scrolling child of a flex parent grows
24
+ the parent instead of scrolling), same scroll ownership. */
25
+ .autoc-chat {
26
+ flex: 1 1 auto;
27
+ min-width: 0;
28
+ min-height: 0;
29
+ display: flex;
30
+ flex-direction: column;
31
+ }
32
+
33
+ .autoc-log {
34
+ flex: 1 1 auto;
35
+ min-height: 0;
36
+ overflow-y: auto;
37
+ padding: 20px 24px 12px;
38
+ display: flex;
39
+ flex-direction: column;
40
+ gap: 14px;
41
+ }
42
+
43
+ /* The empty state is a SENTENCE, not an illustration: it says what to type, which is the only
44
+ thing a reader needs from a surface they have never used. DESIGN.md's "never over-explain". */
45
+ .autoc-empty {
46
+ margin: 0;
47
+ color: var(--lp-muted);
48
+ font-size: var(--lp-fs-sm);
49
+ max-width: 52ch;
50
+ }
51
+
52
+ .autoc-msg {
53
+ max-width: 60ch;
54
+ padding: 10px 14px;
55
+ border-radius: var(--lp-r-md);
56
+ font-size: var(--lp-fs-sm);
57
+ line-height: 1.5;
58
+ white-space: pre-wrap;
59
+ overflow-wrap: anywhere;
60
+ }
61
+ .autoc-msg.is-you {
62
+ align-self: flex-end;
63
+ background: var(--lp-surface-2);
64
+ color: var(--lp-ink);
65
+ }
66
+ .autoc-msg.is-agent {
67
+ align-self: flex-start;
68
+ border: 1px solid var(--lp-line);
69
+ color: var(--lp-ink);
70
+ }
71
+ /* β›” A FAILED TURN IS A MESSAGE, NOT A TOAST. A toast is gone before a reader has finished the
72
+ sentence, and this surface's failures are things you act on (rephrase, pick a database).
73
+ ⚠ `--lp-red-deep` / `--lp-red-tint`, CHECKED AGAINST THE PALETTE, not typed from memory: the
74
+ first draft of this rule used `--lp-danger` and `--lp-danger-line`, and NEITHER EXISTS. A ghost
75
+ custom property does not fail loudly β€” it falls back or inherits β€” so the error message would
76
+ have painted in ordinary ink and looked like an answer. Same defect T14 caught on
77
+ `--lp-ink-muted` in the same wave; the palette is the only source worth trusting. */
78
+ .autoc-msg.is-error {
79
+ align-self: flex-start;
80
+ border: 1px solid var(--lp-red);
81
+ background: var(--lp-red-tint);
82
+ color: var(--lp-red-deep);
83
+ }
84
+
85
+ /* What the model asked for and the validator would not keep. ⚠ It renders as ORDINARY content,
86
+ not as a warning ornament: R6's standing rule is that a limit must be REPORTED, and a report
87
+ nobody reads because it looks like chrome is the same as no report. */
88
+ .autoc-dropped {
89
+ align-self: flex-start;
90
+ max-width: 60ch;
91
+ margin: 0;
92
+ padding: 8px 12px;
93
+ border-left: 2px solid var(--lp-line);
94
+ color: var(--lp-muted);
95
+ font-size: var(--lp-fs-xs);
96
+ line-height: 1.5;
97
+ }
98
+ .autoc-dropped ul { margin: 4px 0 0; padding-left: 18px; }
99
+
100
+ .autoc-compose {
101
+ flex: 0 0 auto;
102
+ display: flex;
103
+ gap: 8px;
104
+ align-items: flex-end;
105
+ padding: 12px 24px 20px;
106
+ border-top: 1px solid var(--lp-line);
107
+ }
108
+ .autoc-input {
109
+ flex: 1 1 auto;
110
+ min-width: 0;
111
+ resize: none;
112
+ font: inherit;
113
+ font-size: var(--lp-fs-sm);
114
+ line-height: 1.5;
115
+ padding: 8px 10px;
116
+ border: 1px solid var(--lp-line);
117
+ border-radius: var(--lp-r-md);
118
+ background: var(--lp-surface);
119
+ color: var(--lp-ink);
120
+ }
121
+ .autoc-input:focus-visible {
122
+ outline: 2px solid var(--lp-blue-deep);
123
+ outline-offset: 1px;
124
+ }
125
+
126
+ /* The pending row. ⚠ It uses `.lp-spin`, the ONE loading mark app-wide (purple as of this wave's
127
+ T24) β€” a second spinner vocabulary in a new surface is how an app stops having one. */
128
+ .autoc-pending {
129
+ display: flex;
130
+ align-items: center;
131
+ gap: 8px;
132
+ align-self: flex-start;
133
+ color: var(--lp-muted);
134
+ font-size: var(--lp-fs-sm);
135
+ }
136
+
137
+ /* The accept bar. It is deliberately NOT inside the transcript: accepting is an act on the
138
+ AUTOMATION, not a reply in the conversation, and putting it in the log would make it scroll
139
+ away from the thing it acts on. */
140
+ .autoc-accept {
141
+ flex: 0 0 auto;
142
+ display: flex;
143
+ align-items: center;
144
+ gap: 10px;
145
+ padding: 10px 24px;
146
+ border-top: 1px solid var(--lp-line);
147
+ background: var(--lp-surface-2);
148
+ font-size: var(--lp-fs-sm);
149
+ }
150
+ .autoc-accept-note { color: var(--lp-muted); margin-right: auto; }
151
+
152
+ /* ── the run log's note disclosure (R21 / W34-T45) ────────────────────────── */
153
+
154
+ /* ⭐ WAVE 34 · W34-T45. `notes` is up to 300 chars x 25 entries per run and was rendered
155
+ unconditionally over 20 runs; that is the "too wordy" the owner reported. It is still
156
+ unconditional for a run that FAILED or hit a cap (R6: a limit must be REPORTED) and behind this
157
+ disclosure otherwise.
158
+ ⚠ The list INSIDE stays `.auto-run-notes`, which is C's existing rule in `index.css`. Only the
159
+ wrapper is new, so the notes themselves cannot start looking different depending on which
160
+ branch rendered them. */
161
+ .autor-notes {
162
+ margin: 2px 0 0;
163
+ }
164
+ .autor-notes-toggle {
165
+ cursor: pointer;
166
+ color: var(--lp-muted);
167
+ font-size: var(--lp-fs-2xs);
168
+ /* The default triangle is the affordance; no custom marker, because a disclosure that does not
169
+ look like one is how a person decides there is nothing behind it. */
170
+ list-style: revert;
171
+ }
172
+ .autor-notes-toggle:hover { color: var(--lp-ink); }
173
+ .autor-notes-toggle:focus-visible {
174
+ outline: 2px solid var(--lp-blue-deep);
175
+ outline-offset: 1px;
176
+ }
web/src/automation/automationApi.ts CHANGED
@@ -42,7 +42,18 @@ export type AutomationKind =
42
  | "field_instagram"
43
  | "discover_instagram"
44
  | "discover_tiktok"
45
- | "plain";
 
 
 
 
 
 
 
 
 
 
 
46
  export type RunState = "idle" | "running" | "ok" | "error" | "partial";
47
  /*
48
  * ⭐ WAVE 28 Β· R5 β€” `CaptureTier` ("anonymous" | "brightdata") IS DELETED. It was the client's
@@ -131,6 +142,20 @@ export interface Automation {
131
  createdBy?: string;
132
  nextRunAt?: string;
133
  running?: boolean;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
134
  /**
135
  * ⭐ WAVE 26 Β· ITEM 22 / D-70 / R12 β€” IS A PAID SEARCH ALREADY OUTSTANDING AT THE PROVIDER?
136
  *
@@ -471,6 +496,18 @@ export interface ActionCatalogRow {
471
  group: string;
472
  ready: boolean;
473
  detail: string;
 
 
 
 
 
 
 
 
 
 
 
 
474
  /**
475
  * ⭐ WAVE 24 / C-ACT β€” THE SERVER DECIDES THE MENU'S GROUP ORDER, and the client sorts by
476
  * this number and **never by a literal list of group names**. A hard-coded
@@ -976,6 +1013,66 @@ export function createAutomation(body: unknown): Promise<{ automation: Automatio
976
  return send("/automations", { method: "POST", body: JSON.stringify(body) });
977
  }
978
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
979
  export function patchAutomation(
980
  id: string,
981
  body: unknown
@@ -1153,7 +1250,7 @@ export function runBlock(a: Automation): { blocked: boolean; label: string; why:
1153
  label: "Waiting for results",
1154
  why:
1155
  "The search is already running at the provider and has been paid for. It collects on " +
1156
- "its own β€” starting another would buy a second search.",
1157
  };
1158
  }
1159
  return { blocked: false, label: "", why: "" };
@@ -1176,7 +1273,7 @@ export const COUNT_LABELS: [string, string][] = [
1176
  // property of the data and usually benign; "not written β€” the database is
1177
  // full" is a property of the system and never is. One chip for both is how a
1178
  // time series stops growing without anybody being told.
1179
- ["capped", "Not written β€” the database is full"],
1180
  ["source_rows", "Rows read"],
1181
  ["profiles", "Profiles"],
1182
  ["ok", "Read in full"],
 
42
  | "field_instagram"
43
  | "discover_instagram"
44
  | "discover_tiktok"
45
+ | "plain"
46
+ /**
47
+ * ⭐⭐ WAVE 34 Β· CONTRACT C3 (R13) β€” an AI enrichment COLUMN, surfaced here as an agent.
48
+ *
49
+ * β›” NO AUTOMATION IS EVER STORED WITH THIS KIND. It arrives only on the SYNTHETIC rows
50
+ * `routes_automation._field_agent_rows` derives from field definitions, so it is absent from
51
+ * the server's `KINDS`, uncreatable by construction, and a `POST /automations` naming it is
52
+ * refused by the ordinary save door. It is in this union because the wire carries it and the
53
+ * rail renders it β€” the comment above this type records what it costs when the wire carries a
54
+ * value the caller cannot name.
55
+ */
56
+ | "field_agent";
57
  export type RunState = "idle" | "running" | "ok" | "error" | "partial";
58
  /*
59
  * ⭐ WAVE 28 Β· R5 β€” `CaptureTier` ("anonymous" | "brightdata") IS DELETED. It was the client's
 
142
  createdBy?: string;
143
  nextRunAt?: string;
144
  running?: boolean;
145
+ /**
146
+ * ⭐⭐ WAVE 34 Β· CONTRACTS C3 + C4 β€” WHY THIS AGENT CANNOT BE DELETED, as a slug.
147
+ *
148
+ * Absent on an ordinary agent. `"field_agent"` is an AI enrichment column surfaced here (R13);
149
+ * `"odoo_sync"` is the connector's own schedule (R22). ⚠ A STRING, NOT A BOOLEAN: the two
150
+ * contracts disagreed about that (ask `E-13`), and a slug is what lets the delete refusal NAME
151
+ * the reason and send the reader to the right place. Truthiness still answers "is this a system
152
+ * agent", so a caller that only needs that can keep asking it that way.
153
+ *
154
+ * β›” IT IS NOT A PERMISSION. The server refuses the delete with `409 system_agent` whatever the
155
+ * client renders; this field exists so the UI can explain BEFORE the click rather than after,
156
+ * and hiding a control is never the wall.
157
+ */
158
+ system?: string;
159
  /**
160
  * ⭐ WAVE 26 Β· ITEM 22 / D-70 / R12 β€” IS A PAID SEARCH ALREADY OUTSTANDING AT THE PROVIDER?
161
  *
 
496
  group: string;
497
  ready: boolean;
498
  detail: string;
499
+ /**
500
+ * ⭐⭐ WAVE 34 Β· W34-T44 / R18 β€” MAY THE PICKER OFFER IT? Distinct from `ready`, and the
501
+ * difference is who the row is FOR. `ready:false` is a row a person SHOULD see and cannot use
502
+ * yet (it answers "can this product do X"); `menu:false` is a row a person should never be
503
+ * offered but the builder must still be able to READ, because a step stored before the rule
504
+ * changed still has to render a name instead of its raw kind token.
505
+ * R18 collapsed six web actions into one; the other five carry `menu:false` and stay in the
506
+ * catalog because the surviving action composes its own steps out of them server-side.
507
+ * ⚠ OPTIONAL FOR ONE REASON ONLY: a server that predates this wave stamps nothing, and an
508
+ * absent value must mean OFFERED. `groupActions` tests `=== false` for exactly that.
509
+ */
510
+ menu?: boolean;
511
  /**
512
  * ⭐ WAVE 24 / C-ACT β€” THE SERVER DECIDES THE MENU'S GROUP ORDER, and the client sorts by
513
  * this number and **never by a literal list of group names**. A hard-coded
 
1013
  return send("/automations", { method: "POST", body: JSON.stringify(body) });
1014
  }
1015
 
1016
+ /**
1017
+ * ⭐⭐ WAVE 34 Β· W34-T42 / R17 β€” a plain-English description becomes a DRAFT flow.
1018
+ *
1019
+ * β›” THE DOOR IS NOT NEW; THE CALLER IS. `POST /api/v1/automations/draft` shipped in wave 33 and
1020
+ * `D-269` books it as having had NO front-end caller ever since β€” a whole, correct, unreachable
1021
+ * feature, which is this repo's most-repeated failure shape. This function is that caller.
1022
+ *
1023
+ * β›” `saved: false` IS LOAD-BEARING AND MUST NOT BE OPTIMISED AWAY. The route deliberately writes
1024
+ * NOTHING: accepting a draft is the ordinary `createAutomation`, so a drafted flow and a
1025
+ * hand-built one are the same object by construction rather than by resemblance. A caller that
1026
+ * treated the response as "created" would be inventing a second write path.
1027
+ *
1028
+ * ⚠ `dropped` IS THE HONEST HALF AND IT IS NOT AN ERROR LIST. The route runs the REAL save-door
1029
+ * validator over the model's output and diffs it, because `clean_actions` silently discards a
1030
+ * config key it does not recognise and still answers 200. Anything in `dropped` is something the
1031
+ * person was shown that will NOT be there afterwards; rendering it is R6's second sentence.
1032
+ */
1033
+ export interface AutomationDraft {
1034
+ draft: {
1035
+ name?: string;
1036
+ /**
1037
+ * ⚠ A STRING, NOT `{key}`, AND THE FIRST DRAFT OF THIS TYPE GOT IT WRONG. The route returns
1038
+ * `"trigger": _trig or "manual"` β€” a bare trigger KEY β€” while a stored automation's own
1039
+ * `trigger` is an object. Typed as `{key?: string}` it compiled, `draft.trigger?.key` read
1040
+ * `undefined` on every draft, and every accepted flow would have silently landed with no
1041
+ * trigger at all. Nothing would have been red: the save succeeds, the flow is valid, and it
1042
+ * simply never fires. Read off the route's own `return`, not from the sibling shape.
1043
+ */
1044
+ trigger?: string;
1045
+ table?: string;
1046
+ actions?: { id?: string; kind?: string; config?: Record<string, unknown>; why?: string;
1047
+ needs?: string[]; unknownField?: string }[];
1048
+ };
1049
+ provider?: string;
1050
+ /**
1051
+ * What the real save-door validator discarded from the model's answer (D-75's channel).
1052
+ *
1053
+ * β›” OBJECTS, NOT SENTENCES, AND TYPING IT `string[]` COMPILED. The route builds
1054
+ * `{kind, keys}` per step whose config the validator changed. Rendered as a string each row
1055
+ * would have painted `[object Object]` β€” a report about what silently changed, itself silently
1056
+ * wrong. Read off the route's `dropped.append(...)`, not from the shape of `notes` beside it.
1057
+ */
1058
+ dropped?: { kind: string; keys: string[] }[];
1059
+ /**
1060
+ * The route's OTHER honest channel, and it is not the same as `dropped`: a trigger this
1061
+ * deployment does not have (corrected to manual, and said), or steps beyond
1062
+ * `MAX_DRAFT_ACTIONS` that were not kept. Both are things the reader must be told, and both
1063
+ * arrive here rather than in `dropped`.
1064
+ */
1065
+ notes?: string[];
1066
+ saved?: boolean;
1067
+ }
1068
+
1069
+ export function draftAutomation(prompt: string): Promise<AutomationDraft> {
1070
+ return send("/automations/draft", {
1071
+ method: "POST",
1072
+ body: JSON.stringify({ prompt }),
1073
+ });
1074
+ }
1075
+
1076
  export function patchAutomation(
1077
  id: string,
1078
  body: unknown
 
1250
  label: "Waiting for results",
1251
  why:
1252
  "The search is already running at the provider and has been paid for. It collects on " +
1253
+ "its own. Starting another would buy a second search.",
1254
  };
1255
  }
1256
  return { blocked: false, label: "", why: "" };
 
1273
  // property of the data and usually benign; "not written β€” the database is
1274
  // full" is a property of the system and never is. One chip for both is how a
1275
  // time series stops growing without anybody being told.
1276
+ ["capped", "Not written. The database is full"],
1277
  ["source_rows", "Rows read"],
1278
  ["profiles", "Profiles"],
1279
  ["ok", "Read in full"],
web/src/automation/steps.ts CHANGED
@@ -367,11 +367,21 @@ export interface ActionGroup {
367
  * printed caption ("Web action", "Database", "Connected", "Advanced logic") β€” the server sends
368
  * no separate `groupLabel` for actions β€” so this returns `key` and the caller prints it. Adding
369
  * a `label` member that merely copied `key` would invent a second name for one string.
 
 
 
 
 
 
 
 
 
370
  */
371
  export function groupActions(rows: ActionCatalogRow[]): ActionGroup[] {
372
  const out: ActionGroup[] = [];
373
  const byKey = new Map<string, ActionGroup>();
374
  for (const c of rows) {
 
375
  const key = c.group || "";
376
  let g = byKey.get(key);
377
  if (!g) {
 
367
  * printed caption ("Web action", "Database", "Connected", "Advanced logic") β€” the server sends
368
  * no separate `groupLabel` for actions β€” so this returns `key` and the caller prints it. Adding
369
  * a `label` member that merely copied `key` would invent a second name for one string.
370
+ *
371
+ * ⭐⭐ WAVE 34 Β· W34-T44 / R18 β€” IT IS THE MENU, SO IT HONOURS `menu`. The server ships rows the
372
+ * picker must not OFFER but the builder must still be able to READ: R18 collapsed six web actions
373
+ * into one, and the other five stay in the catalog because `_ai_agent_plan` composes the surviving
374
+ * action's steps out of them. This is the ONE place the distinction is applied, because this is
375
+ * the one function whose output is a menu β€” the label lookup in `AutomationBuilder` walks the
376
+ * unfiltered catalog on purpose, so a step stored last month still renders a name and not a token.
377
+ * ⚠ `!== false` and not truthiness: the server stamps a real boolean on every row, and an older
378
+ * server that stamps nothing must keep offering its rows rather than showing an empty picker.
379
  */
380
  export function groupActions(rows: ActionCatalogRow[]): ActionGroup[] {
381
  const out: ActionGroup[] = [];
382
  const byKey = new Map<string, ActionGroup>();
383
  for (const c of rows) {
384
+ if (c.menu === false) continue;
385
  const key = c.group || "";
386
  let g = byKey.get(key);
387
  if (!g) {
web/src/customer-grid/ColumnMenu.tsx CHANGED
@@ -84,6 +84,18 @@ interface FieldConfigExtra {
84
  * it: `_clean_field` accepts a code column without one and it renders as plain text, so this
85
  * is an ordinary edit rather than a create requirement. */
86
  code?: { language: string };
 
 
 
 
 
 
 
 
 
 
 
 
87
  }
88
 
89
  interface ColumnMenuProps {
@@ -230,6 +242,11 @@ interface ColumnMenuProps {
230
  * the control is not rendered at all, so a base field cannot even be offered the action.
231
  */
232
  onDelete?: () => void;
 
 
 
 
 
233
  /** Wave-5 item 1 β€” Duplicate field. Supplied for creatable strata only (base Odoo fields
234
  * offer no Duplicate β€” cloning the source of truth into an editable copy is out of scope). */
235
  onDuplicate?: () => void;
@@ -932,7 +949,7 @@ const AUTOMATION_KINDS: { key: string; label: string; hint: string }[] = [
932
  key: "instagram_profile",
933
  label: "Instagram profile",
934
  hint: "Reads what is anonymously public about the profile in the chosen URL column, and "
935
- + "writes the result here. Runs from the Automation surface β€” on demand or on a schedule.",
936
  },
937
  ];
938
 
@@ -1012,6 +1029,42 @@ function ScopeControl({
1012
  * cannot drift: a FORMULA gets its source editor with live validation and a reference picker;
1013
  * a RATING gets its star count (the contract's 2..10).
1014
  */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1015
  function ExtraTypeEditor({
1016
  kind,
1017
  fields,
@@ -1022,6 +1075,8 @@ function ExtraTypeEditor({
1022
  onMaxValue,
1023
  codeLanguage,
1024
  onCodeLanguage,
 
 
1025
  idPrefix,
1026
  showFormulaHelp = true,
1027
  automationKind = "instagram_profile",
@@ -1073,6 +1128,12 @@ function ExtraTypeEditor({
1073
  * which draft it is editing. */
1074
  codeLanguage: string;
1075
  onCodeLanguage: (v: string) => void;
 
 
 
 
 
 
1076
  idPrefix: string;
1077
  showFormulaHelp?: boolean;
1078
  /** C5-AUTOFIELD β€” the automation column's config drafts. Optional so the call sites that
@@ -1232,6 +1293,140 @@ function ExtraTypeEditor({
1232
  </label>
1233
  );
1234
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1235
  if (kind === "created_time") {
1236
  return (
1237
  <div className="cg-field-hint">
@@ -1661,7 +1856,7 @@ function ExtraTypeEditor({
1661
  <RollupConditionList
1662
  idPrefix={`${idPrefix}-rollup-cond`}
1663
  title="Then keep only"
1664
- hint={"Applied AFTER the window above, over exactly the records it kept β€” which is "
1665
  + "what a β€œΟƒ from the mean” threshold is measured against."}
1666
  conditions={rollupConditions}
1667
  onConditions={(v) => onRollupConditions?.(v)}
@@ -1698,7 +1893,7 @@ function ExtraTypeEditor({
1698
  </label>
1699
  <div className="cg-field-hint">
1700
  {rollupMode === "source"
1701
- ? "Answered straight from the source β€” it can total hundreds of thousands of rows because none of them are copied into this database."
1702
  : "Folds the records a link column reaches, which must already exist in this workspace."}
1703
  </div>
1704
  {rollupMode === "source" ? sourceEditor : linkEditor}
@@ -1740,6 +1935,7 @@ export default function ColumnMenu({
1740
  onUnpin,
1741
  onPeriod,
1742
  onDelete,
 
1743
  onDuplicate,
1744
  onPermissions,
1745
  onFormat,
@@ -1776,6 +1972,31 @@ export default function ColumnMenu({
1776
  /** ⭐ Wave-27 item 13 (R13) β€” the create form's code LANGUAGE. Seeded to `plain`, which is
1777
  * also what an absent bag means, so creating without touching this stores nothing extra. */
1778
  const [codeLanguage, setCodeLanguage] = useState<string>("plain");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1779
  /** C5-AUTOFIELD β€” the create form's automation config. */
1780
  const [automationKind, setAutomationKind] = useState(AUTOMATION_KINDS[0].key);
1781
  const [automationUrlField, setAutomationUrlField] = useState(
@@ -1987,6 +2208,10 @@ export default function ColumnMenu({
1987
  // creating one without this gate produces a column that is named, configured and simply
1988
  // absent on the next read. The same "permanently blank column" argument as the two rules
1989
  // above, one step worse β€” the column does not exist at all.
 
 
 
 
1990
  (kind !== "link" || linkTable !== "") &&
1991
  (kind !== "rollup" || (rollupMode === "source"
1992
  // ⭐ 2026-08-09 β€” the read-through bag. ALL FOUR keys are required by `_clean_rollup`
@@ -2015,6 +2240,26 @@ export default function ColumnMenu({
2015
  // "switch this column back to Plain text" a change that saves. Omitting it instead would
2016
  // inherit the previous language (the patch path's omit-means-keep rule).
2017
  if (t === "code") return { code: { language: lang } };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2018
  if (t === "automation")
2019
  return {
2020
  automation: {
@@ -2114,7 +2359,20 @@ export default function ColumnMenu({
2114
  swapToNewType != null &&
2115
  swapLabel.trim() !== "" &&
2116
  (!needsOptions(swapToNewType) || swapNewOptions.length > 0) &&
2117
- (swapToNewType !== "formula" || swapFormulaCheck.ok);
 
 
 
 
 
 
 
 
 
 
 
 
 
2118
 
2119
  const createAndSwap = () => {
2120
  if (!swapToNewType || !canCreateSwap) return;
@@ -2219,6 +2477,11 @@ export default function ColumnMenu({
2219
  editLinkSingle !== (field.link?.single === true));
2220
  const canSaveEdit =
2221
  !editBlocked &&
 
 
 
 
 
2222
  (editingRelational
2223
  ? (editRollupValid && (editRelationalTouched || editNameDirty) &&
2224
  (field.type !== "link" || editLinkTable !== ""))
@@ -2282,6 +2545,27 @@ export default function ColumnMenu({
2282
  },
2283
  }
2284
  : {}),
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2285
  ...(editNameDirty ? { label: cleanRename } : {}),
2286
  }
2287
  );
@@ -2487,6 +2771,8 @@ export default function ColumnMenu({
2487
  </>
2488
  )}
2489
  <ExtraTypeEditor
 
 
2490
  kind={kind}
2491
  fields={fields}
2492
  formulaText={formulaText}
@@ -2657,6 +2943,8 @@ export default function ColumnMenu({
2657
  {field.type === "rollup" ? "Rollup" : "Linked records"}
2658
  </span>
2659
  <ExtraTypeEditor
 
 
2660
  kind={field.type}
2661
  fields={fields}
2662
  linkTargets={linkTargets}
@@ -2729,6 +3017,8 @@ export default function ColumnMenu({
2729
  <div className="cg-type-block">
2730
  <span className="cg-type-title">Automation</span>
2731
  <ExtraTypeEditor
 
 
2732
  kind="automation"
2733
  fields={fields}
2734
  formulaText={editFormulaText}
@@ -2878,6 +3168,8 @@ export default function ColumnMenu({
2878
  )}
2879
  {!onRetype && field.type === "formula" && onFormula && (
2880
  <ExtraTypeEditor
 
 
2881
  kind="formula"
2882
  fields={fields}
2883
  formulaText={editFormulaText}
@@ -2978,6 +3270,8 @@ export default function ColumnMenu({
2978
  />
2979
  </label>
2980
  <ExtraTypeEditor
 
 
2981
  kind={swapToNewType}
2982
  fields={fields}
2983
  formulaText={swapFormulaText}
@@ -3501,6 +3795,40 @@ export default function ColumnMenu({
3501
  >
3502
  <MenuLabel icon="hide" text="Hide field" />
3503
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3504
  {!schemaLocked && onDelete && (
3505
  <button
3506
  type="button"
@@ -3523,11 +3851,11 @@ export default function ColumnMenu({
3523
  field.measure
3524
  ? "Removes this formula column from the workspace."
3525
  : field.automation?.preset === true
3526
- ? "Part of this database's built-in schema β€” it will be re-created on the " +
3527
  "next run. Hide it to keep it off the grid."
3528
  : field.type === "rollup" || field.type === "link" ||
3529
  field.type === "formula"
3530
- ? "Removes this column. No values are lost β€” it holds none of its own."
3531
  : "Removes this field AND its values on every row."
3532
  }
3533
  onClick={() => (confirmDelete ? onDelete() : setConfirmDelete(true))}
 
84
  * it: `_clean_field` accepts a code column without one and it renders as plain text, so this
85
  * is an ordinary edit rather than a create requirement. */
86
  code?: { language: string };
87
+ /** ⭐⭐ Wave-34 (owner ruling R13) β€” an AI ENRICHMENT column's config. REQUIRED, like `link`
88
+ * and `rollup` and unlike `code`: `_clean_field` returns None for an `ai_enrich` column with
89
+ * no bag, because a prompt is the only thing that can ever produce a value here. Shaped
90
+ * exactly as `core.user_tables._clean_ai_enrich` stores it, so the panel and the store cannot
91
+ * disagree about a key name. */
92
+ aiEnrich?: {
93
+ prompt: string;
94
+ model?: string;
95
+ maxTokens: number;
96
+ trigger: { mode: string; cron?: string };
97
+ overwrite: string;
98
+ };
99
  }
100
 
101
  interface ColumnMenuProps {
 
242
  * the control is not rendered at all, so a base field cannot even be offered the action.
243
  */
244
  onDelete?: () => void;
245
+ /** ⭐⭐ Wave-34 (R13) β€” run this AI enrichment column over many rows.
246
+ * `scope` is `"blank"` (only cells still empty) or `"always"` (every row the AI
247
+ * wrote). OPTIONAL for `onDelete`'s reason: a host that has not wired the door shows
248
+ * no row, rather than a row that does nothing. */
249
+ onEnrich?: (scope: "blank" | "always") => void;
250
  /** Wave-5 item 1 β€” Duplicate field. Supplied for creatable strata only (base Odoo fields
251
  * offer no Duplicate β€” cloning the source of truth into an editable copy is out of scope). */
252
  onDuplicate?: () => void;
 
949
  key: "instagram_profile",
950
  label: "Instagram profile",
951
  hint: "Reads what is anonymously public about the profile in the chosen URL column, and "
952
+ + "writes the result here. Runs from the Automation surface, on demand or on a schedule.",
953
  },
954
  ];
955
 
 
1029
  * cannot drift: a FORMULA gets its source editor with live validation and a reference picker;
1030
  * a RATING gets its star count (the contract's 2..10).
1031
  */
1032
+ /**
1033
+ * ⭐⭐ Wave-34 (R13) β€” the AI enrichment draft, as the panel holds it.
1034
+ *
1035
+ * FLAT, while the wire bag nests `trigger`. That is deliberate: a form is edited one control at a
1036
+ * time and a nested draft makes every setter a two-level spread, which is where a patcher drops a
1037
+ * sibling key. `extraFor` does the one nesting at the moment of sending.
1038
+ */
1039
+ interface AiEnrichDraft {
1040
+ prompt: string;
1041
+ model: string;
1042
+ maxTokens: number;
1043
+ mode: string;
1044
+ cron: string;
1045
+ overwrite: string;
1046
+ }
1047
+
1048
+ /** The three moments an enrichment column can run. Mirrors `AI_ENRICH_TRIGGER_MODES`. */
1049
+ const AI_ENRICH_MODES: ReadonlyArray<{ value: string; label: string }> = [
1050
+ { value: "manual", label: "Only when I ask" },
1051
+ { value: "on_change", label: "When a referenced column changes" },
1052
+ { value: "schedule", label: "On a schedule" },
1053
+ ];
1054
+
1055
+ /**
1056
+ * What an AUTOMATIC run may replace. β›” There is no "overwrite anything" option and there must
1057
+ * not be: a cell somebody typed into is never overwritten by an agent, whatever is chosen here,
1058
+ * so "Every row" means every row THE AI WROTE. The labels say that rather than leaving a user to
1059
+ * discover it, because a control that quietly means less than it says is worse than a narrower
1060
+ * control (`core.user_tables.ai_enrich_human_authored` is the law it is describing).
1061
+ */
1062
+ const AI_ENRICH_OVERWRITE: ReadonlyArray<{ value: string; label: string }> = [
1063
+ { value: "blank", label: "Only rows that are still empty" },
1064
+ { value: "stale", label: "Empty rows, and AI answers whose inputs have changed" },
1065
+ { value: "always", label: "Every row the AI wrote, every run" },
1066
+ ];
1067
+
1068
  function ExtraTypeEditor({
1069
  kind,
1070
  fields,
 
1075
  onMaxValue,
1076
  codeLanguage,
1077
  onCodeLanguage,
1078
+ aiEnrich,
1079
+ onAiEnrich,
1080
  idPrefix,
1081
  showFormulaHelp = true,
1082
  automationKind = "instagram_profile",
 
1128
  * which draft it is editing. */
1129
  codeLanguage: string;
1130
  onCodeLanguage: (v: string) => void;
1131
+ /** ⭐⭐ Wave-34 (R13) β€” the AI enrichment draft. REQUIRED for `codeLanguage`'s exact reason and
1132
+ * one step stronger: this pane renders the ONLY control that can make an `ai_enrich` column
1133
+ * creatable at all, so an unwired mount would render a prompt box that saves nothing and a
1134
+ * Create button that `canCreate` then blocks forever with no visible cause. */
1135
+ aiEnrich: AiEnrichDraft;
1136
+ onAiEnrich: (patch: Partial<AiEnrichDraft>) => void;
1137
  idPrefix: string;
1138
  showFormulaHelp?: boolean;
1139
  /** C5-AUTOFIELD β€” the automation column's config drafts. Optional so the call sites that
 
1293
  </label>
1294
  );
1295
  }
1296
+ if (kind === "ai_enrich") {
1297
+ // ⭐⭐ WAVE-34 (owner ruling R13) β€” *"a prompt per row that populates text. Detailed
1298
+ // configuration in the field's own config, including a token-usage limit."*
1299
+ //
1300
+ // β›” THE PROMPT IS FIRST AND IT IS THE ONLY REQUIRED CONTROL, because it is the only one
1301
+ // without which the column can never hold anything (`_clean_field` refuses the field). The
1302
+ // other four all have a working default, so a user who types one sentence and presses Create
1303
+ // gets a column that runs.
1304
+ // ⚠ Every control below is an EXISTING one (`cg-input`, `cg-select`, `cg-field-hint`,
1305
+ // `<label><span>…`): `index.css` belongs to another lane this wave, so this panel is built
1306
+ // from the vocabulary already in the file rather than from a rule I cannot add.
1307
+ const refs = Array.from(
1308
+ new Set((aiEnrich.prompt.match(/\{([a-zA-Z0-9_]{1,60})\}/g) ?? []).map((m) => m.slice(1, -1)))
1309
+ );
1310
+ const known = new Set(fields.map((f) => f.key));
1311
+ const unknown = refs.filter((r) => !known.has(r));
1312
+ return (
1313
+ <>
1314
+ <label>
1315
+ <span>Prompt</span>
1316
+ <textarea
1317
+ id={`${idPrefix}-ai-prompt`}
1318
+ className="cg-input"
1319
+ rows={3}
1320
+ value={aiEnrich.prompt}
1321
+ placeholder="Summarise what {name} sells, in one line."
1322
+ aria-label="Enrichment prompt"
1323
+ onChange={(event) => onAiEnrich({ prompt: event.target.value })}
1324
+ />
1325
+ {/* β›” THE UNRESOLVED-TOKEN WARNING IS NOT DECORATION. A `{token}` naming no column is
1326
+ sent verbatim to the model, which then answers a different question on every row and
1327
+ returns text that looks exactly like a correct answer. The server reports it per row
1328
+ at run time; saying it here costs one run nobody has paid for yet. */}
1329
+ {unknown.length > 0 ? (
1330
+ <span className="cg-field-hint">
1331
+ {unknown.map((u) => `{${u}}`).join(", ")} is not a column on this database. Wrap a
1332
+ column key in braces to put its value into the prompt.
1333
+ </span>
1334
+ ) : (
1335
+ <span className="cg-field-hint">
1336
+ Put a column key in braces to use its value, the way a formula does. Each row gets
1337
+ its own answer.
1338
+ </span>
1339
+ )}
1340
+ </label>
1341
+ <label>
1342
+ <span>When it runs</span>
1343
+ <select
1344
+ id={`${idPrefix}-ai-mode`}
1345
+ className="cg-select"
1346
+ value={aiEnrich.mode}
1347
+ aria-label="When the enrichment runs"
1348
+ onChange={(event) => onAiEnrich({ mode: event.target.value })}
1349
+ >
1350
+ {AI_ENRICH_MODES.map((m) => (
1351
+ <option key={m.value} value={m.value}>
1352
+ {m.label}
1353
+ </option>
1354
+ ))}
1355
+ </select>
1356
+ </label>
1357
+ {/* A cadence box only where a cadence is read. A schedule stored under "Only when I ask"
1358
+ is a setting the user can see and nothing will ever act on, which is the shape D-246
1359
+ wears on the Odoo sync, so the server drops it and this never offers it. */}
1360
+ {aiEnrich.mode === "schedule" ? (
1361
+ <label>
1362
+ <span>Schedule</span>
1363
+ <input
1364
+ id={`${idPrefix}-ai-cron`}
1365
+ className="cg-input"
1366
+ value={aiEnrich.cron}
1367
+ placeholder="0 6 * * *"
1368
+ aria-label="Enrichment schedule"
1369
+ onChange={(event) => onAiEnrich({ cron: event.target.value })}
1370
+ />
1371
+ <span className="cg-field-hint">Minute, hour, day, month, weekday.</span>
1372
+ </label>
1373
+ ) : null}
1374
+ <label>
1375
+ <span>Which rows</span>
1376
+ <select
1377
+ id={`${idPrefix}-ai-overwrite`}
1378
+ className="cg-select"
1379
+ value={aiEnrich.overwrite}
1380
+ aria-label="Which rows a run may fill"
1381
+ onChange={(event) => onAiEnrich({ overwrite: event.target.value })}
1382
+ >
1383
+ {AI_ENRICH_OVERWRITE.map((o) => (
1384
+ <option key={o.value} value={o.value}>
1385
+ {o.label}
1386
+ </option>
1387
+ ))}
1388
+ </select>
1389
+ <span className="cg-field-hint">
1390
+ A cell you have typed into is never overwritten by a run, whichever of these is chosen.
1391
+ </span>
1392
+ </label>
1393
+ <label>
1394
+ <span>Token limit per row</span>
1395
+ <input
1396
+ id={`${idPrefix}-ai-tokens`}
1397
+ type="number"
1398
+ className="cg-input"
1399
+ min={16}
1400
+ max={4000}
1401
+ value={aiEnrich.maxTokens}
1402
+ aria-label="Token limit per row"
1403
+ onChange={(event) => onAiEnrich({ maxTokens: Number(event.target.value) || 300 })}
1404
+ />
1405
+ {/* R6's standing rule, at the control rather than in a support answer: a run that hits
1406
+ its ceiling STOPS and says how far it got, so this number is the whole budget's
1407
+ basis and not a soft target. */}
1408
+ <span className="cg-field-hint">
1409
+ Also sets the whole run's budget. A run that reaches it stops and reports how many
1410
+ rows it filled.
1411
+ </span>
1412
+ </label>
1413
+ <label>
1414
+ <span>Model</span>
1415
+ <input
1416
+ id={`${idPrefix}-ai-model`}
1417
+ className="cg-input"
1418
+ value={aiEnrich.model}
1419
+ placeholder="Leave empty for the default"
1420
+ aria-label="Enrichment model"
1421
+ onChange={(event) => onAiEnrich({ model: event.target.value })}
1422
+ />
1423
+ <span className="cg-field-hint">
1424
+ Empty uses the cheapest provider configured on this deployment.
1425
+ </span>
1426
+ </label>
1427
+ </>
1428
+ );
1429
+ }
1430
  if (kind === "created_time") {
1431
  return (
1432
  <div className="cg-field-hint">
 
1856
  <RollupConditionList
1857
  idPrefix={`${idPrefix}-rollup-cond`}
1858
  title="Then keep only"
1859
+ hint={"Applied AFTER the window above, over exactly the records it kept, which is "
1860
  + "what a β€œΟƒ from the mean” threshold is measured against."}
1861
  conditions={rollupConditions}
1862
  onConditions={(v) => onRollupConditions?.(v)}
 
1893
  </label>
1894
  <div className="cg-field-hint">
1895
  {rollupMode === "source"
1896
+ ? "Answered straight from the source. It can total hundreds of thousands of rows because none of them are copied into this database."
1897
  : "Folds the records a link column reaches, which must already exist in this workspace."}
1898
  </div>
1899
  {rollupMode === "source" ? sourceEditor : linkEditor}
 
1935
  onUnpin,
1936
  onPeriod,
1937
  onDelete,
1938
+ onEnrich,
1939
  onDuplicate,
1940
  onPermissions,
1941
  onFormat,
 
1972
  /** ⭐ Wave-27 item 13 (R13) β€” the create form's code LANGUAGE. Seeded to `plain`, which is
1973
  * also what an absent bag means, so creating without touching this stores nothing extra. */
1974
  const [codeLanguage, setCodeLanguage] = useState<string>("plain");
1975
+ /**
1976
+ * ⭐⭐ Wave-34 (owner ruling R13) β€” the create form's AI ENRICHMENT config.
1977
+ *
1978
+ * ⚠ ONE BAG AND ONE PATCHER, not six flat props. `link`/`rollup`/`automation` above are each a
1979
+ * scatter of `xState`/`onXState` pairs, and the scouted consequence is concrete: the swap form
1980
+ * mounts `ExtraTypeEditor` WITHOUT those setters, so choosing one of those kinds there renders
1981
+ * a panel whose controls are wired to `undefined`. A single bag cannot be half-passed, so this
1982
+ * kind cannot arrive at that door inert.
1983
+ * ⚠ The defaults ARE the server's defaults (`AI_ENRICH_DEFAULT_TOKENS = 300`, `mode: manual`,
1984
+ * `overwrite: blank`), so creating without touching anything but the prompt stores exactly what
1985
+ * the panel showed.
1986
+ */
1987
+ const [aiEnrich, setAiEnrich] = useState<AiEnrichDraft>(() => ({
1988
+ prompt: "",
1989
+ model: "",
1990
+ maxTokens: 300,
1991
+ mode: "manual",
1992
+ cron: "",
1993
+ overwrite: "blank",
1994
+ }));
1995
+ /** ⚠ FUNCTIONAL UPDATE, not `setAiEnrich({...aiEnrich, ...patch})`. Two controls in this panel
1996
+ * can change in one tick (picking `schedule` reveals the cron box, which the user then types
1997
+ * into), and a spread over a captured `aiEnrich` would drop whichever landed first. */
1998
+ const patchAiEnrich = (patch: Partial<AiEnrichDraft>) =>
1999
+ setAiEnrich((prev) => ({ ...prev, ...patch }));
2000
  /** C5-AUTOFIELD β€” the create form's automation config. */
2001
  const [automationKind, setAutomationKind] = useState(AUTOMATION_KINDS[0].key);
2002
  const [automationUrlField, setAutomationUrlField] = useState(
 
2208
  // creating one without this gate produces a column that is named, configured and simply
2209
  // absent on the next read. The same "permanently blank column" argument as the two rules
2210
  // above, one step worse β€” the column does not exist at all.
2211
+ // ⭐⭐ Wave-34 (R13) β€” the same argument one kind over: `_clean_field` returns None for an
2212
+ // `ai_enrich` column with no prompt, so creating one without this gate produces a column that
2213
+ // is named, configured and simply absent on the next read.
2214
+ (kind !== "ai_enrich" || aiEnrich.prompt.trim() !== "") &&
2215
  (kind !== "link" || linkTable !== "") &&
2216
  (kind !== "rollup" || (rollupMode === "source"
2217
  // ⭐ 2026-08-09 β€” the read-through bag. ALL FOUR keys are required by `_clean_rollup`
 
2240
  // "switch this column back to Plain text" a change that saves. Omitting it instead would
2241
  // inherit the previous language (the patch path's omit-means-keep rule).
2242
  if (t === "code") return { code: { language: lang } };
2243
+ // ⭐⭐ Wave-34 (R13) β€” the enrichment bag, shaped exactly as `_clean_ai_enrich` stores it.
2244
+ // ⚠ `cron` TRAVELS ONLY WITH `schedule`, and `model` only when set: the server drops both in
2245
+ // those cases anyway, so sending them would put keys in the payload that come back absent,
2246
+ // which is how a panel starts looking like it does not save. Same rule the rollup bags above
2247
+ // follow for `limit`/`sortBy`.
2248
+ if (t === "ai_enrich")
2249
+ return {
2250
+ aiEnrich: {
2251
+ prompt: aiEnrich.prompt.trim(),
2252
+ ...(aiEnrich.model.trim() ? { model: aiEnrich.model.trim() } : {}),
2253
+ maxTokens: aiEnrich.maxTokens,
2254
+ trigger: {
2255
+ mode: aiEnrich.mode,
2256
+ ...(aiEnrich.mode === "schedule" && aiEnrich.cron.trim()
2257
+ ? { cron: aiEnrich.cron.trim() }
2258
+ : {}),
2259
+ },
2260
+ overwrite: aiEnrich.overwrite,
2261
+ },
2262
+ };
2263
  if (t === "automation")
2264
  return {
2265
  automation: {
 
2359
  swapToNewType != null &&
2360
  swapLabel.trim() !== "" &&
2361
  (!needsOptions(swapToNewType) || swapNewOptions.length > 0) &&
2362
+ (swapToNewType !== "formula" || swapFormulaCheck.ok) &&
2363
+ // ⭐⭐ Wave-34 (R13) β€” the SAME completeness gate `canCreate` carries, on this door too.
2364
+ //
2365
+ // β›” AND THIS DOOR HAD NO SUCH GATE FOR ANY KIND, WHICH IS A PRE-EXISTING DEFECT I AM NOT
2366
+ // FIXING HERE, ONLY REPORTING. `canCreate` refuses a `link` with no target, a `rollup` with
2367
+ // no function and an `automation` with no URL column; this predicate refuses none of them,
2368
+ // and the swap form's own `<ExtraTypeEditor>` mount passes none of their setters either, so
2369
+ // picking one of those three from "Change field" renders a panel wired to nothing and Save
2370
+ // ships whatever stale draft the CREATE pane happens to hold. Booked for the register rather
2371
+ // than repaired mid-ticket ([[defects-that-mask-each-other]] is why: the missing gate and the
2372
+ // missing props hide each other, and fixing one alone arms the other).
2373
+ // ⚠ `ai_enrich` cannot arrive here inert, because it is ONE bag and one patcher rather than a
2374
+ // scatter of optional setters, so this conjunct is the whole of its half.
2375
+ (swapToNewType !== "ai_enrich" || aiEnrich.prompt.trim() !== "");
2376
 
2377
  const createAndSwap = () => {
2378
  if (!swapToNewType || !canCreateSwap) return;
 
2477
  editLinkSingle !== (field.link?.single === true));
2478
  const canSaveEdit =
2479
  !editBlocked &&
2480
+ // ⭐⭐ Wave-34 (R13) β€” the third door onto the same completeness rule (`canCreate` and
2481
+ // `canCreateSwap` are the other two). Retyping a column to AI enrichment without a prompt
2482
+ // is refused by the server, so Save is refused here instead: a 400 arriving after the click
2483
+ // is the same "named, configured, gone" surprise one layer later.
2484
+ (editType !== "ai_enrich" || aiEnrich.prompt.trim() !== "") &&
2485
  (editingRelational
2486
  ? (editRollupValid && (editRelationalTouched || editNameDirty) &&
2487
  (field.type !== "link" || editLinkTable !== ""))
 
2545
  },
2546
  }
2547
  : {}),
2548
+ // ⭐⭐ Wave-34 (R13) β€” RETYPING A COLUMN TO AI ENRICHMENT CARRIES ITS BAG, or the
2549
+ // server refuses the whole write: `_clean_field` returns None for an `ai_enrich`
2550
+ // column with no prompt. The edit pane renders the same panel the create pane does
2551
+ // (`ExtraTypeEditor` is mounted with `kind={editType}`), so the draft the user just
2552
+ // filled in is the one sent here.
2553
+ ...(editType === "ai_enrich"
2554
+ ? {
2555
+ aiEnrich: {
2556
+ prompt: aiEnrich.prompt.trim(),
2557
+ ...(aiEnrich.model.trim() ? { model: aiEnrich.model.trim() } : {}),
2558
+ maxTokens: aiEnrich.maxTokens,
2559
+ trigger: {
2560
+ mode: aiEnrich.mode,
2561
+ ...(aiEnrich.mode === "schedule" && aiEnrich.cron.trim()
2562
+ ? { cron: aiEnrich.cron.trim() }
2563
+ : {}),
2564
+ },
2565
+ overwrite: aiEnrich.overwrite,
2566
+ },
2567
+ }
2568
+ : {}),
2569
  ...(editNameDirty ? { label: cleanRename } : {}),
2570
  }
2571
  );
 
2771
  </>
2772
  )}
2773
  <ExtraTypeEditor
2774
+ aiEnrich={aiEnrich}
2775
+ onAiEnrich={patchAiEnrich}
2776
  kind={kind}
2777
  fields={fields}
2778
  formulaText={formulaText}
 
2943
  {field.type === "rollup" ? "Rollup" : "Linked records"}
2944
  </span>
2945
  <ExtraTypeEditor
2946
+ aiEnrich={aiEnrich}
2947
+ onAiEnrich={patchAiEnrich}
2948
  kind={field.type}
2949
  fields={fields}
2950
  linkTargets={linkTargets}
 
3017
  <div className="cg-type-block">
3018
  <span className="cg-type-title">Automation</span>
3019
  <ExtraTypeEditor
3020
+ aiEnrich={aiEnrich}
3021
+ onAiEnrich={patchAiEnrich}
3022
  kind="automation"
3023
  fields={fields}
3024
  formulaText={editFormulaText}
 
3168
  )}
3169
  {!onRetype && field.type === "formula" && onFormula && (
3170
  <ExtraTypeEditor
3171
+ aiEnrich={aiEnrich}
3172
+ onAiEnrich={patchAiEnrich}
3173
  kind="formula"
3174
  fields={fields}
3175
  formulaText={editFormulaText}
 
3270
  />
3271
  </label>
3272
  <ExtraTypeEditor
3273
+ aiEnrich={aiEnrich}
3274
+ onAiEnrich={patchAiEnrich}
3275
  kind={swapToNewType}
3276
  fields={fields}
3277
  formulaText={swapFormulaText}
 
3795
  >
3796
  <MenuLabel icon="hide" text="Hide field" />
3797
  </button>
3798
+ {/* ⭐⭐ WAVE-34 (owner ruling R13) β€” THE BULK RUN, and it is TWO rows rather than one
3799
+ because "run this column" is two different questions with two different bills.
3800
+ β›” NEITHER LABEL SAYS "everything". `ai_enrich_may_write` refuses a human-edited cell
3801
+ under every policy, so "All rows" means every row the AI wrote; saying "all" flat
3802
+ would be a control that quietly means less than it says, which is worse than a
3803
+ narrower control. The server takes these as `scope: "blank" | "always"`.
3804
+ ⚠ `onEnrich` is OPTIONAL, exactly as `onDelete` above it is: a host that has not
3805
+ wired the door renders no row rather than a row that does nothing.
3806
+ β›” THE GLYPH IS `FieldTypeIcon`, NOT A `MenuIcon` NAME, and that is not a style
3807
+ choice. `MenuLabel`'s prop is `MenuIconName | ReactNode`, and `ReactNode` admits
3808
+ any string, so `icon="sparkle"` COMPILES and renders an empty glyph box forever
3809
+ (`MENU_ICONS` has no such key). Reusing the type icon paints the same mark the
3810
+ column header and the add-field picker already wear, from one geometry source, and
3811
+ needs no new key in a file no lane owns. [[schema-role-is-not-a-value-wall]] */}
3812
+ {field.type === "ai_enrich" && onEnrich && (
3813
+ <>
3814
+ <button
3815
+ type="button"
3816
+ title="Fills only the cells that are still empty. Costs nothing for rows that already have an answer."
3817
+ onClick={() => onEnrich("blank")}
3818
+ >
3819
+ <MenuLabel icon={<FieldTypeIcon type="ai_enrich" size={16} />}
3820
+ text="Fill rows never filled" />
3821
+ </button>
3822
+ <button
3823
+ type="button"
3824
+ title="Re-asks the model for every row the AI wrote. A cell you have typed into is left alone."
3825
+ onClick={() => onEnrich("always")}
3826
+ >
3827
+ <MenuLabel icon={<FieldTypeIcon type="ai_enrich" size={16} />}
3828
+ text="Fill all rows again" />
3829
+ </button>
3830
+ </>
3831
+ )}
3832
  {!schemaLocked && onDelete && (
3833
  <button
3834
  type="button"
 
3851
  field.measure
3852
  ? "Removes this formula column from the workspace."
3853
  : field.automation?.preset === true
3854
+ ? "Part of this database's built-in schema. It will be re-created on the " +
3855
  "next run. Hide it to keep it off the grid."
3856
  : field.type === "rollup" || field.type === "link" ||
3857
  field.type === "formula"
3858
+ ? "Removes this column. No values are lost. It holds none of its own."
3859
  : "Removes this field AND its values on every row."
3860
  }
3861
  onClick={() => (confirmDelete ? onDelete() : setConfirmDelete(true))}
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -60,7 +60,7 @@ import { NAV_MINIMIZE_EVENT, ROWS_STALE_EVENT, TOAST_EVENT, VIEW_OPEN_EVENT,
60
  from "../apiContract";
61
  import type { ViewOpenDetail } from "../apiContract";
62
  import { addTableField, addTableRow, deleteTableField, deleteTableRow, fetchLinkTargets,
63
- fetchRollupSources, patchTableField } from "./apiBridge";
64
  import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
65
  import SelectFromFile from "./SelectFromFile";
66
  import ImportDialog from "./ImportDialog";
@@ -76,7 +76,7 @@ import { buildTsCsv, tsSheetToTable } from "./timeSeriesData";
76
  import type { TsSheet } from "./timeSeriesData";
77
  import { echoReemit, reconcileEchoView } from "./viewEcho";
78
  import { pruneStamps, reconcileFields } from "./optimism";
79
- import { applyViewOrder, newFolderId, pruneFolderStamps, reconcileFolders,
80
  resolveFolderId } from "./folders";
81
  import type { FolderStamps } from "./folders";
82
  // ⭐ WAVE 27 item 22 β€” the cells the active view's filter forces on a record added under it.
@@ -2105,8 +2105,8 @@ function CustomerGridSurface({
2105
  if (moved)
2106
  signal(
2107
  TOAST_EVENT,
2108
- `${moved} restored record${moved === 1 ? "" : "s"} came back under a new id β€” ` +
2109
- `something had taken the original while it was gone.`
2110
  );
2111
  signal(ROWS_STALE_EVENT);
2112
  } else if (op.kind === "rowDelete") {
@@ -2244,7 +2244,7 @@ function CustomerGridSurface({
2244
  if (offscreen.length) {
2245
  signal(
2246
  TOAST_EVENT,
2247
- `Nothing was deleted β€” ${offscreen.length} of the ${pids.length} selected ` +
2248
  `record${pids.length === 1 ? " is" : "s are"} not shown in this view. Clear the ` +
2249
  `filter (or scroll them into view) and select again.`
2250
  );
@@ -2305,9 +2305,9 @@ function CustomerGridSurface({
2305
  `${risky.length} of these ${one ? "records was" : "records were"} FOUND by an ` +
2306
  `automation. Deleting ${one ? "it" : "them"} is not undone by a re-find` +
2307
  (losses.length
2308
- ? `: ${losses.join(", ")} β€” so a card somebody had already moved on comes back ` +
2309
  `undecided`
2310
- : ` β€” ${one ? "it" : "they"} would come back as a new record with no history`) +
2311
  `. Press Delete again (or click again) to confirm.`
2312
  );
2313
  return false;
@@ -2319,7 +2319,7 @@ function CustomerGridSurface({
2319
  for (const r of captured)
2320
  if (await deleteTableRow(scope, r.rid)) gone.push(r);
2321
  if (!gone.length) {
2322
- signal(TOAST_EVENT, "Nothing was deleted β€” the server refused.");
2323
  return false;
2324
  }
2325
  undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
@@ -2351,7 +2351,7 @@ function CustomerGridSurface({
2351
  TOAST_EVENT,
2352
  `Deleted ${gone.length} record${gone.length === 1 ? "" : "s"}. Ctrl+Z to undo.` +
2353
  (fedByAutomation
2354
- ? " If this automation finds one again it comes back as a NEW candidate β€” first-found" +
2355
  " and times-found reset, and its stage back to Review. Undo keeps all of that."
2356
  : "")
2357
  );
@@ -2581,7 +2581,7 @@ function CustomerGridSurface({
2581
  // returns the label by identity when it fits, so this asks the renderer what it did
2582
  // rather than re-running the measurement and hoping the two agree.
2583
  const cut = !!field && !!colDef && colDef.title !== field.label;
2584
- const text = cut && field ? (note ? `${field.label} β€” ${note}` : field.label) : note;
2585
  const bounds = args.bounds;
2586
  if (text && bounds) {
2587
  setHeaderTip((prev) =>
@@ -3698,7 +3698,7 @@ function CustomerGridSurface({
3698
  if (serverWindowed) {
3699
  out[id] =
3700
  "No count: this database is read through its source in windows, so the browser holds " +
3701
- "one page rather than the whole table β€” a number from it would be the size of that " +
3702
  "page, not of this view. Counting it honestly needs a server-side count per view.";
3703
  } else if (pendingMeasures(view.config.filters, measureSets) > 0) {
3704
  out[id] =
@@ -3961,7 +3961,7 @@ function CustomerGridSurface({
3961
  if (!sheet) return refuse("a time series");
3962
  if (sheet.empty) {
3963
  signal(TOAST_EVENT,
3964
- `"${view.name}" has no metrics on its sheet yet β€” add one, then export.`);
3965
  return;
3966
  }
3967
  if (format === "csv") {
@@ -3987,7 +3987,7 @@ function CustomerGridSurface({
3987
  if (spec?.mode === "catalog") {
3988
  signal(
3989
  TOAST_EVENT,
3990
- `"${view.name}" is a catalog β€” open it and use Print to save it as a PDF. ` +
3991
  `A spreadsheet of its products is not what it draws.`
3992
  );
3993
  return;
@@ -5177,7 +5177,7 @@ function CustomerGridSurface({
5177
  const kanbanReason = !kanbanField
5178
  ? null
5179
  : kanbanField.type === "status"
5180
- ? `Stacked by ${kanbanField.label} β€” read-only, computed from the source system.`
5181
  : !canEditField(kanbanField)
5182
  ? "You do not have permission to edit this field, so cards cannot be moved."
5183
  : null;
@@ -5187,7 +5187,7 @@ function CustomerGridSurface({
5187
  const swipeReason = !swipeField
5188
  ? null
5189
  : swipeField.type === "status"
5190
- ? `Bound to ${swipeField.label} β€” read-only, computed from the source system.`
5191
  : !canEditField(swipeField)
5192
  ? "You do not have permission to edit this field, so records cannot be decided."
5193
  : null;
@@ -5545,6 +5545,16 @@ function CustomerGridSurface({
5545
  } as unknown as HostEvent);
5546
  }}
5547
  onItemMove={(viewId, folderId) => {
 
 
 
 
 
 
 
 
 
 
5548
  stampFolder((p) => ({
5549
  ...p,
5550
  moved: { ...p.moved, [viewId]: { at: Date.now(), folderId } },
@@ -5982,7 +5992,7 @@ function CustomerGridSurface({
5982
  canEdit={!!activeView && mayEditView(activeView, viewer)}
5983
  readOnlyReason={
5984
  !activeView
5985
- ? "A form lives on a saved view β€” save this one first."
5986
  : mayEditView(activeView, viewer)
5987
  ? null
5988
  : "You have view-only access to this view, so its form cannot be changed."
@@ -6396,6 +6406,45 @@ function CustomerGridSurface({
6396
  ? (w) => changeMeasurePeriod(menuField.key, w)
6397
  : undefined
6398
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6399
  onDelete={
6400
  menuField.custom && menuField.key !== lockedKey
6401
  ? () => {
@@ -6726,7 +6775,7 @@ function CustomerGridSurface({
6726
  </div>
6727
  {addCandidates.length > 50 && (
6728
  <div className="cg-field-hint">
6729
- Showing the first 50 of {addCandidates.length.toLocaleString()} matches β€” keep
6730
  typing to narrow.
6731
  </div>
6732
  )}
 
60
  from "../apiContract";
61
  import type { ViewOpenDetail } from "../apiContract";
62
  import { addTableField, addTableRow, deleteTableField, deleteTableRow, fetchLinkTargets,
63
+ fetchRollupSources, patchTableField, enrichField } from "./apiBridge";
64
  import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
65
  import SelectFromFile from "./SelectFromFile";
66
  import ImportDialog from "./ImportDialog";
 
76
  import type { TsSheet } from "./timeSeriesData";
77
  import { echoReemit, reconcileEchoView } from "./viewEcho";
78
  import { pruneStamps, reconcileFields } from "./optimism";
79
+ import { applyViewOrder, isSyntheticFolderId, newFolderId, pruneFolderStamps, reconcileFolders,
80
  resolveFolderId } from "./folders";
81
  import type { FolderStamps } from "./folders";
82
  // ⭐ WAVE 27 item 22 β€” the cells the active view's filter forces on a record added under it.
 
2105
  if (moved)
2106
  signal(
2107
  TOAST_EVENT,
2108
+ `${moved} restored record${moved === 1 ? "" : "s"} came back under a new id. ` +
2109
+ `Something had taken the original while it was gone.`
2110
  );
2111
  signal(ROWS_STALE_EVENT);
2112
  } else if (op.kind === "rowDelete") {
 
2244
  if (offscreen.length) {
2245
  signal(
2246
  TOAST_EVENT,
2247
+ `Nothing was deleted. ${offscreen.length} of the ${pids.length} selected ` +
2248
  `record${pids.length === 1 ? " is" : "s are"} not shown in this view. Clear the ` +
2249
  `filter (or scroll them into view) and select again.`
2250
  );
 
2305
  `${risky.length} of these ${one ? "records was" : "records were"} FOUND by an ` +
2306
  `automation. Deleting ${one ? "it" : "them"} is not undone by a re-find` +
2307
  (losses.length
2308
+ ? `: ${losses.join(", ")}, so a card somebody had already moved on comes back ` +
2309
  `undecided`
2310
+ : `, so ${one ? "it" : "they"} would come back as a new record with no history`) +
2311
  `. Press Delete again (or click again) to confirm.`
2312
  );
2313
  return false;
 
2319
  for (const r of captured)
2320
  if (await deleteTableRow(scope, r.rid)) gone.push(r);
2321
  if (!gone.length) {
2322
+ signal(TOAST_EVENT, "Nothing was deleted: the server refused.");
2323
  return false;
2324
  }
2325
  undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
 
2351
  TOAST_EVENT,
2352
  `Deleted ${gone.length} record${gone.length === 1 ? "" : "s"}. Ctrl+Z to undo.` +
2353
  (fedByAutomation
2354
+ ? " If this automation finds one again it comes back as a NEW candidate: first-found" +
2355
  " and times-found reset, and its stage back to Review. Undo keeps all of that."
2356
  : "")
2357
  );
 
2581
  // returns the label by identity when it fits, so this asks the renderer what it did
2582
  // rather than re-running the measurement and hoping the two agree.
2583
  const cut = !!field && !!colDef && colDef.title !== field.label;
2584
+ const text = cut && field ? (note ? `${field.label}: ${note}` : field.label) : note;
2585
  const bounds = args.bounds;
2586
  if (text && bounds) {
2587
  setHeaderTip((prev) =>
 
3698
  if (serverWindowed) {
3699
  out[id] =
3700
  "No count: this database is read through its source in windows, so the browser holds " +
3701
+ "one page rather than the whole table. A number from it would be the size of that " +
3702
  "page, not of this view. Counting it honestly needs a server-side count per view.";
3703
  } else if (pendingMeasures(view.config.filters, measureSets) > 0) {
3704
  out[id] =
 
3961
  if (!sheet) return refuse("a time series");
3962
  if (sheet.empty) {
3963
  signal(TOAST_EVENT,
3964
+ `"${view.name}" has no metrics on its sheet yet. Add one, then export.`);
3965
  return;
3966
  }
3967
  if (format === "csv") {
 
3987
  if (spec?.mode === "catalog") {
3988
  signal(
3989
  TOAST_EVENT,
3990
+ `"${view.name}" is a catalog. Open it and use Print to save it as a PDF. ` +
3991
  `A spreadsheet of its products is not what it draws.`
3992
  );
3993
  return;
 
5177
  const kanbanReason = !kanbanField
5178
  ? null
5179
  : kanbanField.type === "status"
5180
+ ? `Stacked by ${kanbanField.label}. That field is read-only, computed from the source system.`
5181
  : !canEditField(kanbanField)
5182
  ? "You do not have permission to edit this field, so cards cannot be moved."
5183
  : null;
 
5187
  const swipeReason = !swipeField
5188
  ? null
5189
  : swipeField.type === "status"
5190
+ ? `Bound to ${swipeField.label}. That field is read-only, computed from the source system.`
5191
  : !canEditField(swipeField)
5192
  ? "You do not have permission to edit this field, so records cannot be decided."
5193
  : null;
 
5545
  } as unknown as HostEvent);
5546
  }}
5547
  onItemMove={(viewId, folderId) => {
5548
+ // β›” WAVE 34 Β· T25 β€” REFUSE A SYNTHETIC FOLDER ID AT THE ONE PLACE THE WRITE LEAVES.
5549
+ // "Shared with me" and each shared-FOLDER group are READINGS of the view list, not
5550
+ // records: their ids exist in nobody's store. The rail already withholds drop
5551
+ // handlers from those groups, so nothing constructs this call today, and the server
5552
+ // refuses an unknown target anyway (`grid_events.py`'s `item_move` checks the id
5553
+ // against the caller's own folder list). This is the third layer, and it is here
5554
+ // rather than in the rail because this callback is the single choke point every
5555
+ // future surface will reach through: a second caller would otherwise inherit no
5556
+ // client-side check at all. Raised by T25's verifier, which traced exactly that gap.
5557
+ if (isSyntheticFolderId(folderId)) return;
5558
  stampFolder((p) => ({
5559
  ...p,
5560
  moved: { ...p.moved, [viewId]: { at: Date.now(), folderId } },
 
5992
  canEdit={!!activeView && mayEditView(activeView, viewer)}
5993
  readOnlyReason={
5994
  !activeView
5995
+ ? "A form lives on a saved view. Save this one first."
5996
  : mayEditView(activeView, viewer)
5997
  ? null
5998
  : "You have view-only access to this view, so its form cannot be changed."
 
6406
  ? (w) => changeMeasurePeriod(menuField.key, w)
6407
  : undefined
6408
  }
6409
+ /**
6410
+ * ⭐⭐ WAVE 34 (R13, `W34-T54`) β€” THE BULK ENRICHMENT RUN.
6411
+ *
6412
+ * β›” WIRED AT CLOSE-OUT BY THE INTEGRATOR, and the reason belongs here. Lane F built the
6413
+ * server door, the field kind and the menu row, then offered this prop as optional with
6414
+ * *"nothing breaks if you decline"*. True of the BUILD, false of the FEATURE: this is
6415
+ * the ONLY call site that makes the bulk run reachable, and `W34-T54`'s `done-when`
6416
+ * reads *"the bulk menu offers at least all rows and rows never filled"*. Declining it
6417
+ * would have left that ticket BUILT with its own acceptance test unreachable.
6418
+ * ⭐ OPTIONAL IN THE TYPE IS STILL RIGHT β€” `ColumnMenu` renders no row when the host
6419
+ * passes nothing, so a host that has not wired it shows nothing rather than a control
6420
+ * that does nothing. What was wrong was leaving the one real host unwired.
6421
+ * ⚠ IT SPENDS MONEY, so the result is REPORTED, never swallowed: the server answers a
6422
+ * run report and the sentence names what actually happened. A run that filled 3 of 900
6423
+ * because a token ceiling bit is the silent cap R6's standing rule forbids.
6424
+ * ⚠ Gated on `isUserTable` for the same reason every schema write here is: the door
6425
+ * itself rides `_field_or_refuse`, so this is the affordance agreeing with the wall
6426
+ * rather than a second opinion about it.
6427
+ */
6428
+ onEnrich={
6429
+ isUserTable && menuField.type === "ai_enrich"
6430
+ ? async (runScope) => {
6431
+ setColumnMenu(null);
6432
+ const report = await enrichField(scope, menuField.key, runScope);
6433
+ if (!report) return; // apiBridge already said why
6434
+ const filled = report.filled ?? 0;
6435
+ const failed = report.failed ?? 0;
6436
+ const capped =
6437
+ report.limit !== undefined && (report.tokens ?? 0) >= report.limit;
6438
+ signal(
6439
+ TOAST_EVENT,
6440
+ `${filled} filled` +
6441
+ (failed ? `, ${failed} failed` : "") +
6442
+ (capped ? ". The token ceiling was reached, so the rest were left." : ".")
6443
+ );
6444
+ signal(ROWS_STALE_EVENT, scope);
6445
+ }
6446
+ : undefined
6447
+ }
6448
  onDelete={
6449
  menuField.custom && menuField.key !== lockedKey
6450
  ? () => {
 
6775
  </div>
6776
  {addCandidates.length > 50 && (
6777
  <div className="cg-field-hint">
6778
+ Showing the first 50 of {addCandidates.length.toLocaleString()} matches. Keep
6779
  typing to narrow.
6780
  </div>
6781
  )}
web/src/customer-grid/RecordDetail.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/customer-grid/ViewSidebar.tsx CHANGED
@@ -47,7 +47,15 @@ import { ROOT_FOLDER_ID } from "./folders";
47
  // menu vocabulary. The Inbox module's header wears the SAME component (`ui/icons.BellIcon`),
48
  // so the bell that opens the Inbox and the bell that fills it cannot drift apart.
49
  import { BellIcon, StarIcon } from "../ui/icons";
50
- import { SHARED_FOLDER_ID, groupByFolder, reorderFolderIds } from "./folders";
 
 
 
 
 
 
 
 
51
 
52
  /* ─────────────────────────────────────────────────────────────────────────────────────────
53
  ⭐ WAVE 27 Β· OWNER ITEM 1 β€” a MARK on each "Who can edit" row.
@@ -506,7 +514,29 @@ export default function ViewSidebar({
506
  * is not: a surface with folders switched off must still be able to show you somebody
507
  * else's view under a heading that says whose it is.
508
  */
509
- (v) => v.shared === true
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
510
  );
511
  const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
512
  const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null;
@@ -553,6 +583,25 @@ export default function ViewSidebar({
553
  if (!foldersOn) return;
554
  e.preventDefault();
555
  setDropTarget(null);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
556
  const id = e.dataTransfer.getData("text/plain");
557
  // ⭐⭐ W32-T27 (owner item 20) β€” DROPPING ON ROOT SENDS A VALUE, NOT `null`.
558
  //
@@ -574,6 +623,47 @@ export default function ViewSidebar({
574
  const noteView = noteFor
575
  ? views.find((view) => view.id === noteFor.viewId)
576
  : undefined;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
577
 
578
  const create = () => {
579
  const value = name.trim();
@@ -710,35 +800,58 @@ export default function ViewSidebar({
710
  Owner: the database should carry the SUM of its marked views' numbers, whenever at
711
  least one view in it is marked.
712
  β›” HERE, and not in the shell's database header, DELIBERATELY: this rail belongs to
713
- the OPEN database, so a total in it needs no database name to be unambiguous β€” and
714
- the shell header is another session's file. Named honestly ("Important") rather than
715
- floating a bare number, because an unlabelled figure at the top of a list of views
716
- reads as a count OF VIEWS.
717
- ⚠ `counted < marked` is a PARTIAL total β€” some marked view could not be counted (a
718
  windowed grid, an unresolved measure). It says so instead of presenting a short sum
719
- as a whole one, which is D-205's rule one level up. */}
 
 
 
 
 
 
 
 
 
 
 
720
  {importantTotal && (
721
  <span
722
  className={
723
  "cg-views-important" +
724
- (importantTotal.counted < importantTotal.marked ? " is-partial" : "")
725
- }
726
- title={
727
- importantTotal.counted < importantTotal.marked
728
- ? `${importantTotal.sum.toLocaleString()} records across `
729
- + `${importantTotal.counted} of the ${importantTotal.marked} views marked `
730
- + `important here β€” the rest cannot be counted in the browser, so this total `
731
- + `is lower than the real one`
732
- : `${importantTotal.sum.toLocaleString()} records across the `
733
- + `${importantTotal.marked} view${importantTotal.marked === 1 ? "" : "s"} `
734
- + `marked important in this database`
735
  }
736
  >
737
- Important
738
- <span className="cg-view-count">
739
- {importantTotal.sum.toLocaleString()}
740
- {importantTotal.counted < importantTotal.marked ? "+" : ""}
741
- </span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
742
  </span>
743
  )}
744
  </div>
@@ -985,7 +1098,7 @@ export default function ViewSidebar({
985
  // host-side, so say that rather than letting the user think they
986
  // shared it.
987
  <span className="cg-perm-empty">
988
- No other accounts to pick β€” this will save as Personal.
989
  </span>
990
  ) : (
991
  userOptions.map((u) => (
@@ -1079,7 +1192,11 @@ export default function ViewSidebar({
1079
  * server would store. The row menu was already suppressed for this id in wave 20;
1080
  * the DRAG surfaces were not, because nothing ever constructed the group.
1081
  */
1082
- const isSynthetic = gid === SHARED_FOLDER_ID;
 
 
 
 
1083
  const shut = gid != null && collapsed.has(gid);
1084
  return (
1085
  <div
@@ -1101,7 +1218,27 @@ export default function ViewSidebar({
1101
  //
1102
  // The `is-drop-above` line above got this right (`&& foldDrag &&`), which is the
1103
  // tell: two conditions written minutes apart, one guarded and one not.
1104
- (foldDrag !== null && foldDrag === gid ? " is-folddrag" : "")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1105
  }
1106
  {/* No drop handlers on the synthetic group β€” see `isSynthetic`. Spreading `{}`
1107
  rather than branching the element keeps ONE render path for every group. */
@@ -1173,7 +1310,23 @@ export default function ViewSidebar({
1173
  }}
1174
  />
1175
  ) : (
1176
- <span className="cg-fold-name">{group.folder.name}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1177
  )}
1178
  {/* β›” WAVE 20 item 21 β€” THE VIEW COUNT IS DELETED, and the item is not
1179
  really about the number. `.cg-view-more` (the "…" beside this button)
@@ -1196,7 +1349,11 @@ export default function ViewSidebar({
1196
  row the server would refuse is three fake affordances rather than
1197
  one. Views MOVE OUT of it normally (that is per-receiver placement),
1198
  which is the only thing anyone needs to do to it. */}
1199
- {group.folder.id === SHARED_FOLDER_ID ? null : (
 
 
 
 
1200
  <button
1201
  type="button"
1202
  className="cg-view-more"
@@ -1242,8 +1399,13 @@ export default function ViewSidebar({
1242
  : "")
1243
  }
1244
  key={view.id}
 
 
 
 
 
1245
  draggable={(foldersOn || viewsReorderable) && !renaming
1246
- && mayEditView(view, viewer)}
1247
  onDragStart={(e) => {
1248
  // β›” TWO TYPES ON ONE DRAG, and that is the discrimination C7 asks for.
1249
  // `text/plain` is the FILE-INTO-A-FOLDER channel the folder groups already
@@ -1289,7 +1451,7 @@ export default function ViewSidebar({
1289
  // ellipsis at 188px, so the hover is now the only way to read either one in
1290
  // full. It used to show the note INSTEAD of the name, which meant a
1291
  // described view could not have its own truncated name revealed at all.
1292
- title={view.note ? `${view.name} β€” ${view.note}` : view.name}
1293
  >
1294
  {/* I12 β€” the row wears the view's CURRENT display mode, not a kind dot.
1295
  Same geometry the mode switcher and the create flyout use, so "what
@@ -1339,10 +1501,10 @@ export default function ViewSidebar({
1339
  // number is the same fact; the promise attached to it is not.
1340
  title={
1341
  view.config?.important
1342
- ? `${alertCounts[view.id].toLocaleString()} records match this view `
1343
- + `β€” you marked it important`
1344
  : `${alertCounts[view.id].toLocaleString()} records match this `
1345
- + `view β€” you are alerted when a new one arrives`
1346
  }
1347
  >
1348
  {alertCounts[view.id].toLocaleString()}
@@ -1365,7 +1527,15 @@ export default function ViewSidebar({
1365
  title={countNotes[view.id]}
1366
  aria-label={countNotes[view.id]}
1367
  >
1368
- –
 
 
 
 
 
 
 
 
1369
  </span>
1370
  )}
1371
  {/* ⭐ wave17 R1 / C-LOCKV β€” ONE mark, TWO meanings, and they are genuinely
@@ -1406,7 +1576,7 @@ export default function ViewSidebar({
1406
  title={
1407
  (view.owner
1408
  ? `Shared with you by ${view.owner}.`
1409
- : "Shared with you β€” someone gave you access to this view.") +
1410
  (view.sharedRole === "edit"
1411
  ? " You can edit it."
1412
  : " You can view it, not change it.")
@@ -1437,12 +1607,12 @@ export default function ViewSidebar({
1437
  }
1438
  title={
1439
  view.kind === "locked"
1440
- ? "Locked β€” this view shows only the records locked into it. " +
1441
  "Filters, sorts and columns still narrow within them." +
1442
  (isModeFrozen(view)
1443
  ? ` It also stays a ${MODE_LABELS[mode].toLowerCase()}.`
1444
  : "")
1445
- : `Locked β€” this view stays a ${MODE_LABELS[
1446
  mode
1447
  ].toLowerCase()}. Filters, sorts and columns are still editable.`
1448
  }
@@ -1492,7 +1662,7 @@ export default function ViewSidebar({
1492
  );
1493
  })}
1494
  {group.folder && !shut && group.items.length === 0 && (
1495
- <div className="cg-fold-empty">Empty β€” drag a view here.</div>
1496
  )}
1497
  </div>
1498
  );
@@ -1661,7 +1831,7 @@ export default function ViewSidebar({
1661
  setLockFor(null);
1662
  }}
1663
  >
1664
- Unlock β€” show the whole table again
1665
  </button>
1666
  )}
1667
  </AnchoredOverlay>
@@ -1692,7 +1862,7 @@ export default function ViewSidebar({
1692
  Not included:
1693
  {addPreview.skipped.map((sk) => (
1694
  <span key={sk.name} className="cg-fold-skip">
1695
- {sk.name} β€” {sk.why}
1696
  </span>
1697
  ))}
1698
  </div>
@@ -1826,10 +1996,17 @@ export default function ViewSidebar({
1826
  </div>
1827
  </>
1828
  )}
1829
- {/* I17 (C4) β€” the view's IDENTITY (name, description, folder, deletion) is what
1830
- the view permission governs. Duplicate is deliberately NOT gated: making your
1831
- own copy changes nothing about this view, and it is how someone without edit
1832
- rights gets a version they can work in. */}
 
 
 
 
 
 
 
1833
  {canEditMenuView && (
1834
  <button
1835
  type="button"
@@ -2177,7 +2354,20 @@ export default function ViewSidebar({
2177
  {/* Wave-10 item 9 moved the description off the grid and onto the view's row,
2178
  so this sentence had to move with it β€” it described a banner that no longer
2179
  exists. */}
2180
- <span>Shown under this view's name in the list.</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
2181
  <textarea
2182
  data-overlay-autofocus
2183
  value={noteDraft}
 
47
  // menu vocabulary. The Inbox module's header wears the SAME component (`ui/icons.BellIcon`),
48
  // so the bell that opens the Inbox and the bell that fills it cannot drift apart.
49
  import { BellIcon, StarIcon } from "../ui/icons";
50
+ // ⚠ `SHARED_FOLDER_ID` is no longer imported: W34-T25 replaced both of its call sites with
51
+ // `isSyntheticFolderId`, and `noUnusedLocals` turns a leftover import into a build error rather
52
+ // than into dead weight. The constant itself is still exported and still used by `folders.ts`.
53
+ import {
54
+ groupByFolder,
55
+ isSyntheticFolderId,
56
+ mayFileView,
57
+ reorderFolderIds,
58
+ } from "./folders";
59
 
60
  /* ─────────────────────────────────────────────────────────────────────────────────────────
61
  ⭐ WAVE 27 Β· OWNER ITEM 1 β€” a MARK on each "Who can edit" row.
 
514
  * is not: a surface with folders switched off must still be able to show you somebody
515
  * else's view under a heading that says whose it is.
516
  */
517
+ (v) => v.shared === true,
518
+ /**
519
+ * ⭐⭐ WAVE 34 Β· T25 (ruling R11) β€” WHICH shared folder this view arrived through.
520
+ *
521
+ * ⚠ READ STRUCTURALLY, and that is a deliberate, temporary compromise rather than sloppiness.
522
+ * `core/grid_events.py::_granted_views` has stamped `sharedFolder` on every folder-granted
523
+ * view since `D-37`, its three siblings `shared` / `sharedRole` / `owner` are all declared on
524
+ * `SavedView`, and this one never was: a census of `web/src` found ZERO occurrences before
525
+ * this line. `types.ts` is another session's file this wave (`ASK C-1` is out for the
526
+ * one-line declaration), so rather than block R11 on a type, the field is read through a
527
+ * narrow local shape and validated here. β›” When C-1 lands, delete this cast and take the
528
+ * declared field: a private spelling of a wire field is exactly how two layers drift.
529
+ */
530
+ (v) => {
531
+ const via = (v as { sharedFolder?: unknown }).sharedFolder;
532
+ const name = typeof via === "string" ? via.trim() : "";
533
+ const owner = typeof v.owner === "string" ? v.owner.trim() : "";
534
+ // β›” BOTH, and the owner is not optional. Keyed on the NAME alone, two colleagues who each
535
+ // share a folder called "Reports" merge into ONE group whose head then attributes every
536
+ // view in it to whichever of them happens to sort first. `owner` is already stamped on
537
+ // every projected view by `_granted_views`, so this costs nothing on the wire.
538
+ return name && owner ? { name, owner } : null;
539
+ }
540
  );
541
  const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
542
  const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null;
 
583
  if (!foldersOn) return;
584
  e.preventDefault();
585
  setDropTarget(null);
586
+ // ⭐⭐ WAVE 34 Β· T21 (ruling R3) β€” THE ROW STOPS BEING FADED HERE, AND ONLY HERE.
587
+ //
588
+ // Owner: moving a View INTO a folder must not leave the view's icon and row blurred
589
+ // "until i move it around". `viewDrag` is set in the view row's `onDragStart` and was
590
+ // cleared in exactly two places: that row's own `onDragEnd`, and the SIBLING-REORDER
591
+ // drop in `viewDropHandlers`. Filing into a folder is NEITHER β€” it is this handler,
592
+ // which cleared `dropTarget` and never touched `viewDrag`.
593
+ //
594
+ // β›” AND `onDragEnd` CANNOT COVER IT, which is why the fade was permanent rather than
595
+ // one frame long. `onItemMove` below re-parents the row, so React unmounts the node
596
+ // that started the drag and the browser never dispatches `dragend` on a node removed
597
+ // mid-gesture. The state stayed set and the FRESH row rendered wearing
598
+ // `is-viewdrag` (opacity .45) until some later interaction happened to clear it.
599
+ //
600
+ // ⚠ Ordered BEFORE `onItemMove`, and that is free rather than delicate: `onItemMove`
601
+ // is synchronous and React batches both state writes into the same commit, so there
602
+ // is no frame in which the row is re-parented and still faded.
603
+ setViewDrag(null);
604
+ setViewOver(null);
605
  const id = e.dataTransfer.getData("text/plain");
606
  // ⭐⭐ W32-T27 (owner item 20) β€” DROPPING ON ROOT SENDS A VALUE, NOT `null`.
607
  //
 
623
  const noteView = noteFor
624
  ? views.find((view) => view.id === noteFor.viewId)
625
  : undefined;
626
+ /**
627
+ * ⭐⭐ WAVE 34 Β· T20 (R1, contract C1) β€” THE SENTENCE THE BARE NUMBER CANNOT CARRY.
628
+ *
629
+ * R1 drops the word "Important" from this badge, so what is left on screen is a figure with
630
+ * no noun. W33-T16's own note argued the opposite ("an unlabelled figure at the top of a list
631
+ * of views reads as a count OF VIEWS") and it was right about the risk; the owner's ruling
632
+ * settles the pixels, and this is where the meaning goes instead. Hover and screen reader get
633
+ * the whole fact, in one string, on the one element.
634
+ *
635
+ * β›” THE `counted === 0` BRANCH IS D-252, NOT A TIDY-UP. On a fully server-windowed database
636
+ * every marked view returns no number, so `importantTotal` is `{sum: 0, counted: 0, marked: N}`
637
+ * β€” TRUTHY β€” and the rail painted "Important 0+". Drop the word and that becomes a bare "0+",
638
+ * which is strictly worse: a real zero and an unknown are the same three pixels. So a total
639
+ * that could count NOTHING wears the same is-unknown mark a single uncountable view wears
640
+ * (T14), and never a manufactured 0.
641
+ *
642
+ * ⚠ THE CAUSE IS QUOTED, NOT INVENTED. The two reasons a count is missing (a windowed grid, an
643
+ * unresolved measure) produce different sentences and this rail cannot tell them apart, so it
644
+ * reads the FIRST marked view's own `countNotes` entry rather than asserting one of them. No
645
+ * note at all still gets a sentence with a next step, never silence β€” R6's second half.
646
+ */
647
+ let importantNote = "";
648
+ if (importantTotal) {
649
+ const { sum, counted, marked } = importantTotal;
650
+ const nViews = `${marked} view${marked === 1 ? "" : "s"} marked important in this database`;
651
+ if (counted === 0) {
652
+ const cause = views
653
+ .filter((v) => v.config?.important)
654
+ .map((v) => countNotes?.[v.id])
655
+ .find(Boolean);
656
+ importantNote =
657
+ `No number yet for the ${nViews}. `
658
+ + (cause || "Open a marked view to see why its own count is missing.");
659
+ } else if (counted < marked) {
660
+ importantNote =
661
+ `${sum.toLocaleString()} records across ${counted} of the ${nViews}. `
662
+ + `The rest cannot be counted in the browser, so this total is lower than the real one.`;
663
+ } else {
664
+ importantNote = `${sum.toLocaleString()} records across the ${nViews}.`;
665
+ }
666
+ }
667
 
668
  const create = () => {
669
  const value = name.trim();
 
800
  Owner: the database should carry the SUM of its marked views' numbers, whenever at
801
  least one view in it is marked.
802
  β›” HERE, and not in the shell's database header, DELIBERATELY: this rail belongs to
803
+ the OPEN database, so a total in it needs no database name to be unambiguous.
804
+ ⚠ `counted < marked` is a PARTIAL total: some marked view could not be counted (a
 
 
 
805
  windowed grid, an unresolved measure). It says so instead of presenting a short sum
806
+ as a whole one, which is D-205's rule one level up.
807
+
808
+ ⭐⭐ WAVE 34 · T20 (R1) REVERSED THE LABEL DECISION, AND THE REVERSAL IS THE OWNER'S.
809
+ T16 shipped this as the word "Important" plus a badge, on the argument recorded in
810
+ its own note: "an unlabelled figure at the top of a list of views reads as a count OF
811
+ VIEWS". R1 answers that directly, in the owner's words: the count belongs in the
812
+ database NAVIGATION (B's flyout, contract C1), and what stays here is the number
813
+ alone, smaller, with no word. The risk T16 named is real and is answered by
814
+ `importantNote` rather than by a label: the badge carries the whole sentence in its
815
+ `title` AND its `aria-label`, so hover and screen reader both get the noun the pixels
816
+ gave up. β›” Do not re-add the word to satisfy the old note; `verify_grid_ux`'s
817
+ `[VIEWS RAIL]` scan reds on it, with a control that reproduces the regression. */}
818
  {importantTotal && (
819
  <span
820
  className={
821
  "cg-views-important" +
822
+ // ⚠ PARTIAL means "the number you can see is real and short". A total that counted
823
+ // NOTHING has no number to be short, so it must not wear the dashed-purple partial
824
+ // mark as well as the is-unknown one β€” two statements about one absence.
825
+ (importantTotal.counted > 0 && importantTotal.counted < importantTotal.marked
826
+ ? " is-partial"
827
+ : "")
 
 
 
 
 
828
  }
829
  >
830
+ {/* ⚠ `role="img"` ON BOTH, and it is not decoration. An `aria-label` on a plain
831
+ roleless inline `<span>` is not reliably exposed by assistive technology, and
832
+ this badge is now the ONLY carrier of a noun the pixels no longer say. `img` is
833
+ the correct role for "a graphic whose whole meaning is its label", and it also
834
+ stops a screen reader announcing the bare digits as loose text. */}
835
+ {importantTotal.counted === 0 ? (
836
+ <span
837
+ className="cg-view-count is-unknown"
838
+ role="img"
839
+ title={importantNote}
840
+ aria-label={importantNote}
841
+ >
842
+ -
843
+ </span>
844
+ ) : (
845
+ <span
846
+ className="cg-view-count"
847
+ role="img"
848
+ title={importantNote}
849
+ aria-label={importantNote}
850
+ >
851
+ {importantTotal.sum.toLocaleString()}
852
+ {importantTotal.counted < importantTotal.marked ? "+" : ""}
853
+ </span>
854
+ )}
855
  </span>
856
  )}
857
  </div>
 
1098
  // host-side, so say that rather than letting the user think they
1099
  // shared it.
1100
  <span className="cg-perm-empty">
1101
+ No other accounts to pick. This will save as Personal.
1102
  </span>
1103
  ) : (
1104
  userOptions.map((u) => (
 
1192
  * server would store. The row menu was already suppressed for this id in wave 20;
1193
  * the DRAG surfaces were not, because nothing ever constructed the group.
1194
  */
1195
+ // ⭐ W34-T25 (R11): `isSyntheticFolderId`, NOT `=== SHARED_FOLDER_ID`. There is more than
1196
+ // one synthesised group now (one per shared FOLDER), and a rail that suppressed writes on
1197
+ // "Shared with me" while offering them on a colleague's folder would emit `item_move` /
1198
+ // `folder_rename` / `folder_delete` against an id that exists in nobody's store.
1199
+ const isSynthetic = isSyntheticFolderId(gid);
1200
  const shut = gid != null && collapsed.has(gid);
1201
  return (
1202
  <div
 
1218
  //
1219
  // The `is-drop-above` line above got this right (`&& foldDrag &&`), which is the
1220
  // tell: two conditions written minutes apart, one guarded and one not.
1221
+ (foldDrag !== null && foldDrag === gid ? " is-folddrag" : "") +
1222
+ // ⭐⭐ WAVE 34 Β· T22 (ruling R4) β€” THE ROOT SECTION IS A DROP TARGET WITH NO PIXELS.
1223
+ //
1224
+ // β›” THIS IS THE HALF `D-249` DOES NOT NAME, and on its own it reproduces the owner's
1225
+ // whole report. `groupByFolder` ALWAYS pushes the root group, so a user who has filed
1226
+ // every view into folders gets `<div class="cg-fold-root">` with no head, no rows and
1227
+ // no min-height: a ZERO-PIXEL drop zone. The drag starts fine (that is B1's fix) and
1228
+ // there is physically nowhere to release it. Even a POPULATED root is barely better β€”
1229
+ // this element has no padding, so its box is exactly the union of its rows, and each
1230
+ // row's own `onDragOver` calls `stopPropagation` to claim the reorder gesture. The
1231
+ // only surface that ever meant "file at the top level" was the sliver between rows.
1232
+ //
1233
+ // So while a VIEW drag is in flight the root section is given real height and a real
1234
+ // strip below its last row (index.css `.cg-fold-root.is-viewdrop`). The hover paint is
1235
+ // the existing `.is-drop` rule: this ticket supplies the AREA, not a second vocabulary.
1236
+ //
1237
+ // ⚠ `viewDrag !== null` explicitly, never a bare truthiness test on a value that idles
1238
+ // at `null` beside a `gid` that is `null` for this very section β€” that is the
1239
+ // `foldDrag === gid` trap two lines up, which faded the whole ungrouped section
1240
+ // permanently once already. `isRoot` is a separate boolean, so the two cannot collide.
1241
+ (isRoot && viewDrag !== null ? " is-viewdrop" : "")
1242
  }
1243
  {/* No drop handlers on the synthetic group β€” see `isSynthetic`. Spreading `{}`
1244
  rather than branching the element keeps ONE render path for every group. */
 
1310
  }}
1311
  />
1312
  ) : (
1313
+ <span
1314
+ className="cg-fold-name"
1315
+ /* ⭐ W34-T25 (R11) β€” WHOSE folder this is, on the folder itself.
1316
+ A synthesised shared-folder group looks exactly like one of the reader's
1317
+ own folders otherwise, and two people can share folders with the SAME
1318
+ NAME. The owner rides on every projected view (`_granted_views` stamps
1319
+ `owner`), so the group can answer it without a second wire field. Left as
1320
+ a `title` rather than painted into the name: the rail's indent is measured
1321
+ to the pixel and a second line here would push every view row. */
1322
+ title={
1323
+ isSynthetic && group.items[0]?.owner
1324
+ ? `Shared with you by ${group.items[0].owner}`
1325
+ : undefined
1326
+ }
1327
+ >
1328
+ {group.folder.name}
1329
+ </span>
1330
  )}
1331
  {/* β›” WAVE 20 item 21 β€” THE VIEW COUNT IS DELETED, and the item is not
1332
  really about the number. `.cg-view-more` (the "…" beside this button)
 
1349
  row the server would refuse is three fake affordances rather than
1350
  one. Views MOVE OUT of it normally (that is per-receiver placement),
1351
  which is the only thing anyone needs to do to it. */}
1352
+ {/* ⭐ W34-T25 (R11) β€” widened from `=== SHARED_FOLDER_ID` to EVERY synthesised
1353
+ group, for the reason the block above gives: a colleague's shared folder is no
1354
+ more the reader's to rename, duplicate or delete than "Shared with me" is, and
1355
+ every row of that menu would be refused by a server that has no such folder. */}
1356
+ {isSynthetic ? null : (
1357
  <button
1358
  type="button"
1359
  className="cg-view-more"
 
1399
  : "")
1400
  }
1401
  key={view.id}
1402
+ // ⭐⭐ WAVE 34 Β· T22 (ruling R4, `D-249`) β€” `mayFileView`, NOT `mayEditView`.
1403
+ // A read-only shared view could not BEGIN a drag, so it could never be filed OR
1404
+ // taken back out. The full argument, and the two places the SERVER already
1405
+ // disagreed with this line, are on `mayFileView` in `folders.ts` β€” which is where
1406
+ // the predicate lives so `verify_folders.py` can run its truth table.
1407
  draggable={(foldersOn || viewsReorderable) && !renaming
1408
+ && mayFileView(view, mayEditView(view, viewer))}
1409
  onDragStart={(e) => {
1410
  // β›” TWO TYPES ON ONE DRAG, and that is the discrimination C7 asks for.
1411
  // `text/plain` is the FILE-INTO-A-FOLDER channel the folder groups already
 
1451
  // ellipsis at 188px, so the hover is now the only way to read either one in
1452
  // full. It used to show the note INSTEAD of the name, which meant a
1453
  // described view could not have its own truncated name revealed at all.
1454
+ title={view.note ? `${view.name}: ${view.note}` : view.name}
1455
  >
1456
  {/* I12 β€” the row wears the view's CURRENT display mode, not a kind dot.
1457
  Same geometry the mode switcher and the create flyout use, so "what
 
1501
  // number is the same fact; the promise attached to it is not.
1502
  title={
1503
  view.config?.important
1504
+ ? `${alertCounts[view.id].toLocaleString()} records match this view. `
1505
+ + `You marked it important.`
1506
  : `${alertCounts[view.id].toLocaleString()} records match this `
1507
+ + `view. You are alerted when a new one arrives.`
1508
  }
1509
  >
1510
  {alertCounts[view.id].toLocaleString()}
 
1527
  title={countNotes[view.id]}
1528
  aria-label={countNotes[view.id]}
1529
  >
1530
+ {/* ⚠ W34-T26 (R6): an ASCII hyphen, not the EN DASH this shipped with.
1531
+ The en dash is exactly the character the standing rule bans from copy
1532
+ that reaches a screen, and `web_prose` CANNOT SEE IT: its JSX pass
1533
+ requires the text run to contain no newline, and this span has enough
1534
+ attributes that Prettier puts the closing `>` on its own line. Reported
1535
+ to A in mailbox C-3 as a structural hole rather than worked around
1536
+ silently. ⭐ It is also what makes T20's header badge and this one ONE
1537
+ mark rather than two that merely claim to match. */}
1538
+ -
1539
  </span>
1540
  )}
1541
  {/* ⭐ wave17 R1 / C-LOCKV β€” ONE mark, TWO meanings, and they are genuinely
 
1576
  title={
1577
  (view.owner
1578
  ? `Shared with you by ${view.owner}.`
1579
+ : "Shared with you. Someone gave you access to this view.") +
1580
  (view.sharedRole === "edit"
1581
  ? " You can edit it."
1582
  : " You can view it, not change it.")
 
1607
  }
1608
  title={
1609
  view.kind === "locked"
1610
+ ? "Locked: this view shows only the records locked into it. " +
1611
  "Filters, sorts and columns still narrow within them." +
1612
  (isModeFrozen(view)
1613
  ? ` It also stays a ${MODE_LABELS[mode].toLowerCase()}.`
1614
  : "")
1615
+ : `Locked: this view stays a ${MODE_LABELS[
1616
  mode
1617
  ].toLowerCase()}. Filters, sorts and columns are still editable.`
1618
  }
 
1662
  );
1663
  })}
1664
  {group.folder && !shut && group.items.length === 0 && (
1665
+ <div className="cg-fold-empty">Empty. Drag a view here.</div>
1666
  )}
1667
  </div>
1668
  );
 
1831
  setLockFor(null);
1832
  }}
1833
  >
1834
+ Unlock and show the whole table again
1835
  </button>
1836
  )}
1837
  </AnchoredOverlay>
 
1862
  Not included:
1863
  {addPreview.skipped.map((sk) => (
1864
  <span key={sk.name} className="cg-fold-skip">
1865
+ {sk.name}: {sk.why}
1866
  </span>
1867
  ))}
1868
  </div>
 
1996
  </div>
1997
  </>
1998
  )}
1999
+ {/* I17 (C4) β€” the view's IDENTITY (name, description, deletion) is what the view
2000
+ permission governs. Duplicate is deliberately NOT gated: making your own copy
2001
+ changes nothing about this view, and it is how someone without edit rights gets a
2002
+ version they can work in.
2003
+ β›” W34-T22 REMOVED "folder" FROM THAT LIST, and the removal is the point rather
2004
+ than a tidy-up. Filing a view writes a PLACEMENT into the reader's own workspace
2005
+ stratum and never touches the owner's object, so it is navigation rather than an
2006
+ identity edit; the drag site asks `mayFileView` now (`folders.ts`) while every row
2007
+ below still asks `mayEditView`. Left as a correction rather than deleted, because
2008
+ a sentence three hundred lines from the code it describes is exactly the shape that
2009
+ went stale unnoticed here. */}
2010
  {canEditMenuView && (
2011
  <button
2012
  type="button"
 
2354
  {/* Wave-10 item 9 moved the description off the grid and onto the view's row,
2355
  so this sentence had to move with it β€” it described a banner that no longer
2356
  exists. */}
2357
+ {/* ⚠ W34-T26 β€” "the view name", not "this view's name", and the apostrophe is the
2358
+ whole reason. `web_prose`'s TypeScript walk has no parser: an apostrophe in JSX
2359
+ TEXT opens a single-quoted string it never closes, so the walk ran from here to
2360
+ the end of this region, swallowing ~37 lines (this span's tail, the <textarea>
2361
+ and its attributes, two <button>s and a `//` comment). It then reported the em
2362
+ dash inside that swallowed COMMENT as a finding on this line: mislocated, and a
2363
+ false positive by the gate's own contract, since comments are exempt.
2364
+ β›” THE POINT OF THIS EDIT IS NOT THE GREEN. A walker that believes it is inside a
2365
+ string also SKIPS every real string after it, so those 37 lines were unscanned;
2366
+ removing the apostrophe restores the gate's sight over them. The underlying defect
2367
+ is `verify_prose.py`'s and is reported to A (post C-3; lane B raised the same
2368
+ thing as B-3, measuring 248 lines lost in one file). Do NOT rewrite the COMMENTS
2369
+ it reports to silence it, which is the version of this that destroys reasoning. */}
2370
+ <span>Shown under the view name in the list.</span>
2371
  <textarea
2372
  data-overlay-autofocus
2373
  value={noteDraft}
web/src/customer-grid/apiBridge.ts CHANGED
@@ -1401,3 +1401,60 @@ function standaloneSink(event: HostEvent): boolean {
1401
  export function installStandaloneBridge(): void {
1402
  setStandaloneSink(standaloneSink);
1403
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1401
  export function installStandaloneBridge(): void {
1402
  setStandaloneSink(standaloneSink);
1403
  }
1404
+
1405
+ /**
1406
+ * ⭐⭐ WAVE 34 (owner ruling R13, `W34-T54`) β€” RUN AN `ai_enrich` COLUMN OVER A SCOPE.
1407
+ *
1408
+ * `POST /api/v1/tables/{key}/fields/{fkey}/enrich`, body `{scope: "blank" | "always"}`.
1409
+ * `blank` fills only cells nothing has written; `always` re-runs every row.
1410
+ *
1411
+ * β›” ADDED BY THE INTEGRATOR AT CLOSE-OUT, and the reason is worth stating rather than burying.
1412
+ * Lane F built the server door, the menu row and the field kind, and left the host call site as an
1413
+ * OPTIONAL prop with the note *"nothing breaks if you decline"*. That is true of the BUILD and
1414
+ * false of the FEATURE: this is the only path that makes the bulk run reachable, and `W34-T54`'s
1415
+ * own `done-when` reads *"the bulk menu offers at least all rows and rows never filled"*. Left
1416
+ * unwired, that ticket would have been BUILT with its acceptance test unreachable β€” the exact
1417
+ * shape this repo has shipped five times ([[mounted-is-not-callable]]).
1418
+ *
1419
+ * ⚠ IT SPENDS MONEY, so it reports rather than returns a boolean. The server answers a RUN REPORT
1420
+ * (`filled`, `failed`, `skipped` by reason, `tokens`, `limit`) and the caller shows the sentence:
1421
+ * a bulk enrichment that quietly did 3 of 900 rows because a ceiling bit is precisely the silent
1422
+ * cap R6's standing rule forbids, and a `true` would hide it.
1423
+ */
1424
+ export async function enrichField(
1425
+ tableKey: string,
1426
+ fieldKey: string,
1427
+ scope: "blank" | "always"
1428
+ ): Promise<EnrichReport | null> {
1429
+ try {
1430
+ const res = await fetch(
1431
+ `${API_V1}/tables/${encodeURIComponent(tableKey)}/fields/${encodeURIComponent(fieldKey)}/enrich`,
1432
+ {
1433
+ method: "POST",
1434
+ credentials: CREDENTIALS,
1435
+ headers: JSON_HEADERS,
1436
+ body: JSON.stringify({ scope }),
1437
+ }
1438
+ );
1439
+ if (handledSession(res)) return null;
1440
+ const body = (await readJson(res)) as (EnrichReport & { error?: { message?: string } }) | null;
1441
+ if (!res.ok) {
1442
+ // The server states WHY (no key, a token ceiling, a locked column). Its sentence beats ours.
1443
+ signal(TOAST_EVENT, refusalMessage(body, res.status));
1444
+ return null;
1445
+ }
1446
+ return body ?? null;
1447
+ } catch {
1448
+ signal(DATA_ERROR_EVENT, "The enrichment run could not be reached. Nothing was changed.");
1449
+ return null;
1450
+ }
1451
+ }
1452
+
1453
+ /** What `POST .../enrich` answers. Shapes the sentence the caller shows. */
1454
+ export type EnrichReport = {
1455
+ filled?: number;
1456
+ failed?: number;
1457
+ skipped?: Record<string, number>;
1458
+ tokens?: number;
1459
+ limit?: number;
1460
+ };
web/src/customer-grid/cells.ts CHANGED
@@ -610,6 +610,43 @@ function baseCell(
610
  allowOverlay: false,
611
  };
612
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
613
  case "rating": {
614
  const max = ratingMax(field);
615
  const n = Math.max(0, Math.min(max, Math.round(num(v))));
 
610
  allowOverlay: false,
611
  };
612
  }
613
+ case "ai_enrich": {
614
+ // ⭐⭐ Wave-34 (owner ruling R13) β€” the AI ENRICHMENT cell. The value is an ordinary stored
615
+ // string, so this is a Text cell; what it is NOT is a `formula` (recomputed in the browser)
616
+ // or a `rollup` (read through from another table). Nobody has to be told a number came from
617
+ // somewhere else here, because the answer is words.
618
+ //
619
+ // Written the same way `code` and `json` are, and for their reason rather than a new one:
620
+ // Β· `allowOverlay: false` β€” glide's ONE-LINE overlay editor over multi-line prose is an
621
+ // editor that can only damage the value. The place a person edits an answer is the
622
+ // record drawer, which shows the whole thing.
623
+ // Β· `readonly` deliberately NOT set, so the paste path that legitimately writes a whole
624
+ // answer still works.
625
+ // Β· `copyData` the RAW text, never the preview, or copying this column would yield
626
+ // "Ann is a florist +2 more", which is not data and cannot be pasted back. That is the
627
+ // lesson the image cell booked in wave 19 and the date cell repeated in wave 26.
628
+ //
629
+ // β›” EDITING IS THE POINT, NOT AN AFTERTHOUGHT, which is why the column is NOT in
630
+ // `READONLY_CELL_TYPES` and is NOT in `is_computed_cell` server-side: typing over a cell is
631
+ // what marks it the human's, and `core.user_tables.ai_enrich_human_authored` then protects
632
+ // it from every automatic run forever.
633
+ //
634
+ // ⚠ OWED, AND NAMED RATHER THAN FAKED: this branch cannot paint the agent-written /
635
+ // human-edited / stale / errored STATE, because `makeCell(field, v, editable, avatars)` is
636
+ // handed the value and never the row, and the per-cell mark lives in a stratum the row
637
+ // payload does not carry. The drawer shows the state; the canvas will once the call site in
638
+ // `CustomerGrid.tsx` (lane C's file) passes it. Rendering a guessed state here would be
639
+ // worse than none: "done" on a cell nothing has run for is a claim.
640
+ const raw = String(v ?? "");
641
+ const text = codePreview(raw);
642
+ return {
643
+ kind: GridCellKind.Text,
644
+ data: text,
645
+ displayData: text,
646
+ copyData: raw,
647
+ allowOverlay: false,
648
+ };
649
+ }
650
  case "rating": {
651
  const max = ratingMax(field);
652
  const n = Math.max(0, Math.min(max, Math.round(num(v))));
web/src/customer-grid/display.ts CHANGED
@@ -675,6 +675,16 @@ export function formatDisplay(field: Field, v: CellValue): string {
675
  // for `json`'s exact reason: a snippet is multi-LINE, and falling through to `default`
676
  // would put raw newlines into a CSV cell, a kanban card and a calendar chip.
677
  return codePreview(v);
 
 
 
 
 
 
 
 
 
 
678
  case "automation":
679
  // The machine-written line, verbatim. Explicit rather than left to the `default` branch
680
  // below: `formula` fell through a default once and printed every text result as `0` for
 
675
  // for `json`'s exact reason: a snippet is multi-LINE, and falling through to `default`
676
  // would put raw newlines into a CSV cell, a kanban card and a calendar chip.
677
  return codePreview(v);
678
+ case "ai_enrich":
679
+ // ⭐⭐ Wave-34 (owner ruling R13) β€” an enrichment answer is model-authored PROSE and can run
680
+ // to several lines, so it is explicit here for exactly the reason `json` and `code` are:
681
+ // this function feeds ListView, KanbanView, the calendar, the record panel and all four
682
+ // EXPORT formats, and `default` would put raw newlines into a CSV cell.
683
+ // ⚠ `codePreview` is REUSED rather than given a prose-flavoured twin. Its "first line
684
+ // +N more" is already this product's one way of saying "a multi-line value in a one-line
685
+ // slot", and the count is checkable against what the drawer shows; a second helper would
686
+ // be a second answer to one question ([[one-question-two-normalizers]]).
687
+ return codePreview(v);
688
  case "automation":
689
  // The machine-written line, verbatim. Explicit rather than left to the `default` branch
690
  // below: `formula` fell through a default once and printed every text result as `0` for
web/src/customer-grid/folders.ts CHANGED
@@ -239,6 +239,62 @@ export interface FolderGroup<T> {
239
  export const SHARED_FOLDER_ID = "__shared__";
240
  export const SHARED_FOLDER_NAME = "Shared with me";
241
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
242
  /**
243
  * ⭐⭐ WAVE 32 Β· T27 (owner item 20) β€” **"FILED AT ROOT", AS A VALUE.**
244
  *
@@ -293,11 +349,26 @@ export function groupByFolder<T>(
293
  items: T[],
294
  folders: GridFolder[],
295
  folderIdOf: (item: T) => string | null,
296
- isShared?: (item: T) => boolean
 
 
 
 
 
 
 
 
 
 
 
 
 
297
  ): FolderGroup<T>[] {
298
  const buckets = new Map<string, T[]>(folders.map((f) => [f.id, []]));
299
  const root: T[] = [];
300
  const shared: T[] = [];
 
 
301
  for (const item of items) {
302
  const id = folderIdOf(item);
303
  const bucket = id == null ? undefined : buckets.get(id);
@@ -308,13 +379,38 @@ export function groupByFolder<T>(
308
  // value, `isShared` won, and a shared view dragged to root returned to "Shared with me" on
309
  // the next render β€” the owner's item 20, in one branch.
310
  else if (id === ROOT_FOLDER_ID) root.push(item);
311
- else if (isShared?.(item)) shared.push(item);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
312
  else root.push(item);
313
  }
314
  const out: FolderGroup<T>[] = folders.map((f) => ({ folder: f, items: buckets.get(f.id) ?? [] }));
315
  out.push({ folder: null, items: root });
316
  if (shared.length)
317
  out.push({ folder: { id: SHARED_FOLDER_ID, name: SHARED_FOLDER_NAME }, items: shared });
 
 
 
 
 
 
 
 
 
318
  return out;
319
  }
320
 
@@ -356,6 +452,36 @@ export function reorderFolderIds(
356
  return next;
357
  }
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  /** A fresh folder id. Client-generated, like every other id in this component. */
360
  export function newFolderId(): string {
361
  const rand =
 
239
  export const SHARED_FOLDER_ID = "__shared__";
240
  export const SHARED_FOLDER_NAME = "Shared with me";
241
 
242
+ /**
243
+ * ⭐⭐ WAVE 34 Β· T25 (ruling R11) β€” A SHARED **FOLDER** GETS ITS OWN GROUP, under this prefix.
244
+ *
245
+ * R11: *"A folder (with the Views inside it) can be shared with a team member."* Everything below
246
+ * the client already worked and was VERIFIED before this was written: `core/shares.py::KINDS` has
247
+ * carried `'folder'` since wave 20, the rail already offers "Share folder", and
248
+ * `grid_events.py::_granted_views` has projected each granted folder's views since `D-37`. What
249
+ * did not work is the last inch: every shared view landed in ONE flat "Shared with me" group, so
250
+ * the receiver got loose views and **the folder itself was invisible**.
251
+ *
252
+ * ⭐ The name has been on the wire the whole time. `_granted_views` stamps
253
+ * `v['sharedFolder'] = <folder name>` with the comment *"so the client can group these together
254
+ * later"*, and a census found ZERO readers of it anywhere in `web/src` β€” a flag shipped without
255
+ * its reader. This is the reader.
256
+ *
257
+ * ⚠ A PREFIX RATHER THAN ONE RESERVED ID, because there can be several: two colleagues can each
258
+ * share a folder with you. {@link isSyntheticFolderId} is what every affordance must ask instead
259
+ * of comparing against `SHARED_FOLDER_ID` β€” a rail that suppressed actions on one synthetic group
260
+ * and offered them on another would emit `item_move`/`folder_rename` against a folder id that
261
+ * exists in nobody's store.
262
+ *
263
+ * β›”β›” THE KEY IS **OWNER + NAME**, NOT NAME, AND THE FIRST DRAFT OF THIS FUNCTION GOT IT WRONG.
264
+ * It keyed on the folder name alone while the comment right here claimed to handle two colleagues
265
+ * sharing folders with the SAME NAME β€” so those two folders merged into ONE group, and the head's
266
+ * `title` (which reads the first item's `owner`) misattributed every view from the second sharer.
267
+ * Per-view `sharedRole` was never affected, so nothing leaked; what broke was the rail telling you
268
+ * whose work you were reading, which is the entire point of the group. **A comment that describes
269
+ * the case the code does not handle is worse than no comment**, because it is what a reviewer
270
+ * checks against. Found by this ticket's own verifier reading the two side by side.
271
+ *
272
+ * β›” NOTHING MAY EVER WRITE ONE. Like `SHARED_FOLDER_ID` these ids are a READING of the view list,
273
+ * not a record: no `order`, no icon, no row in `folders`.
274
+ */
275
+ export const SHARED_FOLDER_PREFIX = "__sharedfld__:";
276
+
277
+ /**
278
+ * The synthetic group id for a folder somebody shared with you.
279
+ *
280
+ * ⚠ `owner` FIRST and separated by a character usernames cannot contain. `core/users.py` keeps
281
+ * usernames lower-case and non-empty; a folder NAME is free text and can contain anything a user
282
+ * types, `:` included. Putting the free-text half last means no pair of (owner, name) can spell
283
+ * another pair's id, whatever somebody calls a folder.
284
+ */
285
+ export function sharedFolderGroupId(owner: string, name: string): string {
286
+ return `${SHARED_FOLDER_PREFIX}${owner}:${name}`;
287
+ }
288
+
289
+ /**
290
+ * Is this group id SYNTHESISED rather than stored? True for "Shared with me" and for every
291
+ * shared-folder group. Every drag, drop, rename and delete affordance in the rail asks this.
292
+ */
293
+ export function isSyntheticFolderId(id: string | null | undefined): boolean {
294
+ return id === SHARED_FOLDER_ID
295
+ || (typeof id === "string" && id.startsWith(SHARED_FOLDER_PREFIX));
296
+ }
297
+
298
  /**
299
  * ⭐⭐ WAVE 32 Β· T27 (owner item 20) β€” **"FILED AT ROOT", AS A VALUE.**
300
  *
 
349
  items: T[],
350
  folders: GridFolder[],
351
  folderIdOf: (item: T) => string | null,
352
+ isShared?: (item: T) => boolean,
353
+ /**
354
+ * ⭐ WAVE 34 Β· T25 (R11) β€” the folder a shared item arrived through, if it did.
355
+ *
356
+ * Returns the folder's NAME and its OWNER, because the pair is the identity: the wire carries
357
+ * no folder id for a grant (`_granted_views` stamps a name and an owner, never an id), and the
358
+ * name alone is not unique across sharers.
359
+ *
360
+ * Optional, so every existing caller (the cohort rail, the tests) keeps the pre-T25 shape
361
+ * byte-for-byte: with it omitted, every shared item falls into the flat "Shared with me"
362
+ * group exactly as before. Supplied, the shared views SPLIT by the folder that carried them,
363
+ * and the folder finally appears on the receiver's screen.
364
+ */
365
+ sharedFolderOf?: (item: T) => { name: string; owner: string } | null
366
  ): FolderGroup<T>[] {
367
  const buckets = new Map<string, T[]>(folders.map((f) => [f.id, []]));
368
  const root: T[] = [];
369
  const shared: T[] = [];
370
+ /** synthetic group id -> {name, views}. Keyed by OWNER+NAME; see `SHARED_FOLDER_PREFIX`. */
371
+ const sharedFolders = new Map<string, { name: string; items: T[] }>();
372
  for (const item of items) {
373
  const id = folderIdOf(item);
374
  const bucket = id == null ? undefined : buckets.get(id);
 
379
  // value, `isShared` won, and a shared view dragged to root returned to "Shared with me" on
380
  // the next render β€” the owner's item 20, in one branch.
381
  else if (id === ROOT_FOLDER_ID) root.push(item);
382
+ else if (isShared?.(item)) {
383
+ // ⭐ W34-T25 (R11): a shared view that arrived through a FOLDER goes to that folder's own
384
+ // group; one that was granted directly still goes to the flat "Shared with me". The two
385
+ // are different facts and the receiver can see which is which.
386
+ const via = sharedFolderOf?.(item);
387
+ const name = via ? String(via.name || "").trim() : "";
388
+ const owner = via ? String(via.owner || "").trim() : "";
389
+ // ⚠ BOTH halves required. A folder name with no owner cannot be told apart from another
390
+ // sharer's folder of the same name, so it falls back to the flat group rather than
391
+ // inventing a group that might merge two people's work under one person's label.
392
+ if (name && owner) {
393
+ const key = sharedFolderGroupId(owner, name);
394
+ const bucket2 = sharedFolders.get(key);
395
+ if (bucket2) bucket2.items.push(item);
396
+ else sharedFolders.set(key, { name, items: [item] });
397
+ } else shared.push(item);
398
+ }
399
  else root.push(item);
400
  }
401
  const out: FolderGroup<T>[] = folders.map((f) => ({ folder: f, items: buckets.get(f.id) ?? [] }));
402
  out.push({ folder: null, items: root });
403
  if (shared.length)
404
  out.push({ folder: { id: SHARED_FOLDER_ID, name: SHARED_FOLDER_NAME }, items: shared });
405
+ // ⚠ SORTED, not first-seen. A Map preserves insertion order, which here is the order the VIEWS
406
+ // happen to arrive in β€” so one view moving could reorder whole folders in the rail for no reason
407
+ // a reader could name. Sorting on the composite ID (owner first) is stable across every payload
408
+ // AND groups one colleague's folders together, which reads better than interleaving two people's
409
+ // folders alphabetically by name.
410
+ for (const key of [...sharedFolders.keys()].sort((a, b) => a.localeCompare(b))) {
411
+ const grp = sharedFolders.get(key)!;
412
+ out.push({ folder: { id: key, name: grp.name }, items: grp.items });
413
+ }
414
  return out;
415
  }
416
 
 
452
  return next;
453
  }
454
 
455
+ /**
456
+ * ⭐⭐ WAVE 34 Β· T22 (ruling R4, `D-249`) β€” MAY THIS VIEWER FILE THIS VIEW INTO A FOLDER?
457
+ *
458
+ * β›” NOT THE SAME QUESTION AS `mayEditView`, AND CONFLATING THEM IS THE DEFECT. The rail gated its
459
+ * `draggable` on `mayEditView`, which fail-closes on a shared view whose role is not `edit` β€” so a
460
+ * read-only shared view could not BEGIN a drag, and the owner's report is that a view cannot be
461
+ * taken out of a folder. Filing is not a content edit: it writes a PLACEMENT into the receiver's
462
+ * own workspace stratum and never touches the owner's object.
463
+ *
464
+ * ⭐ THE SERVER ALREADY AGREED, IN TWO PLACES, BEFORE THIS FUNCTION EXISTED. `grid_events.py::
465
+ * table_workspace` merges granted views into `ws['views']`, which is the very set `item_move`'s
466
+ * own-ids test reads β€” so the write this drag produces is ACCEPTED today. And the `view_reorder`
467
+ * branch beside it carries the rule in prose: *"NO OWNERSHIP FILTER … refusing ids they do not own
468
+ * would make exactly those un-draggable, which is the half of sharing people notice."* The client
469
+ * was the only refuser, and it was refusing a write the server was ready to take.
470
+ *
471
+ * ⚠ `mayEdit` IS A PARAMETER, NOT AN IMPORT, and that is deliberate: this module is
472
+ * "pure and React-free so verify_folders.py can run it under node" (see the header), and its only
473
+ * imports today are one constant and one type. Taking the verdict rather than the resolver keeps
474
+ * the truth table testable in isolation and keeps this file's dependency surface where its own
475
+ * header promised it would stay.
476
+ *
477
+ * ⚠ USE IT ONLY AT THE DRAG SITE. Every other affordance the rail gates on `mayEditView` β€” rename,
478
+ * description, delete, permissions β€” really is a content edit on somebody else's object, and the
479
+ * server really does refuse those.
480
+ */
481
+ export function mayFileView(view: { shared?: boolean }, mayEdit: boolean): boolean {
482
+ return view.shared === true || mayEdit;
483
+ }
484
+
485
  /** A fresh folder id. Client-generated, like every other id in this component. */
486
  export function newFolderId(): string {
487
  const rand =
web/src/customer-grid/iconShapes.ts CHANGED
@@ -159,6 +159,18 @@ export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
159
  { d: "M4.8 8h6.4" },
160
  { d: "M6.6 11.6h2.8" },
161
  ],
 
 
 
 
 
 
 
 
 
 
 
 
162
  };
163
 
164
  /**
@@ -617,6 +629,10 @@ export const TYPE_LABELS: Record<FieldType, string> = {
617
  // is called everywhere else in this wave (the ruling, the language picker, the viewer header),
618
  // and it says what the column holds without implying the product will run it.
619
  code: "Code",
 
 
 
 
620
  status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
621
  };
622
 
 
159
  { d: "M4.8 8h6.4" },
160
  { d: "M6.6 11.6h2.8" },
161
  ],
162
+ // ⭐ WAVE 34 Β· T53 (R13), drawn by C on F's ask (`F-2`) β€” AI ENRICHMENT.
163
+ // The SPARKLE, because it is the product's own AI mark already: `Shell.tsx::SparkIcon` wears it
164
+ // on the Assistant rail row, so a column the AI fills reads as the same family rather than as a
165
+ // second vocabulary for one idea. Deliberately NOT the `automation` cycle-ring two entries up
166
+ // (that means "a job that runs, repeatedly", and an enrichment can be manual) and not a robot
167
+ // head (`Shell.tsx::RobotIcon` claims that for the Agents MODULE; a field is not a module).
168
+ // ⚠ TWO stars, not one: a lone four-point star at 16px with a 1.35 stroke reads as a plus sign.
169
+ // The small companion is what makes the mark say "sparkle".
170
+ ai_enrich: [
171
+ { d: "M6.6 2.6l1.3 3.1 3.1 1.3-3.1 1.3-1.3 3.1-1.3-3.1L2.2 7l3.1-1.3z" },
172
+ { d: "M11.9 9.8l.7 1.6 1.6.7-1.6.7-.7 1.6-.7-1.6-1.6-.7 1.6-.7z" },
173
+ ],
174
  };
175
 
176
  /**
 
629
  // is called everywhere else in this wave (the ruling, the language picker, the viewer header),
630
  // and it says what the column holds without implying the product will run it.
631
  code: "Code",
632
+ // ⭐ WAVE 34 Β· T53 (R13), on F's ask (`F-2`) β€” the owner's own noun for the kind: "a field kind
633
+ // called AI enrichment". Not "AI field" (every field in an AI-built view would qualify) and not
634
+ // "Generate" (that names the verb, and the column's value is the point, not the act).
635
+ ai_enrich: "AI enrichment",
636
  status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
637
  };
638
 
web/src/customer-grid/types.ts CHANGED
@@ -76,7 +76,31 @@ export type FieldType =
76
  // a per-column `language` that selects a HIGHLIGHTER and nothing else. R13: "syntax-highlighted
77
  // storage + language config, NO execution engine" β€” no value of `language` may ever reach a
78
  // run path, and there is none to reach.
79
- | "code";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
 
81
  export type FieldSource = "odoo" | "overlay";
82
 
@@ -163,6 +187,18 @@ export const CREATABLE_TYPES: readonly FieldType[] = [
163
  // declared language stores and renders as plain text, which is a legitimate state. So
164
  // `ColumnMenu.canCreate` does not gate on it, and the language picker is an ordinary edit.
165
  "code",
 
 
 
 
 
 
 
 
 
 
 
 
166
  ];
167
 
168
  /**
@@ -1798,6 +1834,32 @@ export interface Field {
1798
  * `isMachineOwned` β€” a person types here, so it must NOT take the machine wash.
1799
  */
1800
  profile?: { source?: string };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1801
  /**
1802
  * ⭐ 2026-08-07 β€” a LINK to rows of another user database (Airtable's `multipleRecordLinks`).
1803
  * The cell holds a COMMA-JOINED list of linked row ids, because a `Row` value is a scalar
 
76
  // a per-column `language` that selects a HIGHLIGHTER and nothing else. R13: "syntax-highlighted
77
  // storage + language config, NO execution engine" β€” no value of `language` may ever reach a
78
  // run path, and there is none to reach.
79
+ | "code"
80
+ // ⭐⭐ WAVE-34 (owner ruling R13) β€” AI ENRICHMENT: a prompt per row that populates text. The
81
+ // cell is an ordinary stored STRING, which is what separates it from `formula` (recomputed in
82
+ // the browser on every paint) and `rollup` (read through from another table). A person may type
83
+ // over it, and doing so permanently protects that cell from every automatic run, so it is
84
+ // deliberately NOT in `READONLY_CELL_TYPES`. Its config lives in `Field.aiEnrich`.
85
+ | "ai_enrich";
86
+
87
+ /**
88
+ * ⭐⭐ WAVE-34 (owner ruling R13) β€” WHOSE VALUE IS IN THIS ENRICHMENT CELL.
89
+ *
90
+ * A row carries `_ai_<colId>` for every `ai_enrich` column that has a state to report, stamped by
91
+ * `routes_tables._stamp_ai_states` beside the `_created` / `lat` / `lon` keys the row already
92
+ * carries. An ABSENT key means nothing has run for that cell.
93
+ *
94
+ * ⚠ Declared HERE, once, because two client surfaces read it (the record drawer today, the grid
95
+ * cell when its call site can pass a row) and a prefix spelled twice is a prefix that will be
96
+ * spelled differently once. The server holds the matching constant in `routes_tables.py`.
97
+ * β›” Collision-proof by construction: `_clean_field` strips leading underscores off every field
98
+ * key, so no column can ever be named `_ai_*`.
99
+ */
100
+ export const AI_STATE_PREFIX = "_ai_";
101
+
102
+ /** What `_ai_<colId>` can say. `empty` is never stamped: an absent key IS empty. */
103
+ export type AiCellState = "agent" | "human" | "stale" | "error";
104
 
105
  export type FieldSource = "odoo" | "overlay";
106
 
 
187
  // declared language stores and renders as plain text, which is a legitimate state. So
188
  // `ColumnMenu.canCreate` does not gate on it, and the language picker is an ordinary edit.
189
  "code",
190
+ // ⭐⭐ WAVE-34 (owner ruling R13) β€” AI enrichment, landing in the SAME change as `FieldType`,
191
+ // `UT_FIELD_TYPES`, `CUSTOM_FIELD_TYPES`, the shape/label tables and the cell renderer, for the
192
+ // reason the three comments above already give in their own words. ⚠ THE WAVE PLANNED IT AS TWO
193
+ // TICKETS (server in `W34-T51`, client in `W34-T53`) AND THAT SPLIT IS NOT BUILDABLE: three
194
+ // parity gates chain over these sets and none permits a partial landing. `api_api` went
195
+ // 969/969 to 968/969 on the server half alone, measured by another lane on the shared tree.
196
+ //
197
+ // β›” LIKE `link`/`rollup` AND UNLIKE `code`, IT REQUIRES ITS CONFIG BAG: `_clean_field` returns
198
+ // None for an `ai_enrich` column with no `aiEnrich` bag, because a prompt is the only thing
199
+ // that can ever produce a value here. So `ColumnMenu.canCreate` gates on it, or the create
200
+ // button is the "created, named, configured, gone" failure arriving one door earlier.
201
+ "ai_enrich",
202
  ];
203
 
204
  /**
 
1834
  * `isMachineOwned` β€” a person types here, so it must NOT take the machine wash.
1835
  */
1836
  profile?: { source?: string };
1837
+ /**
1838
+ * ⭐⭐ WAVE-34 (owner ruling R13) β€” the AI ENRICHMENT column's config: a prompt per row that
1839
+ * populates text, with the token ceiling in the field's own configuration.
1840
+ *
1841
+ * `prompt` may name other columns with `{key}` tokens, the same brace syntax a formula uses, so
1842
+ * somebody who has written one already knows this one. `maxTokens` bounds ONE call's output and
1843
+ * is also what the run's whole budget is derived from. `trigger.cron` is present only when
1844
+ * `mode` is `"schedule"` β€” a cadence stored under `manual` is a setting nothing reads.
1845
+ * `overwrite` decides what an automatic run may replace among the cells THE AGENT ITSELF wrote;
1846
+ * a human-edited cell is never overwritten by any of them, which is why `"human"` is not one of
1847
+ * the values (`core.user_tables.ai_enrich_human_authored` is the law).
1848
+ *
1849
+ * ⚠ THE BAG IS HERE WHILE `"ai_enrich"` IS NOT YET IN `CREATABLE_TYPES`, and the asymmetry is
1850
+ * deliberate: the server stores this key today (`W34-T51`) and `verify_grid_ux::c_field_parity`
1851
+ * tests server->client, so the interface has to mirror it now. The add-field MENU arrives with
1852
+ * `W34-T53`. A kind the server accepts and the client does not offer is invisible; a kind the
1853
+ * client offers and the server refuses deletes the user's column, which is the direction that
1854
+ * shipped as a live defect for three waves (`image`, wave 19).
1855
+ */
1856
+ aiEnrich?: {
1857
+ prompt: string;
1858
+ model?: string;
1859
+ maxTokens: number;
1860
+ trigger: { mode: "manual" | "on_change" | "schedule"; cron?: string };
1861
+ overwrite: "blank" | "stale" | "always";
1862
+ };
1863
  /**
1864
  * ⭐ 2026-08-07 β€” a LINK to rows of another user database (Airtable's `multipleRecordLinks`).
1865
  * The cell holds a COMMA-JOINED list of linked row ids, because a `Row` value is a scalar
web/src/filter-kit/FieldsHidePanel.tsx CHANGED
@@ -200,7 +200,7 @@ export function FieldsHidePanel({
200
  locked
201
  ? `${fieldLabel(f)} is always first`
202
  : canReorder
203
- ? `Reorder ${fieldLabel(f)} β€” drag, or use the arrow keys`
204
  : `Reorder ${fieldLabel(f)} (clear the search first)`
205
  }
206
  title={
 
200
  locked
201
  ? `${fieldLabel(f)} is always first`
202
  : canReorder
203
+ ? `Reorder ${fieldLabel(f)}: drag, or use the arrow keys`
204
  : `Reorder ${fieldLabel(f)} (clear the search first)`
205
  }
206
  title={
web/src/filter-kit/FilterBuilderPanel.tsx CHANGED
@@ -1,988 +1,1003 @@
1
- // ---------------------------------------------------------------------------
2
- // filter-kit / FilterBuilderPanel.tsx
3
- // The Airtable-style RECURSIVE condition builder, as a MOUNTABLE PANEL.
4
- //
5
- // Extracted from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so the admin
6
- // permission editor and the grid toolbar build filters with the SAME control
7
- // rather than two that drift. Behaviour is identical to the toolbar's β€” this is a
8
- // move, not a rewrite.
9
- //
10
- // THE KIT FETCHES NOTHING. Every vocabulary it needs (fields, cohorts, measures,
11
- // the people a `user` field may name) arrives as a prop, which is what lets the
12
- // same panel serve a module whose rows this browser has never loaded.
13
- // ---------------------------------------------------------------------------
14
-
15
- import { useCallback, useMemo, useState } from "react";
16
- import type { ReactNode } from "react";
17
- import type {
18
- Conjunction,
19
- Field,
20
- FilterGroup,
21
- FilterNode,
22
- FilterOp,
23
- FilterRhs,
24
- FilterRule,
25
- FilterTree,
26
- Measure,
27
- } from "../customer-grid/types";
28
- import {
29
- COHORT_FIELD, MAX_COHORT_IDS, MAX_FILTER_DEPTH, RANK_OPS, STAT_KINDS, STAT_LABELS,
30
- VALUE_FREE_OPS, choiceOptions, cohortIds, filterTreeConj, filterTreeNodes,
31
- filterableFields, isFilterGroup, isMeasureRule, isRankOp, normalizeCohortOp, rhsColId,
32
- } from "../customer-grid/types";
33
- import type { CohortOp, StatKind } from "../customer-grid/types";
34
- import type { AnchorMode } from "../customer-grid/windows";
35
- import { ANCHOR_LABELS, ANCHOR_MODES, MAX_N } from "../customer-grid/windows";
36
- import { FieldSelectButton } from "../customer-grid/FieldSelect";
37
- import type { FieldSelectItem } from "../customer-grid/FieldSelect";
38
- import { WindowPicker } from "./WindowPicker";
39
- import {
40
- COHORT_OP_LIST, DEFAULT_DATE_WINDOW, DEFAULT_WINDOW, MEASURE_MARK, MEASURE_OP_LIST,
41
- MEASURE_PAIR_OP_LIST, WITHIN_KINDS, cohortOpLabel, groupSummary, hasRankCondition,
42
- isDateFamily, isNumericType, newCondition, newRuleId, opLabel, opsForType, rankDefault,
43
- withCurrent, withCurrentField,
44
- } from "./ops";
45
- import "../customer-grid/filters.css";
46
-
47
- /** The choice vocabulary of one field, resolved once and threaded down as a function so no
48
- * level of the tree has to carry three separate maps to answer one question. */
49
- type ChoicesFor = (colId: string) => string[];
50
-
51
- /**
52
- * Owner item 3 β€” the DATE value: an anchor mode, then whatever that mode needs.
53
- *
54
- * [is before] [one month ago] <- mode only
55
- * [is before] [N days ago] [30] <- mode + a number
56
- * [is before] [an exact date] [ πŸ“… ] <- mode + a date
57
- *
58
- * The four value-free modes render NO second control, which is exactly why `isRuleActive` has
59
- * to know about them: an empty value would otherwise read as a half-typed condition and be
60
- * ignored, quietly widening the result.
61
- */
62
- function AnchorValue({
63
- rule,
64
- onPatch,
65
- }: {
66
- rule: FilterRule;
67
- onPatch: (p: Partial<FilterRule>) => void;
68
- }) {
69
- const mode = rule.dateMode ?? "exact";
70
- return (
71
- <span className="cg-cond-anchor">
72
- <select
73
- className="cg-select cg-cond-anchor-mode"
74
- value={mode}
75
- aria-label="Date"
76
- onChange={(e) => {
77
- // Clear the value with the mode. A "30" left behind on `an exact date`, or a date
78
- // left behind on `N days ago`, is state the user can neither see nor clear.
79
- const next = e.target.value as AnchorMode;
80
- onPatch({ dateMode: next, value: "" });
81
- }}
82
- >
83
- {ANCHOR_MODES.map((m) => (
84
- <option key={m} value={m}>
85
- {m === "n_days_ago" ? ANCHOR_LABELS[m].replace("{n}", "N") : ANCHOR_LABELS[m]}
86
- </option>
87
- ))}
88
- </select>
89
- {mode === "n_days_ago" && (
90
- <input
91
- className="cg-input cg-cond-win-n"
92
- type="number"
93
- min={0}
94
- max={MAX_N}
95
- aria-label="Number of days ago"
96
- value={rule.value}
97
- onChange={(e) => onPatch({ value: e.target.value })}
98
- />
99
- )}
100
- {mode === "exact" && (
101
- <input
102
- className="cg-input cg-cond-val"
103
- type="date"
104
- aria-label="Date"
105
- value={rule.value}
106
- onChange={(e) => onPatch({ value: e.target.value })}
107
- />
108
- )}
109
- </span>
110
- );
111
- }
112
-
113
- /**
114
- * CG-9 (owner item 4) β€” the comparand when it is ANOTHER ATTRIBUTE rather than a number.
115
- *
116
- * `Sales [this year] > Sales [year to date, last year]`. For a MEASURE the right side carries
117
- * its own window, which is the whole point: the two sides differ by period, not by name.
118
- */
119
- function RhsPicker({
120
- rule,
121
- options,
122
- isMeasure,
123
- onPatch,
124
- }: {
125
- rule: FilterRule;
126
- options: FieldSelectItem[];
127
- isMeasure: boolean;
128
- onPatch: (p: Partial<FilterRule>) => void;
129
- }) {
130
- const rhs = rule.rhs;
131
- const colId = rhsColId(rhs) ?? "";
132
- return (
133
- <span className="cg-cond-rhs">
134
- {/* Item 20 β€” the empty comparand was an `<option value="">` that a select renders as
135
- its first row; here it is the picker's PLACEHOLDER state, so "not chosen yet" is a
136
- state of the control rather than an entry in the vocabulary. */}
137
- <FieldSelectButton
138
- className="cg-cond-field"
139
- ariaLabel="Compare with field"
140
- placeholder="Select a field…"
141
- value={colId || undefined}
142
- fields={options}
143
- onChange={(key) => {
144
- onPatch({
145
- rhs: isMeasure
146
- ? { kind: "measure", colId: key,
147
- window: (rhs?.kind === "measure" && rhs.window) || DEFAULT_WINDOW }
148
- : { kind: "field", colId: key },
149
- });
150
- }}
151
- />
152
- {isMeasure && colId !== "" && (
153
- <WindowPicker
154
- window={(rhs?.kind === "measure" && rhs.window) || DEFAULT_WINDOW}
155
- onWindow={(w) => onPatch({ rhs: { kind: "measure", colId, window: w } })}
156
- />
157
- )}
158
- </span>
159
- );
160
- }
161
-
162
- // --- the value control for one filter condition (type/op aware) ------------
163
- function ConditionValue({
164
- rule,
165
- type,
166
- statusOptions,
167
- onValue,
168
- onValue2,
169
- }: {
170
- rule: FilterRule;
171
- type: Field["type"];
172
- statusOptions: string[];
173
- onValue: (v: string) => void;
174
- onValue2: (v: string) => void;
175
- }) {
176
- if (rule.op === "between") {
177
- const inputType = type === "date" || type === "created_time" ? "date" : "number";
178
- return (
179
- <span className="cg-cond-between">
180
- <input
181
- className="cg-input cg-cond-val"
182
- type={inputType}
183
- value={rule.value}
184
- onChange={(e) => onValue(e.target.value)}
185
- />
186
- <span className="cg-cond-and">and</span>
187
- <input
188
- className="cg-input cg-cond-val"
189
- type={inputType}
190
- value={rule.value2 ?? ""}
191
- onChange={(e) => onValue2(e.target.value)}
192
- />
193
- </span>
194
- );
195
- }
196
- if (
197
- type === "status" || type === "select" || type === "user" || type === "multiselect"
198
- ) {
199
- return (
200
- <select
201
- className="cg-select cg-cond-val"
202
- value={rule.value}
203
- onChange={(e) => onValue(e.target.value)}
204
- >
205
- <option value="">Select…</option>
206
- {statusOptions.map((v) => (
207
- <option key={v} value={v}>
208
- {v}
209
- </option>
210
- ))}
211
- </select>
212
- );
213
- }
214
- const inputType =
215
- type === "date" || type === "created_time"
216
- ? "date"
217
- : isNumericType(type)
218
- ? "number"
219
- : "text";
220
- return (
221
- <input
222
- className="cg-input cg-cond-val"
223
- type={inputType}
224
- placeholder="Value"
225
- value={rule.value}
226
- onChange={(e) => onValue(e.target.value)}
227
- />
228
- );
229
- }
230
-
231
- /**
232
- * C-OPS β€” the value control for a rank condition.
233
- *
234
- * Two shapes, because the two questions are different: a COUNT (or a percentage) is typed and
235
- * unbounded-ish, while a quantile is a choice from four or ten. Rendering the quantile as a
236
- * number box would let somebody type `inQuartile 7`, which the engine refuses outright β€” the
237
- * control that cannot express the invalid state is the better one.
238
- *
239
- * Bounds are on the input as `min`/`max` AND enforced in the engine, which is not redundancy:
240
- * a number input's bounds are advisory (typing past them is allowed and only surfaces on form
241
- * validation), so the box is a hint and `rankOps.bound` is the wall. A value outside the range
242
- * makes the condition UNANSWERABLE β€” it matches nothing and says so beside the count.
243
- */
244
- function RankValue({
245
- rule,
246
- onValue,
247
- }: {
248
- rule: FilterRule;
249
- onValue: (v: string) => void;
250
- }) {
251
- const buckets = rule.op === "inQuartile" ? 4 : rule.op === "inDecile" ? 10 : 0;
252
- if (buckets) {
253
- return (
254
- <select
255
- className="cg-select cg-cond-val"
256
- value={rule.value}
257
- aria-label={rule.op === "inQuartile" ? "Quartile" : "Decile"}
258
- onChange={(e) => onValue(e.target.value)}
259
- >
260
- {Array.from({ length: buckets }, (_, i) => String(i + 1)).map((n) => (
261
- <option key={n} value={n}>
262
- {/* The ends are the ones people mean, so they say so rather than making the
263
- reader remember which end 1 is. */}
264
- {n === "1"
265
- ? `${n} (lowest)`
266
- : n === String(buckets)
267
- ? `${n} (highest)`
268
- : n}
269
- </option>
270
- ))}
271
- </select>
272
- );
273
- }
274
- const pct = rule.op === "inTopPct" || rule.op === "inBottomPct";
275
- return (
276
- <input
277
- className="cg-input cg-cond-val"
278
- type="number"
279
- min={1}
280
- max={pct ? 100 : 10000}
281
- step={1}
282
- placeholder={pct ? "10" : "10"}
283
- aria-label={pct ? "Percent" : "How many"}
284
- value={rule.value}
285
- onChange={(e) => onValue(e.target.value)}
286
- />
287
- );
288
- }
289
-
290
- /**
291
- * The cohorts a `Where [Cohort] [is any of] […]` condition names (owner, 2026-07-27).
292
- *
293
- * Chips plus an add-select, not a `<select multiple>`: a native multi-select needs ctrl-click to
294
- * build a set and shows what is chosen only by highlight, so the condition would stop reading as
295
- * a sentence β€” and the one thing this row has to do is say what it filters. The add-select also
296
- * keeps exactly one `.cg-cond-val` in the row, which is the control the shipped round-trip gate
297
- * drives; choosing a cohort there APPENDS rather than replaces.
298
- *
299
- * The select is rendered even when there is nothing left to add, disabled. A control that
300
- * vanishes at the cap would reflow the sentence mid-edit, and a gate that located it by class
301
- * would report a missing control rather than a full set.
302
- *
303
- * A cohort the view names but the user no longer has still renders, as `(deleted list)` β€” the
304
- * `withCurrent` rule applied to a set. The engine refuses such a condition outright (evalNode
305
- * returns false for the whole leaf, not for that member), so showing the stale chip is exactly
306
- * what lets somebody remove it.
307
- */
308
- function CohortPicker({
309
- rule,
310
- lists,
311
- onPatch,
312
- }: {
313
- rule: FilterRule;
314
- lists: { id: string; name: string }[];
315
- onPatch: (p: Partial<FilterRule>) => void;
316
- }) {
317
- const chosen = cohortIds(rule.value);
318
- const nameById = new Map(lists.map((l) => [l.id, l.name]));
319
- const remaining = lists.filter((l) => !chosen.includes(l.id));
320
- const canAdd = remaining.length > 0 && chosen.length < MAX_COHORT_IDS;
321
- const setIds = (ids: string[]) => onPatch({ value: ids.join(",") });
322
- return (
323
- <span className="cg-cond-cohorts">
324
- {chosen.map((id) => (
325
- <span key={id} className={"cg-chip" + (nameById.has(id) ? "" : " cg-chip--gone")}>
326
- <span className="cg-chip-text">{nameById.get(id) ?? "(deleted list)"}</span>
327
- <button
328
- type="button"
329
- className="cg-chip-x"
330
- aria-label={`Remove ${nameById.get(id) ?? id}`}
331
- onClick={() => setIds(chosen.filter((c) => c !== id))}
332
- >
333
- Γ—
334
- </button>
335
- </span>
336
- ))}
337
- <select
338
- className="cg-select cg-cond-val cg-cond-cohort-add"
339
- value=""
340
- aria-label="Cohort"
341
- disabled={!canAdd}
342
- onChange={(e) => e.target.value && setIds([...chosen, e.target.value])}
343
- >
344
- {/* "nothing left to add" rendered as "nothing left to ad…" at 136px β€” the fourth time a
345
- closed vocabulary has outgrown its control here. Shortened, not widened: the control
346
- is sized so a COHORT NAME stays readable, and that is the string that matters. */}
347
- <option value="">
348
- {!canAdd ? "all added" : chosen.length ? "Add a cohort…" : "Select a cohort…"}
349
- </option>
350
- {remaining.map((l) => (
351
- <option key={l.id} value={l.id}>
352
- {l.name}
353
- </option>
354
- ))}
355
- </select>
356
- </span>
357
- );
358
- }
359
-
360
- // --- Filter: an Airtable-style RECURSIVE condition builder -----------------
361
- // Anatomy per Airtable (captured from the live product 2026-07-25):
362
- // row 0 : static "Where"
363
- // row 1 : the and/or DROPDOWN β€” one conjunction per LEVEL, not per condition
364
- // row 2..n : static text mirroring that level's conjunction
365
- // a group : inset panel + summary line ("All/Any of the following are true…")
366
- // carrying its OWN conjunction and its own Where/and-or rows
367
- // Nesting is capped at MAX_FILTER_DEPTH (3) β€” Airtable grays the group button out.
368
-
369
- /** The leading cell of a condition row: "Where", the conjunction picker, or the mirror. */
370
- function RowPrefix({
371
- index,
372
- conj,
373
- onConj,
374
- }: {
375
- index: number;
376
- conj: Conjunction;
377
- onConj: (c: Conjunction) => void;
378
- }) {
379
- if (index === 0) return <span className="cg-cond-prefix">Where</span>;
380
- if (index === 1)
381
- return (
382
- <select
383
- className="cg-select cg-cond-conj"
384
- value={conj}
385
- aria-label="Combine conditions with"
386
- onChange={(e) => onConj(e.target.value as Conjunction)}
387
- >
388
- <option value="and">and</option>
389
- <option value="or">or</option>
390
- </select>
391
- );
392
- return <span className="cg-cond-prefix cg-cond-conj-echo">{conj}</span>;
393
- }
394
-
395
- /** Everything a level of the tree needs that does not change as it recurses. One object rather
396
- * than eight repeated props: the list grew past the point where each level restating it made
397
- * the recursion easier to read. */
398
- interface LevelContext {
399
- fields: Field[];
400
- fieldByKey: Map<string, Field>;
401
- measures: Measure[];
402
- measureByKey: Map<string, Measure>;
403
- /** Cohorts this user has β€” the vocabulary of `Where [Cohort] [is part of] […]`. */
404
- lists: { id: string; name: string }[];
405
- choicesFor: ChoicesFor;
406
- }
407
-
408
- /**
409
- * One nesting level: renders its nodes (leaves and nested groups) plus the
410
- * add-buttons. Recurses through GroupNode for children that are groups.
411
- */
412
- function ConditionLevel({
413
- nodes,
414
- conj,
415
- depth,
416
- ctx,
417
- onNodes,
418
- onConj,
419
- }: {
420
- nodes: FilterNode[];
421
- conj: Conjunction;
422
- /** 1 = the root level. A new group's depth would be depth + 1. */
423
- depth: number;
424
- ctx: LevelContext;
425
- onNodes: (nodes: FilterNode[]) => void;
426
- onConj: (c: Conjunction) => void;
427
- }) {
428
- /** Item 13 / R9 β€” which add-link was last used at THIS level. Per level, because the state
429
- * belongs to the row of buttons the user clicked, not to the builder as a whole. */
430
- const [lastAdd, setLastAdd] = useState<"condition" | "group" | null>(null);
431
- const setNode = (i: number, next: FilterNode) =>
432
- onNodes(nodes.map((n, j) => (j === i ? next : n)));
433
- const removeNode = (i: number) => onNodes(nodes.filter((_, j) => j !== i));
434
-
435
- const addCondition = () => {
436
- const c = newCondition(ctx.fields);
437
- if (c) onNodes([...nodes, c]);
438
- };
439
- // New groups default to "or" β€” matching Airtable, and the useful case (the
440
- // root ANDs a set of alternatives together).
441
- const addGroup = () => {
442
- const c = newCondition(ctx.fields);
443
- if (c) onNodes([...nodes, { conj: "or", children: [c] }]);
444
- };
445
- const canNest = depth < MAX_FILTER_DEPTH;
446
-
447
- return (
448
- <>
449
- {nodes.map((node, i) => (
450
- <div className="cg-cond-line" key={i}>
451
- <RowPrefix index={i} conj={conj} onConj={onConj} />
452
- {isFilterGroup(node) ? (
453
- <GroupNode
454
- group={node}
455
- depth={depth + 1}
456
- ctx={ctx}
457
- onGroup={(g) => setNode(i, g)}
458
- onRemove={() => removeNode(i)}
459
- />
460
- ) : (
461
- <LeafRow
462
- rule={node}
463
- ctx={ctx}
464
- onRule={(r) => setNode(i, r)}
465
- onRemove={() => removeNode(i)}
466
- />
467
- )}
468
- </div>
469
- ))}
470
- {/* Item 13 (owner, 2026-08-02) β€” the two add-links are BOLD, and the one you just used
471
- holds a dark-blue state.
472
- ⚠ R9 says that state lasts while the popover is open and a fresh open starts
473
- neutral β€” which is exactly what a `useState` HERE gives for free, because `Popover`
474
- renders its children only while open (`{open && <AnchoredOverlay…>}`) and unmounts
475
- them on close. Nothing persists it, nothing has to clear it, and there is no way for
476
- it to survive a reopen. A `:active` pseudo-class would have been the wrong tool
477
- twice over: it lasts for the duration of the mouse-down, and it says "you are
478
- pressing this" rather than "this is the one you have been using". */}
479
- <div className="cg-builder-add">
480
- <button
481
- type="button"
482
- className={"cg-link-btn" + (lastAdd === "condition" ? " is-on" : "")}
483
- onClick={() => {
484
- setLastAdd("condition");
485
- addCondition();
486
- }}
487
- >
488
- + Add condition
489
- </button>
490
- <button
491
- type="button"
492
- className={"cg-link-btn" + (lastAdd === "group" ? " is-on" : "")}
493
- onClick={() => {
494
- setLastAdd("group");
495
- addGroup();
496
- }}
497
- disabled={!canNest}
498
- title={
499
- canNest
500
- ? "Group conditions so they evaluate together"
501
- : `Condition groups can nest ${MAX_FILTER_DEPTH} levels deep`
502
- }
503
- >
504
- + Add condition group
505
- </button>
506
- </div>
507
- </>
508
- );
509
- }
510
-
511
- /**
512
- * One leaf condition: field β–Ύ | (window β–Ύ) | operator β–Ύ | value | remove.
513
- *
514
- * The window bracket appears only for a MEASURE (CG-8) β€” a column already has its window baked
515
- * in ("YTD $" IS year-to-date), and offering one there would imply it could be changed.
516
- */
517
- function LeafRow({
518
- rule,
519
- ctx,
520
- onRule,
521
- onRemove,
522
- }: {
523
- rule: FilterRule;
524
- ctx: LevelContext;
525
- onRule: (r: FilterRule) => void;
526
- onRemove: () => void;
527
- }) {
528
- const { fields, fieldByKey, measures, measureByKey, lists, choicesFor } = ctx;
529
- const isMeasure = isMeasureRule(rule);
530
- const isCohort = rule.colId === COHORT_FIELD;
531
- const hasCohorts = lists.length > 0;
532
- const t = isMeasure
533
- ? measureByKey.get(rule.colId)?.type ?? "currency"
534
- : fieldByKey.get(rule.colId)?.type ?? "text";
535
- const patch = (p: Partial<FilterRule>) => onRule({ ...rule, ...p });
536
- // Switching field resets op to that type's default + clears the value(s), so a
537
- // numeric op can never linger on a text column. Crossing the column/measure line also
538
- // adds or removes the WINDOW and the rule ID, since those are what MAKE it a measure β€”
539
- // leaving a window behind on a column condition would keep the server answering it. The
540
- // rhs goes too: a comparand chosen for the old field is meaningless against the new one.
541
- const onField = (key: string) => {
542
- if (key === COHORT_FIELD) {
543
- onRule({ colId: COHORT_FIELD, op: COHORT_OP_LIST[0], value: "" });
544
- return;
545
- }
546
- const m = measureByKey.get(key);
547
- if (m) {
548
- onRule({
549
- id: rule.id ?? newRuleId(),
550
- colId: key,
551
- op: MEASURE_OP_LIST[0],
552
- value: "",
553
- value2: undefined,
554
- window: rule.window ?? DEFAULT_WINDOW,
555
- });
556
- return;
557
- }
558
- const nt = fieldByKey.get(key)?.type ?? "text";
559
- onRule({ colId: key, op: opsForType(nt)[0], value: "", value2: undefined });
560
- };
561
- // Switching to/from a value-free op clears the stale value it can't show. `within` swaps the
562
- // value for a RANGE and drops any field comparand, since a range has no other side.
563
- const onOp = (op: FilterOp | CohortOp) => {
564
- // A cohort keeps its SET when the set operator changes β€” "any of these three" and "none of
565
- // these three" are the same three cohorts asked about differently, and clearing them would
566
- // make the obvious edit destructive.
567
- if (isCohort) return patch({ op });
568
- // C-OPS: a rank op arrives with its N already chosen (see rankDefault) and drops every
569
- // comparand β€” "the top 10 compared with another field" is not a question.
570
- if (isRankOp(op as string))
571
- return onRule({ ...rule, op: op as FilterOp, value: rankDefault(op as FilterOp),
572
- value2: undefined, rhs: undefined, dateWindow: undefined });
573
- if (VALUE_FREE_OPS.has(op as FilterOp))
574
- return onRule({ ...rule, op, value: "", value2: undefined, rhs: undefined });
575
- if (op === "within")
576
- return onRule({ ...rule, op, value: "", value2: undefined, rhs: undefined,
577
- dateWindow: rule.dateWindow ?? DEFAULT_DATE_WINDOW });
578
- if (rule.op === "within")
579
- return onRule({ ...rule, op, dateWindow: undefined, value: "" });
580
- patch({ op });
581
- };
582
- // Owner item 4: the value box is REPLACEABLE by another attribute. Offered where a
583
- // comparison between two of the same kind of thing means something β€” numbers against
584
- // numbers, dates against dates, a measure against a measure over its own period.
585
- const rhsOptions: FieldSelectItem[] = isMeasure
586
- ? measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK }))
587
- : fields
588
- .filter((f) => f.key !== rule.colId
589
- && (isNumericType(t) ? isNumericType(f.type) : f.type === t))
590
- .map((f) => ({ key: f.key, label: f.label, type: f.type }));
591
- const canCompareField = !isCohort && rule.op !== "within"
592
- && !VALUE_FREE_OPS.has(rule.op as FilterOp) && !isRankOp(rule.op)
593
- && rhsOptions.length > 0 && (isMeasure || isNumericType(t) || isDateFamily(t));
594
- const usingStat = rule.rhs?.kind === "stat";
595
- const usingField = rule.rhs != null && !usingStat;
596
- // Explicitly widened: the cohort branch contributes a DISJOINT vocabulary, and without the
597
- // annotation `withCurrent`'s `T extends string` infers from the first branch alone.
598
- const opList: (FilterOp | CohortOp)[] = isMeasure
599
- ? (usingField || usingStat ? MEASURE_PAIR_OP_LIST : MEASURE_OP_LIST)
600
- : isCohort ? COHORT_OP_LIST : opsForType(t);
601
- const opValue: FilterOp | CohortOp = isCohort ? normalizeCohortOp(rule.op) : rule.op;
602
- // C-OPS β€” the RANK family, offered under its own divider for the numeric family only.
603
- //
604
- // A COLUMN only: a measure leaf is answered by a server-side pid set over a window, so
605
- // ranking one client-side would be a second answer to the same question (and the host's
606
- // MEASURE_OPS deliberately does not carry these). A cohort leaf is set membership.
607
- //
608
- // Offered separately rather than appended to `opsForType` because these operators are a
609
- // different KIND of question β€” every other one is about this row's value, these are about
610
- // where the row sits among the others β€” and a divider is the cheapest way to say so.
611
- const canRank = !isMeasure && !isCohort && isNumericType(t);
612
- // `withCurrent` runs over the COMBINED offer so a rule already holding a rank op is not
613
- // appended a second time; the split below then puts each op under the right heading. A
614
- // rank op on a field that can no longer be ranked still renders β€” as itself, in the group,
615
- // which is what lets somebody see it and change it.
616
- const offeredOps = withCurrent(canRank ? [...opList, ...RANK_OPS] : opList, opValue);
617
- const plainOps = offeredOps.filter((op) => !isRankOp(op));
618
- const rankedOps = offeredOps.filter((op) => isRankOp(op));
619
-
620
- return (
621
- <span className={"cg-cond-row" + (isMeasure ? " is-measure" : "")
622
- + (isCohort ? " is-cohort" : "")
623
- + (rule.rhs != null ? " has-rhs" : "")}>
624
- {/* ONE flat list (owner item 1). A measure used to sit under a "Measures (pick a
625
- period)" optgroup; the owner's instruction was that it is all the same list β€”
626
- you pick a thing to filter on, and a measure simply also asks for a period. Item 20
627
- adds the type MARK to each row and takes NO grouping with it: the marks are what the
628
- optgroup was reaching for, and they say it per row instead of splitting the list. */}
629
- <FieldSelectButton
630
- className="cg-cond-field"
631
- ariaLabel="Field"
632
- value={rule.colId}
633
- onChange={onField}
634
- fields={[
635
- ...withCurrentField(fields, rule.colId, fieldByKey, measureByKey, hasCohorts),
636
- ...measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK })),
637
- // A cohort leaf asks about membership of a SET, which is the multiselect mark's
638
- // own meaning β€” the vocabulary already had the right glyph for it.
639
- ...(hasCohorts
640
- ? [{ key: COHORT_FIELD, label: "Cohort", type: "multiselect" as const }]
641
- : []),
642
- ]}
643
- />
644
- {isMeasure && (
645
- <WindowPicker
646
- window={rule.window ?? DEFAULT_WINDOW}
647
- onWindow={(w) => patch({ window: w })}
648
- />
649
- )}
650
- <select
651
- className="cg-select cg-cond-op"
652
- // A view saved before 2026-07-27 holds `eq`/`neq`. Normalising HERE, rather than letting
653
- // `withCurrent` append it, is the difference between a legacy rule reading "is any of"
654
- // once and the list carrying two options with the same label and different values. The
655
- // display stays truthful either way β€” `eq` IS `anyOf` β€” and the first edit writes the
656
- // new op, matching what the validator already does on the server.
657
- value={opValue}
658
- aria-label="Condition"
659
- onChange={(e) => onOp(e.target.value as FilterOp | CohortOp)}
660
- >
661
- {plainOps.map((op) => (
662
- <option key={op} value={op}>
663
- {/* `op as FilterOp` is safe by construction, not by hope: `opList` is the cohort
664
- vocabulary EXACTLY when `isCohort`, so the other branch only ever sees the
665
- column/measure one. The two lists are disjoint, so a wrong cast would show as a
666
- raw key ("anyOf") on screen rather than as a plausible label. */}
667
- {isCohort ? cohortOpLabel(op) : opLabel(op as FilterOp, t)}
668
- </option>
669
- ))}
670
- {rankedOps.length > 0 && (
671
- <optgroup label="Ranked">
672
- {rankedOps.map((op) => (
673
- <option key={op} value={op}>
674
- {opLabel(op as FilterOp, t)}
675
- </option>
676
- ))}
677
- </optgroup>
678
- )}
679
- </select>
680
- {canCompareField && (
681
- // The switch between "a value I type" and "another attribute". Two options rather than
682
- // a checkbox, because the row already reads as a sentence and this is the noun slot.
683
- <select
684
- className="cg-select cg-cond-mode"
685
- value={usingStat ? "stat" : usingField ? "field" : "value"}
686
- aria-label="Compare against"
687
- onChange={(e) => {
688
- const mode = e.target.value;
689
- if (mode === "stat")
690
- return patch({ rhs: { kind: "stat", stat: "median" },
691
- value: "", value2: undefined,
692
- // a statistic is a computed cut-off: the same four ops a
693
- // measure-vs-measure allows, for the same float reason
694
- // reachable only via `canCompareField`, which excludes a cohort
695
- op: MEASURE_PAIR_OP_LIST.includes(rule.op as FilterOp)
696
- ? rule.op : MEASURE_PAIR_OP_LIST[1] });
697
- if (mode === "field") {
698
- // An EMPTY comparand: incomplete, so the rule stays inactive until a field is
699
- // picked. RhsPicker adds the window when the measure side gets one.
700
- const empty: FilterRhs = isMeasure
701
- ? { kind: "measure", colId: "", window: DEFAULT_WINDOW }
702
- : { kind: "field", colId: "" };
703
- return patch({ rhs: empty, value: "", value2: undefined });
704
- }
705
- patch({ rhs: undefined, value: "" });
706
- }}
707
- >
708
- <option value="value">a value</option>
709
- <option value="field">another field</option>
710
- {/* Statistics are computed SERVER-SIDE over the caller's pool, so they are offered
711
- for a MEASURE only. A column statistic would need a second percentile
712
- implementation on the client and the lock-step gate that goes with it. */}
713
- {isMeasure && <option value="stat">a statistic</option>}
714
- </select>
715
- )}
716
- {/* value-free ops render NO value control (Airtable does the same) */}
717
- {VALUE_FREE_OPS.has(rule.op as FilterOp) ? (
718
- <span className="cg-cond-val cg-cond-noval" aria-hidden />
719
- ) : usingStat ? (
720
- <select
721
- className="cg-select cg-cond-val cg-cond-stat"
722
- value={rule.rhs?.kind === "stat" ? rule.rhs.stat : "median"}
723
- aria-label="Statistic"
724
- onChange={(e) => patch({ rhs: { kind: "stat", stat: e.target.value as StatKind } })}
725
- >
726
- {STAT_KINDS.map((k) => (
727
- <option key={k} value={k}>
728
- {STAT_LABELS[k]}
729
- </option>
730
- ))}
731
- </select>
732
- ) : usingField ? (
733
- <RhsPicker rule={rule} options={rhsOptions} isMeasure={isMeasure} onPatch={patch} />
734
- ) : isCohort ? (
735
- <CohortPicker rule={rule} lists={lists} onPatch={patch} />
736
- ) : isRankOp(rule.op) ? (
737
- <RankValue rule={rule} onValue={(v) => patch({ value: v })} />
738
- ) : rule.op === "within" ? (
739
- <WindowPicker
740
- window={rule.dateWindow ?? DEFAULT_DATE_WINDOW}
741
- onWindow={(w) => patch({ dateWindow: w })}
742
- kinds={WITHIN_KINDS}
743
- />
744
- ) : isDateFamily(t) ? (
745
- <AnchorValue rule={rule} onPatch={patch} />
746
- ) : (
747
- <ConditionValue
748
- rule={rule}
749
- type={t}
750
- statusOptions={choicesFor(rule.colId)}
751
- onValue={(v) => patch({ value: v })}
752
- onValue2={(v) => patch({ value2: v })}
753
- />
754
- )}
755
- <button
756
- type="button"
757
- className="cg-cond-x"
758
- aria-label="Remove condition"
759
- onClick={onRemove}
760
- >
761
- Γ—
762
- </button>
763
- </span>
764
- );
765
- }
766
-
767
- /** A nested condition group: summary line + its own recursive level. */
768
- function GroupNode({
769
- group,
770
- depth,
771
- ctx,
772
- onGroup,
773
- onRemove,
774
- }: {
775
- group: FilterGroup;
776
- depth: number;
777
- ctx: LevelContext;
778
- onGroup: (g: FilterGroup) => void;
779
- onRemove: () => void;
780
- }) {
781
- return (
782
- <span className="cg-cond-group" data-depth={depth}>
783
- <span className="cg-cond-group-head">
784
- <span className="cg-cond-group-sum">
785
- {group.children.length
786
- ? groupSummary(group.conj)
787
- : "Add a condition to this group…"}
788
- </span>
789
- <button
790
- type="button"
791
- className="cg-cond-x"
792
- aria-label="Remove condition group"
793
- onClick={onRemove}
794
- >
795
- Γ—
796
- </button>
797
- </span>
798
- <span className="cg-cond-group-body">
799
- <ConditionLevel
800
- nodes={group.children}
801
- conj={group.conj}
802
- depth={depth}
803
- ctx={ctx}
804
- onNodes={(children) => onGroup({ ...group, children })}
805
- onConj={(conj) => onGroup({ ...group, conj })}
806
- />
807
- </span>
808
- </span>
809
- );
810
- }
811
-
812
- export interface FilterBuilderPanelProps {
813
- /**
814
- * The module's COMPLETE field list. The panel offers the FILTERABLE ones
815
- * (`filterableFields`) and resolves labels from all of them β€” a condition saved against a
816
- * column that has since stopped being filterable must still render its own name rather than
817
- * turning into "?", so narrowing the picker may never orphan history.
818
- */
819
- fields: Field[];
820
- /** The whole filter as ONE value β€” nodes plus the root conjunction. See types.FilterTree. */
821
- filters: FilterTree;
822
- onChange: (next: FilterTree) => void;
823
- /** Cohorts the subject of this filter has, for `Where [Cohort] [is any of] […]`. */
824
- cohorts?: { id: string; name: string }[];
825
- /** CG-8 β€” measures the builder may offer alongside the columns. Absent = columns only. */
826
- measures?: Measure[];
827
- /** The people a `user` field may name. Never invented here β€” the host's real user list. */
828
- userOptions?: string[];
829
- /**
830
- * The tenant's today, ISO. RESERVED: it is the anchor a relative window resolves against,
831
- * and it is accepted so a caller that holds it (the grid, the admin editor) has one obvious
832
- * place to put it. It renders NOTHING today β€” a "this resolves to <range>" hint under the
833
- * date controls was considered for wave 15 and rejected as a visual delta C-KIT forbids.
834
- * Kept in the signature rather than added later so no call site has to change to gain it.
835
- */
836
- today?: string;
837
- /**
838
- * The per-field CHOICE VOCABULARY, by field key. The one prop that is not cosmetic when it
839
- * is absent: some columns take their values from the DATA (the grid discovers them from its
840
- * rows β€” `customer-grid/types.ts::choiceVocabulary`), which a panel over a module whose rows
841
- * it has never loaded cannot do. Absent, choices fall back to the field's own declared
842
- * `options` and, for `user`, to `userOptions`.
843
- *
844
- * β›” **Which columns those are is NOT decided by the type, and the old wording here saying
845
- * "only Odoo `status` columns come up empty" was wrong in a way that shipped a live defect**
846
- * (owner item 24, 2026-08-06). `stock_bucket` on the Product grid is declared `select` and
847
- * declares no `options` β€” its vocabulary is computed server-side and rides the rows, exactly
848
- * like a `status` column's. **Any column with no declared `options` needs this prop**, whatever
849
- * its type says. A host that cannot enumerate one renders an empty picker; see `choicesFor`.
850
- */
851
- statusValues?: Record<string, string[]>;
852
- /**
853
- * Item 12 (C-LOCK) β€” this view is LOCKED to a cohort. Present = say so, because the lock
854
- * narrows the list and is not one of the conditions shown here: without the banner the count
855
- * and the conditions disagree with no visible reason.
856
- *
857
- * `name`/`count` ABSENT means the reader cannot see the set β€” a shared view locked to
858
- * somebody else's cohort. The engine matches nothing in that case (fail-closed, the
859
- * cohort-leaf law), so the banner has to say which kind of nothing this is.
860
- */
861
- cohortLock?: { name?: string; count?: number };
862
- /**
863
- * Extra footer content, rendered beside "Clear all" β€” the grid puts its "Copy from another
864
- * view" door here. A SLOT rather than props, exactly like the toolbar's `cohortAction`: the
865
- * door needs the other views and the view-patch plumbing, none of which a panel that also
866
- * serves an admin editor should know about.
867
- */
868
- footerExtra?: ReactNode;
869
- }
870
-
871
- export function FilterBuilderPanel({
872
- fields,
873
- filters,
874
- onChange,
875
- cohorts = [],
876
- measures = [],
877
- userOptions,
878
- statusValues,
879
- cohortLock,
880
- footerExtra,
881
- }: FilterBuilderPanelProps) {
882
- const nodes = filterTreeNodes(filters);
883
- const conj = filterTreeConj(filters);
884
- // The COMPLETE map on purpose (see `fields` above): the picker is narrowed, the label
885
- // resolution is not.
886
- const fieldByKey = useMemo(() => {
887
- const m = new Map<string, Field>();
888
- for (const f of fields) m.set(f.key, f);
889
- return m;
890
- }, [fields]);
891
- const condFields = useMemo(() => filterableFields(fields), [fields]);
892
- const measureByKey = useMemo(() => {
893
- const m = new Map<string, Measure>();
894
- for (const item of measures) m.set(item.key, item);
895
- return m;
896
- }, [measures]);
897
- const choicesFor = useCallback<ChoicesFor>(
898
- (colId) => {
899
- // A supplied map WINS, including when it holds an empty list: "this status column has no
900
- // values in the data" is an answer, not a missing one.
901
- const declared = statusValues?.[colId];
902
- if (declared) return declared;
903
- const f = fieldByKey.get(colId);
904
- if (!f) return [];
905
- return f.type === "user" ? userOptions ?? [] : choiceOptions(f);
906
- },
907
- [statusValues, fieldByKey, userOptions]
908
- );
909
- const ctx = useMemo<LevelContext>(
910
- () => ({ fields: condFields, fieldByKey, measures, measureByKey, lists: cohorts,
911
- choicesFor }),
912
- [condFields, fieldByKey, measures, measureByKey, cohorts, choicesFor]
913
- );
914
-
915
- return (
916
- <div className="cg-pop-body cg-builder">
917
- <div className="cg-pop-title">Filter</div>
918
- {/* C-LOCK (item 12) β€” the lock is NOT a condition row: it cannot be written here and
919
- cannot be removed here (the view menu owns it). So it has to be SAID here, above
920
- the conditions it bounds β€” otherwise the count and the conditions disagree and the
921
- builder shows nothing that explains the gap. A reader who cannot see the set gets a
922
- different sentence, because the engine shows them nobody and "no records" alone does
923
- not distinguish a permission wall from a filter that genuinely matches nobody. */}
924
- {cohortLock && (
925
- <div className="cg-flt-lockchip">
926
- {cohortLock.name
927
- ? `Customer list locked to ${cohortLock.name}` +
928
- (typeof cohortLock.count === "number"
929
- ? ` Β· ${cohortLock.count.toLocaleString()} ${
930
- cohortLock.count === 1 ? "customer" : "customers"
931
- }`
932
- : "")
933
- : "Customer list locked to a set you cannot see"}
934
- </div>
935
- )}
936
- {nodes.length === 0 ? (
937
- <div className="cg-builder-empty">
938
- No conditions. Add one to narrow the list.
939
- </div>
940
- ) : (
941
- <div className="cg-builder-caption">In this view, show records</div>
942
- )}
943
- <ConditionLevel
944
- nodes={nodes}
945
- conj={conj}
946
- depth={1}
947
- ctx={ctx}
948
- onNodes={(next) => onChange({ conj, nodes: next })}
949
- onConj={(next) => onChange({ conj: next, nodes })}
950
- />
951
- {/* C-OPS β€” a ranked condition answers a question about the OTHER records, and there is
952
- no way to see from the row which others. Three sentences, shown only while one is in
953
- the tree: the population, what is left out of it, and the one thing about quantiles
954
- that is genuinely not guessable (equal-size by RANK, so two identical values can
955
- land on opposite sides of a boundary). */}
956
- {hasRankCondition(nodes) && (
957
- <div className="cg-pop-note">
958
- Ranked among the records your other conditions keep β€” so β€œtop 10” inside a filtered
959
- view means the top 10 of that view. Records with no value are not ranked at all.
960
- Quartiles and deciles are equal-size slices BY RANK, not by value.
961
- </div>
962
- )}
963
- {/* The footer carries "Clear all" (only when there is something to clear) and whatever
964
- door the host mounts in `footerExtra`.
965
- ⚠ The door is in the FOOTER rather than beside the add-links, where the reference
966
- screenshot has it, for one structural reason: `.cg-builder-add` is emitted once per
967
- NESTING LEVEL, so a link there would repeat inside every condition group and offer
968
- to overwrite the whole view from inside one of its brackets. The footer is the one
969
- place in this popover that means "the view", which is what the copy acts on. It is
970
- also offered when there are NO conditions yet, which is when somebody most wants to
971
- start from another view. */}
972
- {(nodes.length > 0 || footerExtra) && (
973
- <div className="cg-pop-footer cg-builder-footer">
974
- {nodes.length > 0 && (
975
- <button
976
- type="button"
977
- className="cg-link-btn"
978
- onClick={() => onChange({ conj, nodes: [] })}
979
- >
980
- Clear all
981
- </button>
982
- )}
983
- {footerExtra}
984
- </div>
985
- )}
986
- </div>
987
- );
988
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // filter-kit / FilterBuilderPanel.tsx
3
+ // The Airtable-style RECURSIVE condition builder, as a MOUNTABLE PANEL.
4
+ //
5
+ // Extracted from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so the admin
6
+ // permission editor and the grid toolbar build filters with the SAME control
7
+ // rather than two that drift. Behaviour is identical to the toolbar's β€” this is a
8
+ // move, not a rewrite.
9
+ //
10
+ // THE KIT FETCHES NOTHING. Every vocabulary it needs (fields, cohorts, measures,
11
+ // the people a `user` field may name) arrives as a prop, which is what lets the
12
+ // same panel serve a module whose rows this browser has never loaded.
13
+ // ---------------------------------------------------------------------------
14
+
15
+ import { useCallback, useMemo, useState } from "react";
16
+ import type { ReactNode } from "react";
17
+ import type {
18
+ Conjunction,
19
+ Field,
20
+ FilterGroup,
21
+ FilterNode,
22
+ FilterOp,
23
+ FilterRhs,
24
+ FilterRule,
25
+ FilterTree,
26
+ Measure,
27
+ } from "../customer-grid/types";
28
+ import {
29
+ COHORT_FIELD, MAX_COHORT_IDS, MAX_FILTER_DEPTH, RANK_OPS, STAT_KINDS, STAT_LABELS,
30
+ VALUE_FREE_OPS, choiceOptions, cohortIds, filterTreeConj, filterTreeNodes,
31
+ filterableFields, isFilterGroup, isMeasureRule, isRankOp, normalizeCohortOp, rhsColId,
32
+ } from "../customer-grid/types";
33
+ import type { CohortOp, StatKind } from "../customer-grid/types";
34
+ import type { AnchorMode } from "../customer-grid/windows";
35
+ import { ANCHOR_LABELS, ANCHOR_MODES, MAX_N } from "../customer-grid/windows";
36
+ import { FieldSelectButton } from "../customer-grid/FieldSelect";
37
+ import type { FieldSelectItem } from "../customer-grid/FieldSelect";
38
+ import { WindowPicker } from "./WindowPicker";
39
+ import {
40
+ COHORT_OP_LIST, DEFAULT_DATE_WINDOW, DEFAULT_WINDOW, MEASURE_MARK, MEASURE_OP_LIST,
41
+ MEASURE_PAIR_OP_LIST, WITHIN_KINDS, cohortOpLabel, groupSummary,
42
+ isDateFamily, isNumericType, newCondition, newRuleId, opLabel, opsForType, rankDefault,
43
+ withCurrent, withCurrentField,
44
+ } from "./ops";
45
+ import "../customer-grid/filters.css";
46
+
47
+ /** The choice vocabulary of one field, resolved once and threaded down as a function so no
48
+ * level of the tree has to carry three separate maps to answer one question. */
49
+ type ChoicesFor = (colId: string) => string[];
50
+
51
+ /**
52
+ * Owner item 3 β€” the DATE value: an anchor mode, then whatever that mode needs.
53
+ *
54
+ * [is before] [one month ago] <- mode only
55
+ * [is before] [N days ago] [30] <- mode + a number
56
+ * [is before] [an exact date] [ πŸ“… ] <- mode + a date
57
+ *
58
+ * The four value-free modes render NO second control, which is exactly why `isRuleActive` has
59
+ * to know about them: an empty value would otherwise read as a half-typed condition and be
60
+ * ignored, quietly widening the result.
61
+ */
62
+ function AnchorValue({
63
+ rule,
64
+ onPatch,
65
+ }: {
66
+ rule: FilterRule;
67
+ onPatch: (p: Partial<FilterRule>) => void;
68
+ }) {
69
+ const mode = rule.dateMode ?? "exact";
70
+ return (
71
+ <span className="cg-cond-anchor">
72
+ <select
73
+ className="cg-select cg-cond-anchor-mode"
74
+ value={mode}
75
+ aria-label="Date"
76
+ onChange={(e) => {
77
+ // Clear the value with the mode. A "30" left behind on `an exact date`, or a date
78
+ // left behind on `N days ago`, is state the user can neither see nor clear.
79
+ const next = e.target.value as AnchorMode;
80
+ onPatch({ dateMode: next, value: "" });
81
+ }}
82
+ >
83
+ {ANCHOR_MODES.map((m) => (
84
+ <option key={m} value={m}>
85
+ {m === "n_days_ago" ? ANCHOR_LABELS[m].replace("{n}", "N") : ANCHOR_LABELS[m]}
86
+ </option>
87
+ ))}
88
+ </select>
89
+ {mode === "n_days_ago" && (
90
+ <input
91
+ className="cg-input cg-cond-win-n"
92
+ type="number"
93
+ min={0}
94
+ max={MAX_N}
95
+ aria-label="Number of days ago"
96
+ value={rule.value}
97
+ onChange={(e) => onPatch({ value: e.target.value })}
98
+ />
99
+ )}
100
+ {mode === "exact" && (
101
+ <input
102
+ className="cg-input cg-cond-val"
103
+ type="date"
104
+ aria-label="Date"
105
+ value={rule.value}
106
+ onChange={(e) => onPatch({ value: e.target.value })}
107
+ />
108
+ )}
109
+ </span>
110
+ );
111
+ }
112
+
113
+ /**
114
+ * CG-9 (owner item 4) β€” the comparand when it is ANOTHER ATTRIBUTE rather than a number.
115
+ *
116
+ * `Sales [this year] > Sales [year to date, last year]`. For a MEASURE the right side carries
117
+ * its own window, which is the whole point: the two sides differ by period, not by name.
118
+ */
119
+ function RhsPicker({
120
+ rule,
121
+ options,
122
+ isMeasure,
123
+ onPatch,
124
+ }: {
125
+ rule: FilterRule;
126
+ options: FieldSelectItem[];
127
+ isMeasure: boolean;
128
+ onPatch: (p: Partial<FilterRule>) => void;
129
+ }) {
130
+ const rhs = rule.rhs;
131
+ const colId = rhsColId(rhs) ?? "";
132
+ return (
133
+ <span className="cg-cond-rhs">
134
+ {/* Item 20 β€” the empty comparand was an `<option value="">` that a select renders as
135
+ its first row; here it is the picker's PLACEHOLDER state, so "not chosen yet" is a
136
+ state of the control rather than an entry in the vocabulary. */}
137
+ <FieldSelectButton
138
+ className="cg-cond-field"
139
+ ariaLabel="Compare with field"
140
+ placeholder="Select a field…"
141
+ value={colId || undefined}
142
+ fields={options}
143
+ onChange={(key) => {
144
+ onPatch({
145
+ rhs: isMeasure
146
+ ? { kind: "measure", colId: key,
147
+ window: (rhs?.kind === "measure" && rhs.window) || DEFAULT_WINDOW }
148
+ : { kind: "field", colId: key },
149
+ });
150
+ }}
151
+ />
152
+ {isMeasure && colId !== "" && (
153
+ <WindowPicker
154
+ window={(rhs?.kind === "measure" && rhs.window) || DEFAULT_WINDOW}
155
+ onWindow={(w) => onPatch({ rhs: { kind: "measure", colId, window: w } })}
156
+ />
157
+ )}
158
+ </span>
159
+ );
160
+ }
161
+
162
+ // --- the value control for one filter condition (type/op aware) ------------
163
+ function ConditionValue({
164
+ rule,
165
+ type,
166
+ statusOptions,
167
+ onValue,
168
+ onValue2,
169
+ }: {
170
+ rule: FilterRule;
171
+ type: Field["type"];
172
+ statusOptions: string[];
173
+ onValue: (v: string) => void;
174
+ onValue2: (v: string) => void;
175
+ }) {
176
+ if (rule.op === "between") {
177
+ const inputType = type === "date" || type === "created_time" ? "date" : "number";
178
+ return (
179
+ <span className="cg-cond-between">
180
+ <input
181
+ className="cg-input cg-cond-val"
182
+ type={inputType}
183
+ value={rule.value}
184
+ onChange={(e) => onValue(e.target.value)}
185
+ />
186
+ <span className="cg-cond-and">and</span>
187
+ <input
188
+ className="cg-input cg-cond-val"
189
+ type={inputType}
190
+ value={rule.value2 ?? ""}
191
+ onChange={(e) => onValue2(e.target.value)}
192
+ />
193
+ </span>
194
+ );
195
+ }
196
+ if (
197
+ type === "status" || type === "select" || type === "user" || type === "multiselect"
198
+ ) {
199
+ return (
200
+ <select
201
+ className="cg-select cg-cond-val"
202
+ value={rule.value}
203
+ onChange={(e) => onValue(e.target.value)}
204
+ >
205
+ <option value="">Select…</option>
206
+ {statusOptions.map((v) => (
207
+ <option key={v} value={v}>
208
+ {v}
209
+ </option>
210
+ ))}
211
+ </select>
212
+ );
213
+ }
214
+ const inputType =
215
+ type === "date" || type === "created_time"
216
+ ? "date"
217
+ : isNumericType(type)
218
+ ? "number"
219
+ : "text";
220
+ return (
221
+ <input
222
+ className="cg-input cg-cond-val"
223
+ type={inputType}
224
+ placeholder="Value"
225
+ value={rule.value}
226
+ onChange={(e) => onValue(e.target.value)}
227
+ />
228
+ );
229
+ }
230
+
231
+ /**
232
+ * C-OPS β€” the value control for a rank condition.
233
+ *
234
+ * Two shapes, because the two questions are different: a COUNT (or a percentage) is typed and
235
+ * unbounded-ish, while a quantile is a choice from four or ten. Rendering the quantile as a
236
+ * number box would let somebody type `inQuartile 7`, which the engine refuses outright β€” the
237
+ * control that cannot express the invalid state is the better one.
238
+ *
239
+ * Bounds are on the input as `min`/`max` AND enforced in the engine, which is not redundancy:
240
+ * a number input's bounds are advisory (typing past them is allowed and only surfaces on form
241
+ * validation), so the box is a hint and `rankOps.bound` is the wall. A value outside the range
242
+ * makes the condition UNANSWERABLE β€” it matches nothing and says so beside the count.
243
+ */
244
+ function RankValue({
245
+ rule,
246
+ onValue,
247
+ }: {
248
+ rule: FilterRule;
249
+ onValue: (v: string) => void;
250
+ }) {
251
+ const buckets = rule.op === "inQuartile" ? 4 : rule.op === "inDecile" ? 10 : 0;
252
+ if (buckets) {
253
+ return (
254
+ <select
255
+ className="cg-select cg-cond-val"
256
+ value={rule.value}
257
+ aria-label={rule.op === "inQuartile" ? "Quartile" : "Decile"}
258
+ onChange={(e) => onValue(e.target.value)}
259
+ >
260
+ {Array.from({ length: buckets }, (_, i) => String(i + 1)).map((n) => (
261
+ <option key={n} value={n}>
262
+ {/* The ends are the ones people mean, so they say so rather than making the
263
+ reader remember which end 1 is. */}
264
+ {n === "1"
265
+ ? `${n} (lowest)`
266
+ : n === String(buckets)
267
+ ? `${n} (highest)`
268
+ : n}
269
+ </option>
270
+ ))}
271
+ </select>
272
+ );
273
+ }
274
+ const pct = rule.op === "inTopPct" || rule.op === "inBottomPct";
275
+ return (
276
+ <input
277
+ className="cg-input cg-cond-val"
278
+ type="number"
279
+ min={1}
280
+ max={pct ? 100 : 10000}
281
+ step={1}
282
+ placeholder={pct ? "10" : "10"}
283
+ aria-label={pct ? "Percent" : "How many"}
284
+ value={rule.value}
285
+ onChange={(e) => onValue(e.target.value)}
286
+ />
287
+ );
288
+ }
289
+
290
+ /**
291
+ * The cohorts a `Where [Cohort] [is any of] […]` condition names (owner, 2026-07-27).
292
+ *
293
+ * Chips plus an add-select, not a `<select multiple>`: a native multi-select needs ctrl-click to
294
+ * build a set and shows what is chosen only by highlight, so the condition would stop reading as
295
+ * a sentence β€” and the one thing this row has to do is say what it filters. The add-select also
296
+ * keeps exactly one `.cg-cond-val` in the row, which is the control the shipped round-trip gate
297
+ * drives; choosing a cohort there APPENDS rather than replaces.
298
+ *
299
+ * The select is rendered even when there is nothing left to add, disabled. A control that
300
+ * vanishes at the cap would reflow the sentence mid-edit, and a gate that located it by class
301
+ * would report a missing control rather than a full set.
302
+ *
303
+ * A cohort the view names but the user no longer has still renders, as `(deleted list)` β€” the
304
+ * `withCurrent` rule applied to a set. The engine refuses such a condition outright (evalNode
305
+ * returns false for the whole leaf, not for that member), so showing the stale chip is exactly
306
+ * what lets somebody remove it.
307
+ */
308
+ function CohortPicker({
309
+ rule,
310
+ lists,
311
+ onPatch,
312
+ }: {
313
+ rule: FilterRule;
314
+ lists: { id: string; name: string }[];
315
+ onPatch: (p: Partial<FilterRule>) => void;
316
+ }) {
317
+ const chosen = cohortIds(rule.value);
318
+ const nameById = new Map(lists.map((l) => [l.id, l.name]));
319
+ const remaining = lists.filter((l) => !chosen.includes(l.id));
320
+ const canAdd = remaining.length > 0 && chosen.length < MAX_COHORT_IDS;
321
+ const setIds = (ids: string[]) => onPatch({ value: ids.join(",") });
322
+ return (
323
+ <span className="cg-cond-cohorts">
324
+ {chosen.map((id) => (
325
+ <span key={id} className={"cg-chip" + (nameById.has(id) ? "" : " cg-chip--gone")}>
326
+ <span className="cg-chip-text">{nameById.get(id) ?? "(deleted list)"}</span>
327
+ <button
328
+ type="button"
329
+ className="cg-chip-x"
330
+ aria-label={`Remove ${nameById.get(id) ?? id}`}
331
+ onClick={() => setIds(chosen.filter((c) => c !== id))}
332
+ >
333
+ Γ—
334
+ </button>
335
+ </span>
336
+ ))}
337
+ <select
338
+ className="cg-select cg-cond-val cg-cond-cohort-add"
339
+ value=""
340
+ aria-label="Cohort"
341
+ disabled={!canAdd}
342
+ onChange={(e) => e.target.value && setIds([...chosen, e.target.value])}
343
+ >
344
+ {/* "nothing left to add" rendered as "nothing left to ad…" at 136px β€” the fourth time a
345
+ closed vocabulary has outgrown its control here. Shortened, not widened: the control
346
+ is sized so a COHORT NAME stays readable, and that is the string that matters. */}
347
+ <option value="">
348
+ {!canAdd ? "all added" : chosen.length ? "Add a cohort…" : "Select a cohort…"}
349
+ </option>
350
+ {remaining.map((l) => (
351
+ <option key={l.id} value={l.id}>
352
+ {l.name}
353
+ </option>
354
+ ))}
355
+ </select>
356
+ </span>
357
+ );
358
+ }
359
+
360
+ // --- Filter: an Airtable-style RECURSIVE condition builder -----------------
361
+ // Anatomy per Airtable (captured from the live product 2026-07-25):
362
+ // row 0 : static "Where"
363
+ // row 1 : the and/or DROPDOWN β€” one conjunction per LEVEL, not per condition
364
+ // row 2..n : static text mirroring that level's conjunction
365
+ // a group : inset panel + summary line ("All/Any of the following are true…")
366
+ // carrying its OWN conjunction and its own Where/and-or rows
367
+ // Nesting is capped at MAX_FILTER_DEPTH (3) β€” Airtable grays the group button out.
368
+
369
+ /** The leading cell of a condition row: "Where", the conjunction picker, or the mirror. */
370
+ function RowPrefix({
371
+ index,
372
+ conj,
373
+ onConj,
374
+ }: {
375
+ index: number;
376
+ conj: Conjunction;
377
+ onConj: (c: Conjunction) => void;
378
+ }) {
379
+ if (index === 0) return <span className="cg-cond-prefix">Where</span>;
380
+ if (index === 1)
381
+ return (
382
+ <select
383
+ className="cg-select cg-cond-conj"
384
+ value={conj}
385
+ aria-label="Combine conditions with"
386
+ onChange={(e) => onConj(e.target.value as Conjunction)}
387
+ >
388
+ <option value="and">and</option>
389
+ <option value="or">or</option>
390
+ </select>
391
+ );
392
+ return <span className="cg-cond-prefix cg-cond-conj-echo">{conj}</span>;
393
+ }
394
+
395
+ /** Everything a level of the tree needs that does not change as it recurses. One object rather
396
+ * than eight repeated props: the list grew past the point where each level restating it made
397
+ * the recursion easier to read. */
398
+ interface LevelContext {
399
+ fields: Field[];
400
+ fieldByKey: Map<string, Field>;
401
+ measures: Measure[];
402
+ measureByKey: Map<string, Measure>;
403
+ /** Cohorts this user has β€” the vocabulary of `Where [Cohort] [is part of] […]`. */
404
+ lists: { id: string; name: string }[];
405
+ choicesFor: ChoicesFor;
406
+ }
407
+
408
+ /**
409
+ * One nesting level: renders its nodes (leaves and nested groups) plus the
410
+ * add-buttons. Recurses through GroupNode for children that are groups.
411
+ */
412
+ function ConditionLevel({
413
+ nodes,
414
+ conj,
415
+ depth,
416
+ ctx,
417
+ onNodes,
418
+ onConj,
419
+ }: {
420
+ nodes: FilterNode[];
421
+ conj: Conjunction;
422
+ /** 1 = the root level. A new group's depth would be depth + 1. */
423
+ depth: number;
424
+ ctx: LevelContext;
425
+ onNodes: (nodes: FilterNode[]) => void;
426
+ onConj: (c: Conjunction) => void;
427
+ }) {
428
+ /** Item 13 / R9 β€” which add-link was last used at THIS level. Per level, because the state
429
+ * belongs to the row of buttons the user clicked, not to the builder as a whole. */
430
+ const [lastAdd, setLastAdd] = useState<"condition" | "group" | null>(null);
431
+ const setNode = (i: number, next: FilterNode) =>
432
+ onNodes(nodes.map((n, j) => (j === i ? next : n)));
433
+ const removeNode = (i: number) => onNodes(nodes.filter((_, j) => j !== i));
434
+
435
+ const addCondition = () => {
436
+ const c = newCondition(ctx.fields);
437
+ if (c) onNodes([...nodes, c]);
438
+ };
439
+ // New groups default to "or" β€” matching Airtable, and the useful case (the
440
+ // root ANDs a set of alternatives together).
441
+ const addGroup = () => {
442
+ const c = newCondition(ctx.fields);
443
+ if (c) onNodes([...nodes, { conj: "or", children: [c] }]);
444
+ };
445
+ const canNest = depth < MAX_FILTER_DEPTH;
446
+
447
+ return (
448
+ <>
449
+ {nodes.map((node, i) => (
450
+ <div className="cg-cond-line" key={i}>
451
+ <RowPrefix index={i} conj={conj} onConj={onConj} />
452
+ {isFilterGroup(node) ? (
453
+ <GroupNode
454
+ group={node}
455
+ depth={depth + 1}
456
+ ctx={ctx}
457
+ onGroup={(g) => setNode(i, g)}
458
+ onRemove={() => removeNode(i)}
459
+ />
460
+ ) : (
461
+ <LeafRow
462
+ rule={node}
463
+ ctx={ctx}
464
+ onRule={(r) => setNode(i, r)}
465
+ onRemove={() => removeNode(i)}
466
+ />
467
+ )}
468
+ </div>
469
+ ))}
470
+ {/* Item 13 (owner, 2026-08-02) β€” the two add-links are BOLD, and the one you just used
471
+ holds a dark-blue state.
472
+ ⚠ R9 says that state lasts while the popover is open and a fresh open starts
473
+ neutral β€” which is exactly what a `useState` HERE gives for free, because `Popover`
474
+ renders its children only while open (`{open && <AnchoredOverlay…>}`) and unmounts
475
+ them on close. Nothing persists it, nothing has to clear it, and there is no way for
476
+ it to survive a reopen. A `:active` pseudo-class would have been the wrong tool
477
+ twice over: it lasts for the duration of the mouse-down, and it says "you are
478
+ pressing this" rather than "this is the one you have been using". */}
479
+ <div className="cg-builder-add">
480
+ <button
481
+ type="button"
482
+ className={"cg-link-btn" + (lastAdd === "condition" ? " is-on" : "")}
483
+ onClick={() => {
484
+ setLastAdd("condition");
485
+ addCondition();
486
+ }}
487
+ >
488
+ + Add condition
489
+ </button>
490
+ <button
491
+ type="button"
492
+ className={"cg-link-btn" + (lastAdd === "group" ? " is-on" : "")}
493
+ onClick={() => {
494
+ setLastAdd("group");
495
+ addGroup();
496
+ }}
497
+ disabled={!canNest}
498
+ title={
499
+ canNest
500
+ ? "Group conditions so they evaluate together"
501
+ : `Condition groups can nest ${MAX_FILTER_DEPTH} levels deep`
502
+ }
503
+ >
504
+ + Add condition group
505
+ </button>
506
+ </div>
507
+ </>
508
+ );
509
+ }
510
+
511
+ /**
512
+ * One leaf condition: field β–Ύ | (window β–Ύ) | operator β–Ύ | value | remove.
513
+ *
514
+ * The window bracket appears only for a MEASURE (CG-8) β€” a column already has its window baked
515
+ * in ("YTD $" IS year-to-date), and offering one there would imply it could be changed.
516
+ */
517
+ function LeafRow({
518
+ rule,
519
+ ctx,
520
+ onRule,
521
+ onRemove,
522
+ }: {
523
+ rule: FilterRule;
524
+ ctx: LevelContext;
525
+ onRule: (r: FilterRule) => void;
526
+ onRemove: () => void;
527
+ }) {
528
+ const { fields, fieldByKey, measures, measureByKey, lists, choicesFor } = ctx;
529
+ const isMeasure = isMeasureRule(rule);
530
+ const isCohort = rule.colId === COHORT_FIELD;
531
+ const hasCohorts = lists.length > 0;
532
+ const t = isMeasure
533
+ ? measureByKey.get(rule.colId)?.type ?? "currency"
534
+ : fieldByKey.get(rule.colId)?.type ?? "text";
535
+ const patch = (p: Partial<FilterRule>) => onRule({ ...rule, ...p });
536
+ // Switching field resets op to that type's default + clears the value(s), so a
537
+ // numeric op can never linger on a text column. Crossing the column/measure line also
538
+ // adds or removes the WINDOW and the rule ID, since those are what MAKE it a measure β€”
539
+ // leaving a window behind on a column condition would keep the server answering it. The
540
+ // rhs goes too: a comparand chosen for the old field is meaningless against the new one.
541
+ const onField = (key: string) => {
542
+ if (key === COHORT_FIELD) {
543
+ onRule({ colId: COHORT_FIELD, op: COHORT_OP_LIST[0], value: "" });
544
+ return;
545
+ }
546
+ const m = measureByKey.get(key);
547
+ if (m) {
548
+ onRule({
549
+ id: rule.id ?? newRuleId(),
550
+ colId: key,
551
+ op: MEASURE_OP_LIST[0],
552
+ value: "",
553
+ value2: undefined,
554
+ window: rule.window ?? DEFAULT_WINDOW,
555
+ });
556
+ return;
557
+ }
558
+ const nt = fieldByKey.get(key)?.type ?? "text";
559
+ onRule({ colId: key, op: opsForType(nt)[0], value: "", value2: undefined });
560
+ };
561
+ // Switching to/from a value-free op clears the stale value it can't show. `within` swaps the
562
+ // value for a RANGE and drops any field comparand, since a range has no other side.
563
+ const onOp = (op: FilterOp | CohortOp) => {
564
+ // A cohort keeps its SET when the set operator changes β€” "any of these three" and "none of
565
+ // these three" are the same three cohorts asked about differently, and clearing them would
566
+ // make the obvious edit destructive.
567
+ if (isCohort) return patch({ op });
568
+ // C-OPS: a rank op arrives with its N already chosen (see rankDefault) and drops every
569
+ // comparand β€” "the top 10 compared with another field" is not a question.
570
+ if (isRankOp(op as string))
571
+ return onRule({ ...rule, op: op as FilterOp, value: rankDefault(op as FilterOp),
572
+ value2: undefined, rhs: undefined, dateWindow: undefined });
573
+ if (VALUE_FREE_OPS.has(op as FilterOp))
574
+ return onRule({ ...rule, op, value: "", value2: undefined, rhs: undefined });
575
+ if (op === "within")
576
+ return onRule({ ...rule, op, value: "", value2: undefined, rhs: undefined,
577
+ dateWindow: rule.dateWindow ?? DEFAULT_DATE_WINDOW });
578
+ if (rule.op === "within")
579
+ return onRule({ ...rule, op, dateWindow: undefined, value: "" });
580
+ patch({ op });
581
+ };
582
+ // Owner item 4: the value box is REPLACEABLE by another attribute. Offered where a
583
+ // comparison between two of the same kind of thing means something β€” numbers against
584
+ // numbers, dates against dates, a measure against a measure over its own period.
585
+ const rhsOptions: FieldSelectItem[] = isMeasure
586
+ ? measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK }))
587
+ : fields
588
+ .filter((f) => f.key !== rule.colId
589
+ && (isNumericType(t) ? isNumericType(f.type) : f.type === t))
590
+ .map((f) => ({ key: f.key, label: f.label, type: f.type }));
591
+ const canCompareField = !isCohort && rule.op !== "within"
592
+ && !VALUE_FREE_OPS.has(rule.op as FilterOp) && !isRankOp(rule.op)
593
+ && rhsOptions.length > 0 && (isMeasure || isNumericType(t) || isDateFamily(t));
594
+ const usingStat = rule.rhs?.kind === "stat";
595
+ const usingField = rule.rhs != null && !usingStat;
596
+ // Explicitly widened: the cohort branch contributes a DISJOINT vocabulary, and without the
597
+ // annotation `withCurrent`'s `T extends string` infers from the first branch alone.
598
+ const opList: (FilterOp | CohortOp)[] = isMeasure
599
+ ? (usingField || usingStat ? MEASURE_PAIR_OP_LIST : MEASURE_OP_LIST)
600
+ : isCohort ? COHORT_OP_LIST : opsForType(t);
601
+ const opValue: FilterOp | CohortOp = isCohort ? normalizeCohortOp(rule.op) : rule.op;
602
+ // C-OPS β€” the RANK family, offered under its own divider for the numeric family only.
603
+ //
604
+ // A COLUMN only: a measure leaf is answered by a server-side pid set over a window, so
605
+ // ranking one client-side would be a second answer to the same question (and the host's
606
+ // MEASURE_OPS deliberately does not carry these). A cohort leaf is set membership.
607
+ //
608
+ // Offered separately rather than appended to `opsForType` because these operators are a
609
+ // different KIND of question β€” every other one is about this row's value, these are about
610
+ // where the row sits among the others β€” and a divider is the cheapest way to say so.
611
+ const canRank = !isMeasure && !isCohort && isNumericType(t);
612
+ // `withCurrent` runs over the COMBINED offer so a rule already holding a rank op is not
613
+ // appended a second time; the split below then puts each op under the right heading. A
614
+ // rank op on a field that can no longer be ranked still renders β€” as itself, in the group,
615
+ // which is what lets somebody see it and change it.
616
+ const offeredOps = withCurrent(canRank ? [...opList, ...RANK_OPS] : opList, opValue);
617
+ const plainOps = offeredOps.filter((op) => !isRankOp(op));
618
+ const rankedOps = offeredOps.filter((op) => isRankOp(op));
619
+
620
+ return (
621
+ <span className={"cg-cond-row" + (isMeasure ? " is-measure" : "")
622
+ + (isCohort ? " is-cohort" : "")
623
+ + (rule.rhs != null ? " has-rhs" : "")}>
624
+ {/* ONE flat list (owner item 1). A measure used to sit under a "Measures (pick a
625
+ period)" optgroup; the owner's instruction was that it is all the same list β€”
626
+ you pick a thing to filter on, and a measure simply also asks for a period. Item 20
627
+ adds the type MARK to each row and takes NO grouping with it: the marks are what the
628
+ optgroup was reaching for, and they say it per row instead of splitting the list. */}
629
+ <FieldSelectButton
630
+ className="cg-cond-field"
631
+ ariaLabel="Field"
632
+ value={rule.colId}
633
+ onChange={onField}
634
+ fields={[
635
+ ...withCurrentField(fields, rule.colId, fieldByKey, measureByKey, hasCohorts),
636
+ ...measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK })),
637
+ // A cohort leaf asks about membership of a SET, which is the multiselect mark's
638
+ // own meaning β€” the vocabulary already had the right glyph for it.
639
+ ...(hasCohorts
640
+ ? [{ key: COHORT_FIELD, label: "Cohort", type: "multiselect" as const }]
641
+ : []),
642
+ ]}
643
+ />
644
+ {isMeasure && (
645
+ <WindowPicker
646
+ window={rule.window ?? DEFAULT_WINDOW}
647
+ onWindow={(w) => patch({ window: w })}
648
+ />
649
+ )}
650
+ <select
651
+ className="cg-select cg-cond-op"
652
+ // A view saved before 2026-07-27 holds `eq`/`neq`. Normalising HERE, rather than letting
653
+ // `withCurrent` append it, is the difference between a legacy rule reading "is any of"
654
+ // once and the list carrying two options with the same label and different values. The
655
+ // display stays truthful either way β€” `eq` IS `anyOf` β€” and the first edit writes the
656
+ // new op, matching what the validator already does on the server.
657
+ value={opValue}
658
+ aria-label="Condition"
659
+ onChange={(e) => onOp(e.target.value as FilterOp | CohortOp)}
660
+ >
661
+ {plainOps.map((op) => (
662
+ <option key={op} value={op}>
663
+ {/* `op as FilterOp` is safe by construction, not by hope: `opList` is the cohort
664
+ vocabulary EXACTLY when `isCohort`, so the other branch only ever sees the
665
+ column/measure one. The two lists are disjoint, so a wrong cast would show as a
666
+ raw key ("anyOf") on screen rather than as a plausible label. */}
667
+ {isCohort ? cohortOpLabel(op) : opLabel(op as FilterOp, t)}
668
+ </option>
669
+ ))}
670
+ {rankedOps.length > 0 && (
671
+ <optgroup label="Ranked">
672
+ {rankedOps.map((op) => (
673
+ <option key={op} value={op}>
674
+ {opLabel(op as FilterOp, t)}
675
+ </option>
676
+ ))}
677
+ </optgroup>
678
+ )}
679
+ </select>
680
+ {canCompareField && (
681
+ // The switch between "a value I type" and "another attribute". Two options rather than
682
+ // a checkbox, because the row already reads as a sentence and this is the noun slot.
683
+ <select
684
+ className="cg-select cg-cond-mode"
685
+ value={usingStat ? "stat" : usingField ? "field" : "value"}
686
+ aria-label="Compare against"
687
+ onChange={(e) => {
688
+ const mode = e.target.value;
689
+ if (mode === "stat")
690
+ return patch({ rhs: { kind: "stat", stat: "median" },
691
+ value: "", value2: undefined,
692
+ // a statistic is a computed cut-off: the same four ops a
693
+ // measure-vs-measure allows, for the same float reason
694
+ // reachable only via `canCompareField`, which excludes a cohort
695
+ op: MEASURE_PAIR_OP_LIST.includes(rule.op as FilterOp)
696
+ ? rule.op : MEASURE_PAIR_OP_LIST[1] });
697
+ if (mode === "field") {
698
+ // An EMPTY comparand: incomplete, so the rule stays inactive until a field is
699
+ // picked. RhsPicker adds the window when the measure side gets one.
700
+ const empty: FilterRhs = isMeasure
701
+ ? { kind: "measure", colId: "", window: DEFAULT_WINDOW }
702
+ : { kind: "field", colId: "" };
703
+ return patch({ rhs: empty, value: "", value2: undefined });
704
+ }
705
+ patch({ rhs: undefined, value: "" });
706
+ }}
707
+ >
708
+ <option value="value">a value</option>
709
+ <option value="field">another field</option>
710
+ {/* Statistics are computed SERVER-SIDE over the caller's pool, so they are offered
711
+ for a MEASURE only. A column statistic would need a second percentile
712
+ implementation on the client and the lock-step gate that goes with it. */}
713
+ {isMeasure && <option value="stat">a statistic</option>}
714
+ </select>
715
+ )}
716
+ {/* value-free ops render NO value control (Airtable does the same) */}
717
+ {VALUE_FREE_OPS.has(rule.op as FilterOp) ? (
718
+ <span className="cg-cond-val cg-cond-noval" aria-hidden />
719
+ ) : usingStat ? (
720
+ <select
721
+ className="cg-select cg-cond-val cg-cond-stat"
722
+ value={rule.rhs?.kind === "stat" ? rule.rhs.stat : "median"}
723
+ aria-label="Statistic"
724
+ onChange={(e) => patch({ rhs: { kind: "stat", stat: e.target.value as StatKind } })}
725
+ >
726
+ {STAT_KINDS.map((k) => (
727
+ <option key={k} value={k}>
728
+ {STAT_LABELS[k]}
729
+ </option>
730
+ ))}
731
+ </select>
732
+ ) : usingField ? (
733
+ <RhsPicker rule={rule} options={rhsOptions} isMeasure={isMeasure} onPatch={patch} />
734
+ ) : isCohort ? (
735
+ <CohortPicker rule={rule} lists={lists} onPatch={patch} />
736
+ ) : isRankOp(rule.op) ? (
737
+ <RankValue rule={rule} onValue={(v) => patch({ value: v })} />
738
+ ) : rule.op === "within" ? (
739
+ <WindowPicker
740
+ window={rule.dateWindow ?? DEFAULT_DATE_WINDOW}
741
+ onWindow={(w) => patch({ dateWindow: w })}
742
+ kinds={WITHIN_KINDS}
743
+ />
744
+ ) : isDateFamily(t) ? (
745
+ <AnchorValue rule={rule} onPatch={patch} />
746
+ ) : (
747
+ <ConditionValue
748
+ rule={rule}
749
+ type={t}
750
+ statusOptions={choicesFor(rule.colId)}
751
+ onValue={(v) => patch({ value: v })}
752
+ onValue2={(v) => patch({ value2: v })}
753
+ />
754
+ )}
755
+ <button
756
+ type="button"
757
+ className="cg-cond-x"
758
+ aria-label="Remove condition"
759
+ onClick={onRemove}
760
+ >
761
+ Γ—
762
+ </button>
763
+ </span>
764
+ );
765
+ }
766
+
767
+ /** A nested condition group: summary line + its own recursive level. */
768
+ function GroupNode({
769
+ group,
770
+ depth,
771
+ ctx,
772
+ onGroup,
773
+ onRemove,
774
+ }: {
775
+ group: FilterGroup;
776
+ depth: number;
777
+ ctx: LevelContext;
778
+ onGroup: (g: FilterGroup) => void;
779
+ onRemove: () => void;
780
+ }) {
781
+ return (
782
+ <span className="cg-cond-group" data-depth={depth}>
783
+ <span className="cg-cond-group-head">
784
+ <span className="cg-cond-group-sum">
785
+ {group.children.length
786
+ ? groupSummary(group.conj)
787
+ : "Add a condition to this group…"}
788
+ </span>
789
+ <button
790
+ type="button"
791
+ className="cg-cond-x"
792
+ aria-label="Remove condition group"
793
+ onClick={onRemove}
794
+ >
795
+ Γ—
796
+ </button>
797
+ </span>
798
+ <span className="cg-cond-group-body">
799
+ <ConditionLevel
800
+ nodes={group.children}
801
+ conj={group.conj}
802
+ depth={depth}
803
+ ctx={ctx}
804
+ onNodes={(children) => onGroup({ ...group, children })}
805
+ onConj={(conj) => onGroup({ ...group, conj })}
806
+ />
807
+ </span>
808
+ </span>
809
+ );
810
+ }
811
+
812
+ export interface FilterBuilderPanelProps {
813
+ /**
814
+ * The module's COMPLETE field list. The panel offers the FILTERABLE ones
815
+ * (`filterableFields`) and resolves labels from all of them β€” a condition saved against a
816
+ * column that has since stopped being filterable must still render its own name rather than
817
+ * turning into "?", so narrowing the picker may never orphan history.
818
+ */
819
+ fields: Field[];
820
+ /** The whole filter as ONE value β€” nodes plus the root conjunction. See types.FilterTree. */
821
+ filters: FilterTree;
822
+ onChange: (next: FilterTree) => void;
823
+ /** Cohorts the subject of this filter has, for `Where [Cohort] [is any of] […]`. */
824
+ cohorts?: { id: string; name: string }[];
825
+ /** CG-8 β€” measures the builder may offer alongside the columns. Absent = columns only. */
826
+ measures?: Measure[];
827
+ /** The people a `user` field may name. Never invented here β€” the host's real user list. */
828
+ userOptions?: string[];
829
+ /**
830
+ * The tenant's today, ISO. RESERVED: it is the anchor a relative window resolves against,
831
+ * and it is accepted so a caller that holds it (the grid, the admin editor) has one obvious
832
+ * place to put it. It renders NOTHING today β€” a "this resolves to <range>" hint under the
833
+ * date controls was considered for wave 15 and rejected as a visual delta C-KIT forbids.
834
+ * Kept in the signature rather than added later so no call site has to change to gain it.
835
+ */
836
+ today?: string;
837
+ /**
838
+ * The per-field CHOICE VOCABULARY, by field key. The one prop that is not cosmetic when it
839
+ * is absent: some columns take their values from the DATA (the grid discovers them from its
840
+ * rows β€” `customer-grid/types.ts::choiceVocabulary`), which a panel over a module whose rows
841
+ * it has never loaded cannot do. Absent, choices fall back to the field's own declared
842
+ * `options` and, for `user`, to `userOptions`.
843
+ *
844
+ * β›” **Which columns those are is NOT decided by the type, and the old wording here saying
845
+ * "only Odoo `status` columns come up empty" was wrong in a way that shipped a live defect**
846
+ * (owner item 24, 2026-08-06). `stock_bucket` on the Product grid is declared `select` and
847
+ * declares no `options` β€” its vocabulary is computed server-side and rides the rows, exactly
848
+ * like a `status` column's. **Any column with no declared `options` needs this prop**, whatever
849
+ * its type says. A host that cannot enumerate one renders an empty picker; see `choicesFor`.
850
+ */
851
+ statusValues?: Record<string, string[]>;
852
+ /**
853
+ * Item 12 (C-LOCK) β€” this view is LOCKED to a cohort. Present = say so, because the lock
854
+ * narrows the list and is not one of the conditions shown here: without the banner the count
855
+ * and the conditions disagree with no visible reason.
856
+ *
857
+ * `name`/`count` ABSENT means the reader cannot see the set β€” a shared view locked to
858
+ * somebody else's cohort. The engine matches nothing in that case (fail-closed, the
859
+ * cohort-leaf law), so the banner has to say which kind of nothing this is.
860
+ */
861
+ cohortLock?: { name?: string; count?: number };
862
+ /**
863
+ * Extra footer content, rendered beside "Clear all" β€” the grid puts its "Copy from another
864
+ * view" door here. A SLOT rather than props, exactly like the toolbar's `cohortAction`: the
865
+ * door needs the other views and the view-patch plumbing, none of which a panel that also
866
+ * serves an admin editor should know about.
867
+ */
868
+ footerExtra?: ReactNode;
869
+ }
870
+
871
+ export function FilterBuilderPanel({
872
+ fields,
873
+ filters,
874
+ onChange,
875
+ cohorts = [],
876
+ measures = [],
877
+ userOptions,
878
+ statusValues,
879
+ cohortLock,
880
+ footerExtra,
881
+ }: FilterBuilderPanelProps) {
882
+ const nodes = filterTreeNodes(filters);
883
+ const conj = filterTreeConj(filters);
884
+ // The COMPLETE map on purpose (see `fields` above): the picker is narrowed, the label
885
+ // resolution is not.
886
+ const fieldByKey = useMemo(() => {
887
+ const m = new Map<string, Field>();
888
+ for (const f of fields) m.set(f.key, f);
889
+ return m;
890
+ }, [fields]);
891
+ const condFields = useMemo(() => filterableFields(fields), [fields]);
892
+ const measureByKey = useMemo(() => {
893
+ const m = new Map<string, Measure>();
894
+ for (const item of measures) m.set(item.key, item);
895
+ return m;
896
+ }, [measures]);
897
+ const choicesFor = useCallback<ChoicesFor>(
898
+ (colId) => {
899
+ // A supplied map WINS, including when it holds an empty list: "this status column has no
900
+ // values in the data" is an answer, not a missing one.
901
+ const declared = statusValues?.[colId];
902
+ if (declared) return declared;
903
+ const f = fieldByKey.get(colId);
904
+ if (!f) return [];
905
+ return f.type === "user" ? userOptions ?? [] : choiceOptions(f);
906
+ },
907
+ [statusValues, fieldByKey, userOptions]
908
+ );
909
+ const ctx = useMemo<LevelContext>(
910
+ () => ({ fields: condFields, fieldByKey, measures, measureByKey, lists: cohorts,
911
+ choicesFor }),
912
+ [condFields, fieldByKey, measures, measureByKey, cohorts, choicesFor]
913
+ );
914
+
915
+ return (
916
+ <div className="cg-pop-body cg-builder">
917
+ <div className="cg-pop-title">Filter</div>
918
+ {/* C-LOCK (item 12) β€” the lock is NOT a condition row: it cannot be written here and
919
+ cannot be removed here (the view menu owns it). So it has to be SAID here, above
920
+ the conditions it bounds β€” otherwise the count and the conditions disagree and the
921
+ builder shows nothing that explains the gap. A reader who cannot see the set gets a
922
+ different sentence, because the engine shows them nobody and "no records" alone does
923
+ not distinguish a permission wall from a filter that genuinely matches nobody. */}
924
+ {cohortLock && (
925
+ <div className="cg-flt-lockchip">
926
+ {cohortLock.name
927
+ ? `Customer list locked to ${cohortLock.name}` +
928
+ (typeof cohortLock.count === "number"
929
+ ? ` Β· ${cohortLock.count.toLocaleString()} ${
930
+ cohortLock.count === 1 ? "customer" : "customers"
931
+ }`
932
+ : "")
933
+ : "Customer list locked to a set you cannot see"}
934
+ </div>
935
+ )}
936
+ {nodes.length === 0 ? (
937
+ <div className="cg-builder-empty">
938
+ No conditions. Add one to narrow the list.
939
+ </div>
940
+ ) : (
941
+ <div className="cg-builder-caption">In this view, show records</div>
942
+ )}
943
+ <ConditionLevel
944
+ nodes={nodes}
945
+ conj={conj}
946
+ depth={1}
947
+ ctx={ctx}
948
+ onNodes={(next) => onChange({ conj, nodes: next })}
949
+ onConj={(next) => onChange({ conj: next, nodes })}
950
+ />
951
+ {/* β›”β›” THE RANK EXPLAINER WAS HERE AND IS DELETED (WAVE 34, R5). Owner, 2026-08-16:
952
+ *"Remove this stupid fucking explainer in our filter function… for our view why does it
953
+ show in the filter??"*
954
+
955
+ It was three sentences shown whenever a rank/quartile/decile condition entered the tree,
956
+ and the argument for it was not silly: a ranked condition genuinely answers a question
957
+ about the OTHER records, and nothing in the row says which others. What that argument
958
+ missed is that `DESIGN.md` Β§4's "never over-explain" law is not a style preference β€” a
959
+ paragraph that appears inside a control, every time you use that control, is read once
960
+ and then becomes furniture you have to look past forever. The person configuring a top-10
961
+ filter is not reading a tutorial; they are three clicks into a task.
962
+
963
+ ⚠ NOTHING WAS REPLACED WITH A TOOLTIP, and that is deliberate rather than an omission.
964
+ A tooltip here would be the same prose behind one more interaction, which is how "we
965
+ removed it" turns into "we hid it" β€” and the owner would find it again.
966
+
967
+ ⭐ The facts it carried are still TRUE and still matter (ranking is over the records the
968
+ other conditions keep; blanks are not ranked; quantiles are equal-size BY RANK, so two
969
+ identical values can land on opposite sides of a boundary). They belong in the docs, not
970
+ in the panel.
971
+
972
+ ⚠ AND `hasRankCondition` WENT WITH IT, WHICH THIS COMMENT INITIALLY GOT WRONG. The first
973
+ draft of this note said the predicate "KEEPS its other callers in `ops.ts`". It had none:
974
+ `tsc` (TS6133) named the import unread the moment the paragraph went, and a grep then
975
+ showed the only remaining reference was its OWN recursion. Deleted in the same edit, as
976
+ this comment's own rule demanded. Second time in one session that a confident claim about
977
+ who calls a symbol was refuted by the compiler within a minute. */}
978
+ {/* The footer carries "Clear all" (only when there is something to clear) and whatever
979
+ door the host mounts in `footerExtra`.
980
+ ⚠ The door is in the FOOTER rather than beside the add-links, where the reference
981
+ screenshot has it, for one structural reason: `.cg-builder-add` is emitted once per
982
+ NESTING LEVEL, so a link there would repeat inside every condition group and offer
983
+ to overwrite the whole view from inside one of its brackets. The footer is the one
984
+ place in this popover that means "the view", which is what the copy acts on. It is
985
+ also offered when there are NO conditions yet, which is when somebody most wants to
986
+ start from another view. */}
987
+ {(nodes.length > 0 || footerExtra) && (
988
+ <div className="cg-pop-footer cg-builder-footer">
989
+ {nodes.length > 0 && (
990
+ <button
991
+ type="button"
992
+ className="cg-link-btn"
993
+ onClick={() => onChange({ conj, nodes: [] })}
994
+ >
995
+ Clear all
996
+ </button>
997
+ )}
998
+ {footerExtra}
999
+ </div>
1000
+ )}
1001
+ </div>
1002
+ );
1003
+ }
web/src/filter-kit/ops.ts CHANGED
@@ -1,400 +1,400 @@
1
- // ---------------------------------------------------------------------------
2
- // filter-kit / ops.ts
3
- // The filter/sort operator VOCABULARY β€” which operators each field type offers,
4
- // and how each one reads to a human.
5
- //
6
- // Extracted verbatim from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so
7
- // the permission editor and the grid toolbar offer ONE vocabulary rather than two
8
- // that drift. The matching engine (matchFilter/makeComparator in useVisibleRows)
9
- // and its Python mirrors (filter_sql.py, filter_eval.py) are the other halves of
10
- // the same contract; these are only the labels the popovers render.
11
- // ---------------------------------------------------------------------------
12
-
13
- import type {
14
- Conjunction,
15
- Field,
16
- FieldType,
17
- FilterNode,
18
- FilterOp,
19
- FilterRule,
20
- Measure,
21
- } from "../customer-grid/types";
22
- import {
23
- COHORT_FIELD, COHORT_OPS, MEASURE_OPS, MEASURE_PAIR_OPS, isFilterGroup,
24
- isDateFamilyType, isMeasureRule, isNumericFieldType, isRankOp, normalizeCohortOp,
25
- } from "../customer-grid/types";
26
- import type { CohortOp } from "../customer-grid/types";
27
- import type { FieldSelectItem, FieldSelectType } from "../customer-grid/FieldSelect";
28
- import type { WindowKind, WindowSpec } from "../customer-grid/windows";
29
-
30
- // The type families live in types.ts (ONE definition for every UI surface); the engine keeps
31
- // its own deliberately self-contained copies β€” see isNumericFieldType's doc.
32
- export const isNumericType = isNumericFieldType;
33
- export const isDateFamily = isDateFamilyType;
34
-
35
- /**
36
- * Operators offered for a field type (first = the default for new conditions).
37
- * Mirrors Airtable's own per-type sets, verified against the live product
38
- * (2026-07-25): a text field offers exactly contains / does not contain / is /
39
- * is not / is empty / is not empty. Every type ends with the two value-free ops.
40
- *
41
- * NOTE on isEmpty over the Customer List contract: several Odoo-derived money
42
- * columns (revenue_ytd, revenue_ly, at_risk, ltm_rev, orders_24m, est_missed) are
43
- * built with 0.0/0 defaults in customer_list.pool(), so "is empty" correctly
44
- * matches NOTHING there β€” 0 is a real value, not a blank. The columns where it
45
- * genuinely bites are last_order (''), days_since, typical_gap_days, yoy_pct, and
46
- * every overlay field (notes / custom) β€” "customers with no notes yet" is the
47
- * common case. Offered on all types anyway, exactly as Airtable does.
48
- *
49
- * ⚠ AND the SENTINEL columns, which are a third case and the one that surprises:
50
- * agent Β· city Β· state Β· country Β· zip Β· payment_terms Β· pricelist Β· tags all
51
- * collapse a blank to the STRING '(none)' in `_partner_attrs`, so grouping has no
52
- * null bucket. `isEmpty` tests `value === ""`, so on these it matches nothing even
53
- * though 76 customers have no country and 79 no zip β€” the blanks are found with
54
- * `is` `(none)`, which is also what the cell visibly says. Each of those fields
55
- * carries that sentence as its column `note`, because a source comment is not
56
- * where the person filtering is looking. DATE attributes keep '' instead
57
- * (customer_since, last_order): '(none)' in a date column would sort and compare
58
- * as text against ISO dates.
59
- */
60
- export function opsForType(t: FieldType): FilterOp[] {
61
- // Wave-5: a checkbox stores '1' or blank, so "is checked" IS `isNotEmpty` and "is
62
- // unchecked" IS `isEmpty` β€” the honest mapping onto the existing vocabulary (no new
63
- // FilterOp, the filter_sql lock-step untouched). Both are value-free, so no value control
64
- // renders. First = the default: "is checked".
65
- if (t === "checkbox") return ["isNotEmpty", "isEmpty"];
66
- if (isNumericType(t))
67
- return ["gte", "gt", "lte", "lt", "eq", "neq", "between", "isEmpty", "isNotEmpty"];
68
- if (isDateFamily(t))
69
- // Owner item 3, in the order they asked for it. `between` is GONE from the offer β€” "is
70
- // within" plus a custom range says the same thing and reads better β€” but it stays in the
71
- // ENGINE, because saved views hold it. `legacyOps` below is what keeps such a view
72
- // rendering its own operator instead of silently showing the first one in this list.
73
- return ["eq", "within", "lt", "gt", "lte", "gte", "neq", "isEmpty", "isNotEmpty"];
74
- // `select` and `user` are picked from a fixed list, so they behave like `status`: is / is
75
- // not / empty. `contains` on a chosen value is a substring test against a closed vocabulary β€”
76
- // it would find "Done" inside "Not done" and read as a bug.
77
- if (t === "status" || t === "select" || t === "user")
78
- return ["eq", "neq", "isEmpty", "isNotEmpty"];
79
- // A `multiselect` cell is a comma-joined SET, so the useful question is membership β€” "has" β€”
80
- // which is the EXISTING `contains` op over the joined string (no new engine vocabulary; the
81
- // ops are mirrored in filter_sql.py and inventing one here would break the lock-step). The
82
- // value is still picked from the declared options, so the substring caveat above is bounded
83
- // by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
84
- if (t === "multiselect")
85
- return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
86
- // ⭐ Wave-23 C7 β€” `json` gets EXACTLY the two value-free ops, and the omissions are the
87
- // decision. `contains` over a serialized document is a substring test against punctuation and
88
- // key names: `contains "12"` would match a key `id_12`, a value 12, a timestamp and a
89
- // fragment of 5120, and every one of those reads as a working filter. `eq` is worse β€” two
90
- // documents that mean the same thing differ by key order and whitespace, so "is" would answer
91
- // false for a record that plainly matches. "Has anything been captured here yet" is the
92
- // question a json column can answer soundly, and it is the one people actually ask.
93
- //
94
- // ⚠ NO NEW OPERATOR ENTERS EITHER ENGINE. `isEmpty`/`isNotEmpty` already exist in
95
- // `filter_sql.py` (:76, and :112's VALUE_FREE_OPS) and `filter_eval.py` (:260-262), and both
96
- // are TYPE-INDEPENDENT β€” they test blankness before any type dispatch β€” so the lock-step is
97
- // untouched and nothing on the server had to move for this line. (The contract's prose spells
98
- // them `is_empty`/`is_not_empty`; the runtimes do not. The names come from the code.)
99
- if (t === "json") return ["isNotEmpty", "isEmpty"];
100
- // Wave-18 C5-AUTOFIELD β€” `automation` lands HERE, on the text ops, and that is the whole
101
- // v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
102
- // `contains ok` and `contains error` are the two questions anyone actually asks of the
103
- // column, and both are already sound over a string. Nothing new enters the operator
104
- // vocabulary, so the `filter_sql.py` lock-step is untouched β€” a new op for "state is" would
105
- // have to be mirrored in the Python engine, and the substring answer is the same answer.
106
- return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"]; // text
107
- }
108
-
109
- /**
110
- * C-OPS β€” the RANK operators, worded so the row still reads as a sentence.
111
- *
112
- * The unit lives in the OPERATOR, not beside the box: "top" followed by a bare 10 is ambiguous
113
- * between ten records and ten percent, so `top N` and `top N%` are two operators and the box
114
- * supplies N. The alternative β€” a "%" suffix element after the input β€” would put a second
115
- * thing in the value slot that every gate locating `.cg-cond-val` would have to know about.
116
- *
117
- * ⚠ AND THEY ARE SHORT ON PURPOSE. A 136px `.cg-select` has ~97.9px of readable width (see
118
- * `cohortOpLabel` below, and `withCurrentField` below) β€” about 15 characters of 12.5px Inter.
119
- * "is in the bottom N%" is 19 and would have shipped CLIPPED, which no assertion here can see
120
- * and no screenshot in this session could catch. Dropping the "is " costs nothing: the numeric
121
- * operators beside these are bare glyphs (`β‰₯ > ≀ <`), so the row already reads without one,
122
- * and the `Ranked` divider carries the "these are a different kind of question" signal. Fifth
123
- * time this control has outgrown a label; the label gives way, never the control.
124
- */
125
- export const RANK_OP_LABELS: Record<string, string> = {
126
- topN: "top N",
127
- bottomN: "bottom N",
128
- inTopPct: "top N%",
129
- inBottomPct: "bottom N%",
130
- aboveAvg: "above average",
131
- belowAvg: "below average",
132
- inQuartile: "in quartile",
133
- inDecile: "in decile",
134
- };
135
-
136
- /**
137
- * The value a rank op starts at when it is chosen.
138
- *
139
- * Seeded rather than left blank, and this is a deliberate break from how the other operators
140
- * behave: a blank value makes a rule INACTIVE, so `is in the top N` with nothing typed would
141
- * sit in the builder narrowing nothing, and "the filter I just added does nothing" reads as a
142
- * broken control rather than as an unfinished sentence. Every rank op has an obvious default β€”
143
- * ten, or the top slice β€” so there is a right answer to seed. The two averages take none.
144
- */
145
- export function rankDefault(op: FilterOp): string {
146
- switch (op) {
147
- case "topN":
148
- case "bottomN":
149
- case "inTopPct":
150
- case "inBottomPct":
151
- case "inDecile":
152
- return "10";
153
- case "inQuartile":
154
- return "4";
155
- default:
156
- return ""; // aboveAvg / belowAvg β€” value-free
157
- }
158
- }
159
-
160
- /** Does this tree hold an ACTIVE-looking rank condition? Drives the builder's helper line β€”
161
- * the semantics of a quantile are not guessable, so they are stated where they are chosen. */
162
- export function hasRankCondition(nodes: FilterNode[]): boolean {
163
- for (const node of nodes ?? []) {
164
- if (isFilterGroup(node)) {
165
- if (hasRankCondition(node.children)) return true;
166
- continue;
167
- }
168
- if (isRankOp(node.op) && !isMeasureRule(node)) return true;
169
- }
170
- return false;
171
- }
172
-
173
- /** Human label for an operator, read naturally per field type. */
174
- export function opLabel(op: FilterOp, t: FieldType): string {
175
- // Rank first and type-independently: a rank op means the same thing on every numeric
176
- // family, and a view whose column has since changed type must still SAY what it holds
177
- // rather than falling through to a raw key.
178
- if (isRankOp(op)) return RANK_OP_LABELS[op] ?? op;
179
- // Checkbox first: its two value-free ops READ as states, not as blankness β€” the storage
180
- // contract ('1' or blank) is an implementation detail the sentence must not leak.
181
- if (t === "checkbox") {
182
- if (op === "isNotEmpty") return "is checked";
183
- if (op === "isEmpty") return "is unchecked";
184
- }
185
- // Type-independent: Airtable words these identically for every field type.
186
- if (op === "isEmpty") return "is empty";
187
- if (op === "isNotEmpty") return "is not empty";
188
- if (isDateFamily(t)) {
189
- switch (op) {
190
- case "eq":
191
- return "is";
192
- case "neq":
193
- return "is not";
194
- case "within":
195
- return "is within";
196
- case "lt":
197
- return "is before";
198
- case "lte":
199
- return "is on or before";
200
- case "gt":
201
- return "is after";
202
- case "gte":
203
- return "is on or after";
204
- case "between":
205
- return "is between"; // no longer offered; still rendered for a saved view
206
- }
207
- }
208
- if (isNumericType(t)) {
209
- switch (op) {
210
- case "gte":
211
- return "β‰₯"; // β‰₯
212
- case "gt":
213
- return ">";
214
- case "lte":
215
- return "≀"; // ≀
216
- case "lt":
217
- return "<";
218
- case "eq":
219
- return "=";
220
- case "neq":
221
- return "β‰ "; // β‰ 
222
- case "between":
223
- return "is between";
224
- default:
225
- return op;
226
- }
227
- }
228
- if (t === "multiselect") {
229
- // Membership wording over the joined-set cell. "has" IS `contains` (see opsForType) β€” only
230
- // the label changes, so the engine and its Python mirror stay untouched.
231
- switch (op) {
232
- case "contains":
233
- return "has";
234
- case "doesNotContain":
235
- return "does not have";
236
- case "eq":
237
- return "is exactly";
238
- case "neq":
239
- return "is not exactly";
240
- default:
241
- return op;
242
- }
243
- }
244
- // text / status. (Airtable suffixes value-taking ops with "…" in the OPEN
245
- // dropdown only; a native <select> shows one string in both states, so we use
246
- // the closed-state wording β€” which is what's on screen almost all the time.)
247
- switch (op) {
248
- case "contains":
249
- return "contains";
250
- case "doesNotContain":
251
- return "does not contain";
252
- case "eq":
253
- return "is";
254
- case "neq":
255
- return "is not";
256
- default:
257
- return op;
258
- }
259
- }
260
-
261
- /**
262
- * The offered list, PLUS whatever the rule is actually set to.
263
- *
264
- * A `<select>` whose `value` is not among its `<option>`s renders the FIRST option instead β€”
265
- * so a saved view holding a dropped operator (a date `between`) or a column that has since left
266
- * the filter list (`at_risk`, once its measure replacement shipped) would display something the
267
- * rule does not say, and the first edit would silently rewrite it to that. Painted, plausible,
268
- * and wrong: the class of failure a screenshot catches and an assertion does not.
269
- */
270
- export function withCurrent<T extends string>(offered: readonly T[], current: T | undefined): T[] {
271
- return current && !offered.includes(current) ? [...offered, current] : [...offered];
272
- }
273
-
274
- /** A group's summary line, mirroring Airtable's wording. */
275
- export function groupSummary(conj: Conjunction): string {
276
- return conj === "or"
277
- ? "Any of the following are true…"
278
- : "All of the following are true…";
279
- }
280
-
281
- /**
282
- * CG-8 β€” the operators a MEASURE condition may use, and why they are not `opsForType`.
283
- *
284
- * A measure is answered by a SQL `HAVING`, and `harness/measure_filter.OPS` has exactly six
285
- * comparisons. `between` would need two, and `isEmpty`/`isNotEmpty` have no meaning for a sum
286
- * that is defined to be 0 when there is nothing to add up β€” the resolver refuses all three by
287
- * name. Offering them here would produce a condition the server can only reject.
288
- *
289
- * The owner's "dates between X and Y" is the WINDOW's between, not the value's, and that is the
290
- * `custom` window kind β€” so nothing is lost.
291
- */
292
- export const MEASURE_OP_LIST: FilterOp[] = [...MEASURE_OPS];
293
-
294
- /** CG-9 β€” narrower still when the other side is a measure. `=` and `β‰ ` between two sums of
295
- * floats match nobody and everybody respectively; see types.ts MEASURE_PAIR_OPS. */
296
- export const MEASURE_PAIR_OP_LIST: FilterOp[] = [...MEASURE_PAIR_OPS];
297
-
298
- /**
299
- * A cohort condition names a SET of cohorts (owner, 2026-07-27), so its operators are set
300
- * operators. `eq`/`neq` are not offered β€” the validator rewrites them to `anyOf`/`noneOf`, and
301
- * `withCurrent` would otherwise show a legacy view an operator it can never choose again.
302
- */
303
- export const COHORT_OP_LIST: CohortOp[] = [...COHORT_OPS];
304
-
305
- /**
306
- * ⚠ "is part of any of" measures ~96px at 12.5px Inter against the 97.9px of readable width a
307
- * 136px select actually has (136 - 20.1 arrow - 16 padding - 2 border) β€” inside the box only
308
- * until a fallback font renders it. Airtable's own wording for a multi-select is shorter and
309
- * says the same thing, so the LABEL gives way, not the control. Third time this rule has been
310
- * applied; see the "(not filterable)" and "the 75th percentile" clippings before it.
311
- */
312
- export function cohortOpLabel(op: FilterOp | CohortOp): string {
313
- switch (normalizeCohortOp(op)) {
314
- case "allOf":
315
- return "is all of";
316
- case "noneOf":
317
- return "is none of";
318
- default:
319
- return "is any of";
320
- }
321
- }
322
-
323
- /** Item 20 β€” the mark a MEASURE row wears in every picker on this surface. Named once so the
324
- * three lists that offer measures cannot drift into two different glyphs for one concept. */
325
- export const MEASURE_MARK: FieldSelectType = "measure";
326
-
327
- /** A measure condition is identified by its window, so a new one needs a default window. */
328
- export const DEFAULT_WINDOW: WindowSpec = { kind: "ltm" };
329
-
330
- /** The default RANGE for `is within` β€” the owner's first example ("the past month"). */
331
- export const DEFAULT_DATE_WINDOW: WindowSpec = { kind: "past_month" };
332
-
333
- /**
334
- * The ranges `is within` offers, in the owner's own order (item 3, 2026-07-26):
335
- * the past number of days Β· the past week Β· the past month Β· the past year Β· this calendar
336
- * week Β· this calendar month Β· this calendar year.
337
- *
338
- * Plus `custom`, which is not a nicety: dropping `between` from the date operators would
339
- * otherwise remove the ability to ask for two exact dates at all. The remaining kinds
340
- * (yesterday, last_quarter, ytd_last_year, the forward-looking ones…) stay available to a
341
- * MEASURE's window, where they make sense; "last order is within year to date, last year" is a
342
- * question nobody asks.
343
- */
344
- export const WITHIN_KINDS: WindowKind[] = [
345
- "last_n_days", "past_week", "past_month", "past_year",
346
- "this_week", "this_month", "this_year", "custom",
347
- ];
348
-
349
- /**
350
- * The field dropdown's COLUMN half, plus the column the rule is actually set to if the picker
351
- * no longer offers it. See `withCurrent` β€” a select whose value is missing from its options
352
- * silently displays a different field than the rule holds. This is not hypothetical: the
353
- * shipped "Win-back" list filters `at_risk`, which left the filter list the day its measure
354
- * replacement arrived.
355
- */
356
- export function withCurrentField(
357
- fields: Field[],
358
- current: string,
359
- fieldByKey: Map<string, Field>,
360
- measureByKey: Map<string, Measure>,
361
- hasCohorts: boolean
362
- ): FieldSelectItem[] {
363
- // Item 20: the rows carry their TYPE now, because the picker paints the type mark. Same
364
- // list, same order, same `withCurrent` rule β€” only the shape of a row grew a field.
365
- const out: FieldSelectItem[] = fields.map((f) => ({
366
- key: f.key, label: f.label, type: f.type,
367
- }));
368
- const known = new Set(out.map((o) => o.key));
369
- if (!current || known.has(current) || measureByKey.has(current)) return out;
370
- if (current === COHORT_FIELD && hasCohorts) return out;
371
- // The field's OWN label, with no "(not filterable)" annotation: the select is ~170px and the
372
- // suffix CLIPPED to "At risk $ (not filter" β€” a truncated parenthetical reads as a rendering
373
- // bug, and the property that matters is that the condition says what it says. Found by
374
- // reading the screenshot; every assertion in the run was green.
375
- const f = fieldByKey.get(current);
376
- out.push({ key: current, label: f ? f.label : current, type: f?.type });
377
- return out;
378
- }
379
-
380
- /** Ids only have to be unique within one view's tree; they never leave this browser's view. */
381
- let ruleSeq = 0;
382
- export function newRuleId(): string {
383
- ruleSeq += 1;
384
- return `r${Date.now().toString(36)}${ruleSeq.toString(36)}`;
385
- }
386
-
387
- /** A fresh leaf condition on the first available field. */
388
- export function newCondition(fields: Field[]): FilterRule | null {
389
- const f0 = fields[0];
390
- if (!f0) return null;
391
- return { colId: f0.key, op: opsForType(f0.type)[0], value: "" };
392
- }
393
-
394
- /** Total LEAF conditions in a filter tree (groups contribute their contents). */
395
- export function countConditions(nodes: FilterNode[]): number {
396
- let n = 0;
397
- for (const node of nodes)
398
- n += isFilterGroup(node) ? countConditions(node.children) : 1;
399
- return n;
400
- }
 
1
+ // ---------------------------------------------------------------------------
2
+ // filter-kit / ops.ts
3
+ // The filter/sort operator VOCABULARY β€” which operators each field type offers,
4
+ // and how each one reads to a human.
5
+ //
6
+ // Extracted verbatim from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so
7
+ // the permission editor and the grid toolbar offer ONE vocabulary rather than two
8
+ // that drift. The matching engine (matchFilter/makeComparator in useVisibleRows)
9
+ // and its Python mirrors (filter_sql.py, filter_eval.py) are the other halves of
10
+ // the same contract; these are only the labels the popovers render.
11
+ // ---------------------------------------------------------------------------
12
+
13
+ import type {
14
+ Conjunction,
15
+ Field,
16
+ FieldType,
17
+ FilterNode,
18
+ FilterOp,
19
+ FilterRule,
20
+ Measure,
21
+ } from "../customer-grid/types";
22
+ import {
23
+ COHORT_FIELD, COHORT_OPS, MEASURE_OPS, MEASURE_PAIR_OPS, isFilterGroup,
24
+ isDateFamilyType, isNumericFieldType, isRankOp, normalizeCohortOp,
25
+ } from "../customer-grid/types";
26
+ import type { CohortOp } from "../customer-grid/types";
27
+ import type { FieldSelectItem, FieldSelectType } from "../customer-grid/FieldSelect";
28
+ import type { WindowKind, WindowSpec } from "../customer-grid/windows";
29
+
30
+ // The type families live in types.ts (ONE definition for every UI surface); the engine keeps
31
+ // its own deliberately self-contained copies β€” see isNumericFieldType's doc.
32
+ export const isNumericType = isNumericFieldType;
33
+ export const isDateFamily = isDateFamilyType;
34
+
35
+ /**
36
+ * Operators offered for a field type (first = the default for new conditions).
37
+ * Mirrors Airtable's own per-type sets, verified against the live product
38
+ * (2026-07-25): a text field offers exactly contains / does not contain / is /
39
+ * is not / is empty / is not empty. Every type ends with the two value-free ops.
40
+ *
41
+ * NOTE on isEmpty over the Customer List contract: several Odoo-derived money
42
+ * columns (revenue_ytd, revenue_ly, at_risk, ltm_rev, orders_24m, est_missed) are
43
+ * built with 0.0/0 defaults in customer_list.pool(), so "is empty" correctly
44
+ * matches NOTHING there β€” 0 is a real value, not a blank. The columns where it
45
+ * genuinely bites are last_order (''), days_since, typical_gap_days, yoy_pct, and
46
+ * every overlay field (notes / custom) β€” "customers with no notes yet" is the
47
+ * common case. Offered on all types anyway, exactly as Airtable does.
48
+ *
49
+ * ⚠ AND the SENTINEL columns, which are a third case and the one that surprises:
50
+ * agent Β· city Β· state Β· country Β· zip Β· payment_terms Β· pricelist Β· tags all
51
+ * collapse a blank to the STRING '(none)' in `_partner_attrs`, so grouping has no
52
+ * null bucket. `isEmpty` tests `value === ""`, so on these it matches nothing even
53
+ * though 76 customers have no country and 79 no zip β€” the blanks are found with
54
+ * `is` `(none)`, which is also what the cell visibly says. Each of those fields
55
+ * carries that sentence as its column `note`, because a source comment is not
56
+ * where the person filtering is looking. DATE attributes keep '' instead
57
+ * (customer_since, last_order): '(none)' in a date column would sort and compare
58
+ * as text against ISO dates.
59
+ */
60
+ export function opsForType(t: FieldType): FilterOp[] {
61
+ // Wave-5: a checkbox stores '1' or blank, so "is checked" IS `isNotEmpty` and "is
62
+ // unchecked" IS `isEmpty` β€” the honest mapping onto the existing vocabulary (no new
63
+ // FilterOp, the filter_sql lock-step untouched). Both are value-free, so no value control
64
+ // renders. First = the default: "is checked".
65
+ if (t === "checkbox") return ["isNotEmpty", "isEmpty"];
66
+ if (isNumericType(t))
67
+ return ["gte", "gt", "lte", "lt", "eq", "neq", "between", "isEmpty", "isNotEmpty"];
68
+ if (isDateFamily(t))
69
+ // Owner item 3, in the order they asked for it. `between` is GONE from the offer β€” "is
70
+ // within" plus a custom range says the same thing and reads better β€” but it stays in the
71
+ // ENGINE, because saved views hold it. `legacyOps` below is what keeps such a view
72
+ // rendering its own operator instead of silently showing the first one in this list.
73
+ return ["eq", "within", "lt", "gt", "lte", "gte", "neq", "isEmpty", "isNotEmpty"];
74
+ // `select` and `user` are picked from a fixed list, so they behave like `status`: is / is
75
+ // not / empty. `contains` on a chosen value is a substring test against a closed vocabulary β€”
76
+ // it would find "Done" inside "Not done" and read as a bug.
77
+ if (t === "status" || t === "select" || t === "user")
78
+ return ["eq", "neq", "isEmpty", "isNotEmpty"];
79
+ // A `multiselect` cell is a comma-joined SET, so the useful question is membership β€” "has" β€”
80
+ // which is the EXISTING `contains` op over the joined string (no new engine vocabulary; the
81
+ // ops are mirrored in filter_sql.py and inventing one here would break the lock-step). The
82
+ // value is still picked from the declared options, so the substring caveat above is bounded
83
+ // by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
84
+ if (t === "multiselect")
85
+ return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
86
+ // ⭐ Wave-23 C7 β€” `json` gets EXACTLY the two value-free ops, and the omissions are the
87
+ // decision. `contains` over a serialized document is a substring test against punctuation and
88
+ // key names: `contains "12"` would match a key `id_12`, a value 12, a timestamp and a
89
+ // fragment of 5120, and every one of those reads as a working filter. `eq` is worse β€” two
90
+ // documents that mean the same thing differ by key order and whitespace, so "is" would answer
91
+ // false for a record that plainly matches. "Has anything been captured here yet" is the
92
+ // question a json column can answer soundly, and it is the one people actually ask.
93
+ //
94
+ // ⚠ NO NEW OPERATOR ENTERS EITHER ENGINE. `isEmpty`/`isNotEmpty` already exist in
95
+ // `filter_sql.py` (:76, and :112's VALUE_FREE_OPS) and `filter_eval.py` (:260-262), and both
96
+ // are TYPE-INDEPENDENT οΏ½οΏ½ they test blankness before any type dispatch β€” so the lock-step is
97
+ // untouched and nothing on the server had to move for this line. (The contract's prose spells
98
+ // them `is_empty`/`is_not_empty`; the runtimes do not. The names come from the code.)
99
+ if (t === "json") return ["isNotEmpty", "isEmpty"];
100
+ // Wave-18 C5-AUTOFIELD β€” `automation` lands HERE, on the text ops, and that is the whole
101
+ // v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
102
+ // `contains ok` and `contains error` are the two questions anyone actually asks of the
103
+ // column, and both are already sound over a string. Nothing new enters the operator
104
+ // vocabulary, so the `filter_sql.py` lock-step is untouched β€” a new op for "state is" would
105
+ // have to be mirrored in the Python engine, and the substring answer is the same answer.
106
+ return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"]; // text
107
+ }
108
+
109
+ /**
110
+ * C-OPS β€” the RANK operators, worded so the row still reads as a sentence.
111
+ *
112
+ * The unit lives in the OPERATOR, not beside the box: "top" followed by a bare 10 is ambiguous
113
+ * between ten records and ten percent, so `top N` and `top N%` are two operators and the box
114
+ * supplies N. The alternative β€” a "%" suffix element after the input β€” would put a second
115
+ * thing in the value slot that every gate locating `.cg-cond-val` would have to know about.
116
+ *
117
+ * ⚠ AND THEY ARE SHORT ON PURPOSE. A 136px `.cg-select` has ~97.9px of readable width (see
118
+ * `cohortOpLabel` below, and `withCurrentField` below) β€” about 15 characters of 12.5px Inter.
119
+ * "is in the bottom N%" is 19 and would have shipped CLIPPED, which no assertion here can see
120
+ * and no screenshot in this session could catch. Dropping the "is " costs nothing: the numeric
121
+ * operators beside these are bare glyphs (`β‰₯ > ≀ <`), so the row already reads without one,
122
+ * and the `Ranked` divider carries the "these are a different kind of question" signal. Fifth
123
+ * time this control has outgrown a label; the label gives way, never the control.
124
+ */
125
+ export const RANK_OP_LABELS: Record<string, string> = {
126
+ topN: "top N",
127
+ bottomN: "bottom N",
128
+ inTopPct: "top N%",
129
+ inBottomPct: "bottom N%",
130
+ aboveAvg: "above average",
131
+ belowAvg: "below average",
132
+ inQuartile: "in quartile",
133
+ inDecile: "in decile",
134
+ };
135
+
136
+ /**
137
+ * The value a rank op starts at when it is chosen.
138
+ *
139
+ * Seeded rather than left blank, and this is a deliberate break from how the other operators
140
+ * behave: a blank value makes a rule INACTIVE, so `is in the top N` with nothing typed would
141
+ * sit in the builder narrowing nothing, and "the filter I just added does nothing" reads as a
142
+ * broken control rather than as an unfinished sentence. Every rank op has an obvious default β€”
143
+ * ten, or the top slice β€” so there is a right answer to seed. The two averages take none.
144
+ */
145
+ export function rankDefault(op: FilterOp): string {
146
+ switch (op) {
147
+ case "topN":
148
+ case "bottomN":
149
+ case "inTopPct":
150
+ case "inBottomPct":
151
+ case "inDecile":
152
+ return "10";
153
+ case "inQuartile":
154
+ return "4";
155
+ default:
156
+ return ""; // aboveAvg / belowAvg β€” value-free
157
+ }
158
+ }
159
+
160
+ /* β›” `hasRankCondition` LIVED HERE AND IS DELETED WITH THE PARAGRAPH IT GATED (wave 34, R5).
161
+ Its whole job was to decide whether to draw the builder's rank explainer; the owner has
162
+ removed that paragraph, and the predicate had exactly one caller besides its own recursion.
163
+ ⚠ `isRankOp` stays: `opLabel` below still calls it. `isMeasureRule` was `hasRankCondition`'s
164
+ ONLY use in this module, so THIS FILE'S IMPORT of it went too β€” the symbol itself lives on in
165
+ `types.ts` with plenty of callers, including `FilterBuilderPanel` two files over. The
166
+ distinction matters: an unused IMPORT is dead weight here, an unused EXPORT would be dead code
167
+ there, and only one of those was true.
168
+ ⚠⚠ THIS COMMENT'S FIRST DRAFT SAID BOTH SYMBOLS "STAY, they have many callers" β€” and `tsc`
169
+ refuted it in the next run. That is the THIRD time in one session a confident claim about who
170
+ calls a symbol was wrong within a minute of writing it. The habit worth keeping is not "be more
171
+ careful"; it is that the compiler answers this question for free, so ask it before asserting. */
172
+
173
+ /** Human label for an operator, read naturally per field type. */
174
+ export function opLabel(op: FilterOp, t: FieldType): string {
175
+ // Rank first and type-independently: a rank op means the same thing on every numeric
176
+ // family, and a view whose column has since changed type must still SAY what it holds
177
+ // rather than falling through to a raw key.
178
+ if (isRankOp(op)) return RANK_OP_LABELS[op] ?? op;
179
+ // Checkbox first: its two value-free ops READ as states, not as blankness β€” the storage
180
+ // contract ('1' or blank) is an implementation detail the sentence must not leak.
181
+ if (t === "checkbox") {
182
+ if (op === "isNotEmpty") return "is checked";
183
+ if (op === "isEmpty") return "is unchecked";
184
+ }
185
+ // Type-independent: Airtable words these identically for every field type.
186
+ if (op === "isEmpty") return "is empty";
187
+ if (op === "isNotEmpty") return "is not empty";
188
+ if (isDateFamily(t)) {
189
+ switch (op) {
190
+ case "eq":
191
+ return "is";
192
+ case "neq":
193
+ return "is not";
194
+ case "within":
195
+ return "is within";
196
+ case "lt":
197
+ return "is before";
198
+ case "lte":
199
+ return "is on or before";
200
+ case "gt":
201
+ return "is after";
202
+ case "gte":
203
+ return "is on or after";
204
+ case "between":
205
+ return "is between"; // no longer offered; still rendered for a saved view
206
+ }
207
+ }
208
+ if (isNumericType(t)) {
209
+ switch (op) {
210
+ case "gte":
211
+ return "β‰₯"; // β‰₯
212
+ case "gt":
213
+ return ">";
214
+ case "lte":
215
+ return "≀"; // ≀
216
+ case "lt":
217
+ return "<";
218
+ case "eq":
219
+ return "=";
220
+ case "neq":
221
+ return "β‰ "; // β‰ 
222
+ case "between":
223
+ return "is between";
224
+ default:
225
+ return op;
226
+ }
227
+ }
228
+ if (t === "multiselect") {
229
+ // Membership wording over the joined-set cell. "has" IS `contains` (see opsForType) β€” only
230
+ // the label changes, so the engine and its Python mirror stay untouched.
231
+ switch (op) {
232
+ case "contains":
233
+ return "has";
234
+ case "doesNotContain":
235
+ return "does not have";
236
+ case "eq":
237
+ return "is exactly";
238
+ case "neq":
239
+ return "is not exactly";
240
+ default:
241
+ return op;
242
+ }
243
+ }
244
+ // text / status. (Airtable suffixes value-taking ops with "…" in the OPEN
245
+ // dropdown only; a native <select> shows one string in both states, so we use
246
+ // the closed-state wording β€” which is what's on screen almost all the time.)
247
+ switch (op) {
248
+ case "contains":
249
+ return "contains";
250
+ case "doesNotContain":
251
+ return "does not contain";
252
+ case "eq":
253
+ return "is";
254
+ case "neq":
255
+ return "is not";
256
+ default:
257
+ return op;
258
+ }
259
+ }
260
+
261
+ /**
262
+ * The offered list, PLUS whatever the rule is actually set to.
263
+ *
264
+ * A `<select>` whose `value` is not among its `<option>`s renders the FIRST option instead β€”
265
+ * so a saved view holding a dropped operator (a date `between`) or a column that has since left
266
+ * the filter list (`at_risk`, once its measure replacement shipped) would display something the
267
+ * rule does not say, and the first edit would silently rewrite it to that. Painted, plausible,
268
+ * and wrong: the class of failure a screenshot catches and an assertion does not.
269
+ */
270
+ export function withCurrent<T extends string>(offered: readonly T[], current: T | undefined): T[] {
271
+ return current && !offered.includes(current) ? [...offered, current] : [...offered];
272
+ }
273
+
274
+ /** A group's summary line, mirroring Airtable's wording. */
275
+ export function groupSummary(conj: Conjunction): string {
276
+ return conj === "or"
277
+ ? "Any of the following are true…"
278
+ : "All of the following are true…";
279
+ }
280
+
281
+ /**
282
+ * CG-8 β€” the operators a MEASURE condition may use, and why they are not `opsForType`.
283
+ *
284
+ * A measure is answered by a SQL `HAVING`, and `harness/measure_filter.OPS` has exactly six
285
+ * comparisons. `between` would need two, and `isEmpty`/`isNotEmpty` have no meaning for a sum
286
+ * that is defined to be 0 when there is nothing to add up β€” the resolver refuses all three by
287
+ * name. Offering them here would produce a condition the server can only reject.
288
+ *
289
+ * The owner's "dates between X and Y" is the WINDOW's between, not the value's, and that is the
290
+ * `custom` window kind β€” so nothing is lost.
291
+ */
292
+ export const MEASURE_OP_LIST: FilterOp[] = [...MEASURE_OPS];
293
+
294
+ /** CG-9 β€” narrower still when the other side is a measure. `=` and `β‰ ` between two sums of
295
+ * floats match nobody and everybody respectively; see types.ts MEASURE_PAIR_OPS. */
296
+ export const MEASURE_PAIR_OP_LIST: FilterOp[] = [...MEASURE_PAIR_OPS];
297
+
298
+ /**
299
+ * A cohort condition names a SET of cohorts (owner, 2026-07-27), so its operators are set
300
+ * operators. `eq`/`neq` are not offered β€” the validator rewrites them to `anyOf`/`noneOf`, and
301
+ * `withCurrent` would otherwise show a legacy view an operator it can never choose again.
302
+ */
303
+ export const COHORT_OP_LIST: CohortOp[] = [...COHORT_OPS];
304
+
305
+ /**
306
+ * ⚠ "is part of any of" measures ~96px at 12.5px Inter against the 97.9px of readable width a
307
+ * 136px select actually has (136 - 20.1 arrow - 16 padding - 2 border) β€” inside the box only
308
+ * until a fallback font renders it. Airtable's own wording for a multi-select is shorter and
309
+ * says the same thing, so the LABEL gives way, not the control. Third time this rule has been
310
+ * applied; see the "(not filterable)" and "the 75th percentile" clippings before it.
311
+ */
312
+ export function cohortOpLabel(op: FilterOp | CohortOp): string {
313
+ switch (normalizeCohortOp(op)) {
314
+ case "allOf":
315
+ return "is all of";
316
+ case "noneOf":
317
+ return "is none of";
318
+ default:
319
+ return "is any of";
320
+ }
321
+ }
322
+
323
+ /** Item 20 β€” the mark a MEASURE row wears in every picker on this surface. Named once so the
324
+ * three lists that offer measures cannot drift into two different glyphs for one concept. */
325
+ export const MEASURE_MARK: FieldSelectType = "measure";
326
+
327
+ /** A measure condition is identified by its window, so a new one needs a default window. */
328
+ export const DEFAULT_WINDOW: WindowSpec = { kind: "ltm" };
329
+
330
+ /** The default RANGE for `is within` β€” the owner's first example ("the past month"). */
331
+ export const DEFAULT_DATE_WINDOW: WindowSpec = { kind: "past_month" };
332
+
333
+ /**
334
+ * The ranges `is within` offers, in the owner's own order (item 3, 2026-07-26):
335
+ * the past number of days Β· the past week Β· the past month Β· the past year Β· this calendar
336
+ * week Β· this calendar month Β· this calendar year.
337
+ *
338
+ * Plus `custom`, which is not a nicety: dropping `between` from the date operators would
339
+ * otherwise remove the ability to ask for two exact dates at all. The remaining kinds
340
+ * (yesterday, last_quarter, ytd_last_year, the forward-looking ones…) stay available to a
341
+ * MEASURE's window, where they make sense; "last order is within year to date, last year" is a
342
+ * question nobody asks.
343
+ */
344
+ export const WITHIN_KINDS: WindowKind[] = [
345
+ "last_n_days", "past_week", "past_month", "past_year",
346
+ "this_week", "this_month", "this_year", "custom",
347
+ ];
348
+
349
+ /**
350
+ * The field dropdown's COLUMN half, plus the column the rule is actually set to if the picker
351
+ * no longer offers it. See `withCurrent` β€” a select whose value is missing from its options
352
+ * silently displays a different field than the rule holds. This is not hypothetical: the
353
+ * shipped "Win-back" list filters `at_risk`, which left the filter list the day its measure
354
+ * replacement arrived.
355
+ */
356
+ export function withCurrentField(
357
+ fields: Field[],
358
+ current: string,
359
+ fieldByKey: Map<string, Field>,
360
+ measureByKey: Map<string, Measure>,
361
+ hasCohorts: boolean
362
+ ): FieldSelectItem[] {
363
+ // Item 20: the rows carry their TYPE now, because the picker paints the type mark. Same
364
+ // list, same order, same `withCurrent` rule β€” only the shape of a row grew a field.
365
+ const out: FieldSelectItem[] = fields.map((f) => ({
366
+ key: f.key, label: f.label, type: f.type,
367
+ }));
368
+ const known = new Set(out.map((o) => o.key));
369
+ if (!current || known.has(current) || measureByKey.has(current)) return out;
370
+ if (current === COHORT_FIELD && hasCohorts) return out;
371
+ // The field's OWN label, with no "(not filterable)" annotation: the select is ~170px and the
372
+ // suffix CLIPPED to "At risk $ (not filter" β€” a truncated parenthetical reads as a rendering
373
+ // bug, and the property that matters is that the condition says what it says. Found by
374
+ // reading the screenshot; every assertion in the run was green.
375
+ const f = fieldByKey.get(current);
376
+ out.push({ key: current, label: f ? f.label : current, type: f?.type });
377
+ return out;
378
+ }
379
+
380
+ /** Ids only have to be unique within one view's tree; they never leave this browser's view. */
381
+ let ruleSeq = 0;
382
+ export function newRuleId(): string {
383
+ ruleSeq += 1;
384
+ return `r${Date.now().toString(36)}${ruleSeq.toString(36)}`;
385
+ }
386
+
387
+ /** A fresh leaf condition on the first available field. */
388
+ export function newCondition(fields: Field[]): FilterRule | null {
389
+ const f0 = fields[0];
390
+ if (!f0) return null;
391
+ return { colId: f0.key, op: opsForType(f0.type)[0], value: "" };
392
+ }
393
+
394
+ /** Total LEAF conditions in a filter tree (groups contribute their contents). */
395
+ export function countConditions(nodes: FilterNode[]): number {
396
+ let n = 0;
397
+ for (const node of nodes)
398
+ n += isFilterGroup(node) ? countConditions(node.children) : 1;
399
+ return n;
400
+ }
web/src/inbox/InboxPage.tsx CHANGED
@@ -46,7 +46,6 @@ import {
46
  UNOPENABLE_NOTE,
47
  canOpen,
48
  inboxSections,
49
- kindLabel,
50
  previewOf,
51
  senderOf,
52
  subjectOf,
@@ -538,9 +537,15 @@ export function InboxSurface({
538
  onClick={() => onOpen(selected)}
539
  >
540
  {/* Item 19 (T29): the verb names the DESTINATION rather than promising
541
- "open" generically β€” two kinds go two places. */}
542
- {kindLabel(selected.kind) === "Automation"
543
- ? "Open the automation"
 
 
 
 
 
 
544
  : "Open the database"}
545
  </button>
546
  ) : (
 
46
  UNOPENABLE_NOTE,
47
  canOpen,
48
  inboxSections,
 
49
  previewOf,
50
  senderOf,
51
  subjectOf,
 
537
  onClick={() => onOpen(selected)}
538
  >
539
  {/* Item 19 (T29): the verb names the DESTINATION rather than promising
540
+ "open" generically β€” two kinds go two places.
541
+ β›” WAVE 34 Β· R12 β€” THIS COMPARED A DISPLAY STRING, AND THAT IS WHY IT IS
542
+ WRITTEN THIS WAY NOW. It read `kindLabel(selected.kind) === "Automation"`:
543
+ a second copy of a caption, in a file the rename had no reason to open. The
544
+ moment `kindLabel` returned "Agents" the test went false in silence and
545
+ every automation notification offered to open a DATABASE. Compare the KIND,
546
+ which is a stored wire value nobody renames for a caption. */}
547
+ {selected.kind === NOTIF_KIND_AUTOMATION
548
+ ? "Open the agent"
549
  : "Open the database"}
550
  </button>
551
  ) : (
web/src/inbox/inboxModel.ts CHANGED
@@ -115,10 +115,21 @@ export function previewOf(n: Notification): string {
115
  return label && label !== subjectOf(n) ? label : "";
116
  }
117
 
 
 
 
 
 
 
 
 
 
 
 
118
  /** The kind, in the reader's words. Unknown kinds fall to the alert wording rather than to a raw
119
  * server token β€” a row is never allowed to print a vocabulary word at somebody. */
120
  export function kindLabel(kind: string | undefined): string {
121
- if (kind === NOTIF_KIND_AUTOMATION) return "Automation";
122
  if (kind === NOTIF_KIND_SHARE) return "Shared with you";
123
  return "Alert";
124
  }
@@ -138,7 +149,7 @@ export function kindLabel(kind: string | undefined): string {
138
  export function senderOf(n: Notification): string {
139
  const sent = String(n.sender || "").trim();
140
  if (sent) return sent;
141
- if (n.kind === NOTIF_KIND_AUTOMATION) return "Automation";
142
  if (n.kind === NOTIF_KIND_SHARE) return "A teammate";
143
  return "Alerts";
144
  }
 
115
  return label && label !== subjectOf(n) ? label : "";
116
  }
117
 
118
+ /**
119
+ * ⭐ WAVE 34 Β· R12 β€” THE MODULE'S NAME, IN ONE PLACE ON THE CLIENT.
120
+ *
121
+ * The owner renamed the Automation module to **Agents**. The WIRE kind stays `automation`
122
+ * ({@link NOTIF_KIND_AUTOMATION}) because it is a stored value on every queued notification;
123
+ * only what a reader sees moved. Both display sites below take it from here rather than
124
+ * repeating the word, because the last rename left the literal in three files and a fourth
125
+ * comparing against it.
126
+ */
127
+ export const AGENTS_MODULE_LABEL = "Agents";
128
+
129
  /** The kind, in the reader's words. Unknown kinds fall to the alert wording rather than to a raw
130
  * server token β€” a row is never allowed to print a vocabulary word at somebody. */
131
  export function kindLabel(kind: string | undefined): string {
132
+ if (kind === NOTIF_KIND_AUTOMATION) return AGENTS_MODULE_LABEL;
133
  if (kind === NOTIF_KIND_SHARE) return "Shared with you";
134
  return "Alert";
135
  }
 
149
  export function senderOf(n: Notification): string {
150
  const sent = String(n.sender || "").trim();
151
  if (sent) return sent;
152
+ if (n.kind === NOTIF_KIND_AUTOMATION) return AGENTS_MODULE_LABEL;
153
  if (n.kind === NOTIF_KIND_SHARE) return "A teammate";
154
  return "Alerts";
155
  }
web/src/index.css CHANGED
@@ -171,6 +171,23 @@
171
  --lp-purple-tint: #f1eefb; /* the GROUP chip wash + its column header */
172
  --lp-purple-deep: #6b57a8; /* 5.17:1 on its tint β€” group chip text */
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  /* --- Wave-15 C-BOLD (owner ruling R7) β€” THE PRIMARY ACTION -----------------
175
  "Key buttons bold (dark purple)". A THIRD role in the palette, and it is
176
  worth naming why it is not one of the five hues above: `--lp-purple` is the
@@ -2639,12 +2656,29 @@ body {
2639
  a nav that came back empty said the same thing. A mark that spins says only
2640
  "still working", which is the one true statement available.
2641
 
2642
- TWO COLOURS, ZERO LITERALS. The leading arc is `currentColor`, so the mark
2643
- inherits the ink of whatever it sits in and no caller has to pick a colour;
2644
- the track is that same ink at 22%, which reads as the muted grey the contract
2645
- asks for WITHOUT hard-coding a grey that would disappear on a tinted panel.
2646
- A literal here would be exactly the mid-render one-off the palette block at
2647
- the top of this file spent 55 call sites removing.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2648
 
2649
  Markup contract (C-SPIN): <span class="lp-spin" role="status" aria-label="Loading" />
2650
  β€” an empty element, sized by CSS. `aria-label` carries the word so the screen
@@ -2655,7 +2689,7 @@ body {
2655
  width: 14px;
2656
  height: 14px;
2657
  border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
2658
- border-top-color: currentColor;
2659
  border-radius: 50%;
2660
  animation: lp-spin-rot 0.7s linear infinite;
2661
  }
@@ -2744,7 +2778,7 @@ body {
2744
  padding: 26px 28px 18px;
2745
  box-shadow: var(--shell-shadow-overlay);
2746
  }
2747
- .login-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
2748
  .login-brand .lp-wordmark {
2749
  font-size: var(--lp-fs-lg);
2750
  font-weight: 600;
@@ -2752,6 +2786,27 @@ body {
2752
  line-height: 1.15;
2753
  }
2754
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2755
  /* ---- the 8-STATE INPUT DISCIPLINE ----------------------------------------
2756
  rest Β· placeholder Β· hover Β· focus Β· filled Β· error Β· disabled Β· autofill.
2757
  border-WIDTH is 1px in EVERY state β€” state is carried by colour, background
@@ -4673,6 +4728,26 @@ a.cg-map-ctl-b { text-decoration: none; }
4673
  /* Wave-8 I11c (C4) β€” folders in the Views rail. */
4674
  .cg-fold-new { padding: 2px 8px 4px; }
4675
  .cg-fold, .cg-fold-root { border-radius: 6px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4676
  .cg-fold.is-drop, .cg-fold-root.is-drop {
4677
  background: var(--lp-blue-tint);
4678
  outline: 1px dashed var(--lp-blue-deep);
@@ -12194,16 +12269,33 @@ textarea.cg-json-raw:focus {
12194
  auto` pushes it to the right edge so the badge lines up with the per-view badges directly
12195
  below it β€” the total and its parts read as one column, which is the whole point of putting it
12196
  here rather than in the shell's database header. */
 
 
 
 
 
 
 
 
12197
  .cg-views-important {
12198
  margin-left: auto;
12199
  display: inline-flex;
12200
  align-items: center;
12201
- gap: 6px;
12202
- font-size: var(--lp-fs-2xs);
12203
- font-weight: 600;
12204
- color: var(--lp-muted);
12205
  white-space: nowrap;
12206
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
12207
  /* A PARTIAL total wears the same purple as a whole one β€” the number it shows IS real, it is just
12208
  not all of them β€” and says so with a `+` and its title. Muting it would read as "less
12209
  important" rather than "less complete". */
 
171
  --lp-purple-tint: #f1eefb; /* the GROUP chip wash + its column header */
172
  --lp-purple-deep: #6b57a8; /* 5.17:1 on its tint β€” group chip text */
173
 
174
+ /* --- WAVE 34 R2 β€” THE BRAND MARK'S PURPLE, NOW A TOKEN --------------------
175
+ Owner, 2026-08-16: *"The loading screen loop, the color should be purple as
176
+ well."* "As well" is the operative phrase: the loop mark in the logo is this
177
+ purple, and the spinner is the same loop, so it should be the same colour.
178
+
179
+ β›” THIS IS THE THIRD PURPLE AND IT IS NOT INTERCHANGEABLE WITH THE OTHER TWO.
180
+ `--lp-purple` (#c3b3ee) is a 1.91:1 FILL and would be invisible as a 2px arc;
181
+ `--lp-purple-deep` (#6b57a8) is the GROUP control's hue, and a spinner painted
182
+ from it would say "this wait is about grouping". This one is the mark's own
183
+ colour, previously a literal in `favicon.svg` and `_brandmark.LP_PURPLE` with
184
+ no CSS token at all β€” which is exactly why the comment three lines up had to
185
+ spell the hex out in prose to warn people off it.
186
+ ⚠ The comment above still stands and is not contradicted: the brand purple is
187
+ deliberately OUTSIDE the UI palette. This token does not admit it to the
188
+ palette; it names it so the ONE surface that is the brand mark can use it. */
189
+ --lp-brand: #6e56cf; /* the loop mark (favicon.svg, _brandmark) */
190
+
191
  /* --- Wave-15 C-BOLD (owner ruling R7) β€” THE PRIMARY ACTION -----------------
192
  "Key buttons bold (dark purple)". A THIRD role in the palette, and it is
193
  worth naming why it is not one of the five hues above: `--lp-purple` is the
 
2656
  a nav that came back empty said the same thing. A mark that spins says only
2657
  "still working", which is the one true statement available.
2658
 
2659
+ TWO COLOURS, ZERO LITERALS. The TRACK is `currentColor` at 22%, so it reads as
2660
+ the muted grey the contract asks for WITHOUT hard-coding a grey that would
2661
+ disappear on a tinted panel.
2662
+
2663
+ ⭐ WAVE 34 R2 β€” THE LEADING ARC IS THE BRAND PURPLE NOW, not `currentColor`.
2664
+ Owner: *"The loading screen loop, the color should be purple as well."* The
2665
+ spinner IS the logo's loop, so it wears the logo's colour.
2666
+ ⚠ WHAT THAT COSTS, stated rather than discovered later: the arc no longer
2667
+ adapts to its container's ink. That adaptation was the original design and it
2668
+ was a good one β€” it is why no caller has ever had to pick a colour. Pinning it
2669
+ is safe TODAY because it was MEASURED, not assumed. All 27 `.lp-spin` call sites
2670
+ sit on light panels, and #6e56cf against each of them is:
2671
+ white 5.39:1 Β· --lp-surface-2 #f7f7f6 5.03:1 Β· --lp-purple-tint 4.71:1
2672
+ --lp-wash #fafbfc 5.20:1
2673
+ NONE of them sits inside a filled or dark button, which is the case that breaks.
2674
+ (These are the same numbers the palette block above quotes for its own hues, by
2675
+ the same formula β€” a contrast claim written as "clears 4.5:1" would have been a
2676
+ threshold restated as a fact [[no-unverifiable-aggregates]].)
2677
+ β›” SO THE RULE FOR THE NEXT PERSON: if you mount `.lp-spin` on a dark or
2678
+ saturated surface, it will be nearly invisible and the fix is a modifier class
2679
+ that restores `currentColor` for that surface, NOT reverting this line.
2680
+ The TRACK deliberately stays `currentColor` so the ring still seats itself on
2681
+ whatever it sits on.
2682
 
2683
  Markup contract (C-SPIN): <span class="lp-spin" role="status" aria-label="Loading" />
2684
  β€” an empty element, sized by CSS. `aria-label` carries the word so the screen
 
2689
  width: 14px;
2690
  height: 14px;
2691
  border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
2692
+ border-top-color: var(--lp-brand);
2693
  border-radius: 50%;
2694
  animation: lp-spin-rot 0.7s linear infinite;
2695
  }
 
2778
  padding: 26px 28px 18px;
2779
  box-shadow: var(--shell-shadow-overlay);
2780
  }
2781
+ .login-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
2782
  .login-brand .lp-wordmark {
2783
  font-size: var(--lp-fs-lg);
2784
  font-weight: 600;
 
2786
  line-height: 1.15;
2787
  }
2788
 
2789
+ /* ⭐ WAVE 34 R9 β€” the sign-in card's one line of sell.
2790
+ ⚠ IT MUST NOT COMPETE WITH THE WORDMARK. `--lp-fs-sm` and the muted ink put it a clear step
2791
+ below the brand line, which is what keeps the card reading as a door rather than a landing
2792
+ page. The 18px bottom margin replaces `.login-brand`'s 12px as the gap before the form, so the
2793
+ form does not move when this line is present: measured from the same total, not added to it.
2794
+ β›” NO gold accent line and no rule above the form. The owner rejected exactly that once already
2795
+ ("there's an orange line at the top, it's ugly"), and a slogan is the most tempting place to
2796
+ reach for one. */
2797
+ /* ⚠ `--lp-muted`, NOT `--lp-ink-muted`. The first draft of this rule used the second name, which
2798
+ does not exist -- and a ghost token does not fail loudly, it paints the INHERITED colour, so the
2799
+ tagline would have rendered at full `--lp-ink` weight and competed with the wordmark. The token
2800
+ block above carries a scar from the same class (`--lp-wash: var(--lp-wash)`, a self-reference
2801
+ that silently painted nothing everywhere it was consumed) and notes that the ghost-token gate
2802
+ only checks a consumed name is DECLARED. Measured: `--lp-muted` #6b7280 is 4.83:1 on white. */
2803
+ .login-tagline {
2804
+ margin: 0 0 18px;
2805
+ font-size: var(--lp-fs-sm);
2806
+ color: var(--lp-muted);
2807
+ letter-spacing: -0.005em;
2808
+ }
2809
+
2810
  /* ---- the 8-STATE INPUT DISCIPLINE ----------------------------------------
2811
  rest Β· placeholder Β· hover Β· focus Β· filled Β· error Β· disabled Β· autofill.
2812
  border-WIDTH is 1px in EVERY state β€” state is carried by colour, background
 
4728
  /* Wave-8 I11c (C4) β€” folders in the Views rail. */
4729
  .cg-fold-new { padding: 2px 8px 4px; }
4730
  .cg-fold, .cg-fold-root { border-radius: 6px; }
4731
+ /* ⭐⭐ WAVE 34 Β· T22 (ruling R4) β€” WHILE A VIEW IS BEING DRAGGED, THE ROOT SECTION HAS A BODY.
4732
+ Off-drag this rule does not apply at all, so the rail keeps the exact layout it has today; the
4733
+ class is only on the root group and only while `viewDrag !== null` (ViewSidebar).
4734
+ Β· `min-height` is the EMPTY case: a user who filed every view into folders had a zero-pixel
4735
+ drop target and no way to unfile anything.
4736
+ Β· `padding-bottom` is the POPULATED case, which is the one nobody had noticed. This element has
4737
+ no padding of its own, so its box is exactly the union of its rows, and every row claims the
4738
+ drag for reordering via `stopPropagation`. Without a strip BELOW the last row there is no
4739
+ pixel anywhere that means "file this at the top level".
4740
+ ⚠ IT SETS NO PAINT, DELIBERATELY. `.cg-fold-root.is-drop` immediately below is the hover state
4741
+ and must keep winning; declaring a background or outline here would fight it at equal
4742
+ specificity and the "you are about to drop here" tint would depend on rule order. The area is
4743
+ this rule's job, the picture is that one's.
4744
+ ⚠ COST, STATED: the "Shared with me" group (the only thing rendered after root) shifts down by
4745
+ this padding for the duration of a drag. That is the price of not reserving the space
4746
+ permanently, and a transient shift under the cursor reads as the target opening up. */
4747
+ .cg-fold-root.is-viewdrop {
4748
+ min-height: 30px;
4749
+ padding-bottom: 20px;
4750
+ }
4751
  .cg-fold.is-drop, .cg-fold-root.is-drop {
4752
  background: var(--lp-blue-tint);
4753
  outline: 1px dashed var(--lp-blue-deep);
 
12269
  auto` pushes it to the right edge so the badge lines up with the per-view badges directly
12270
  below it β€” the total and its parts read as one column, which is the whole point of putting it
12271
  here rather than in the shell's database header. */
12272
+ /* ⭐⭐ WAVE 34 Β· T20 (R1, contract C1) β€” THE WORD IS GONE, SO ITS TYPOGRAPHY GOES WITH IT.
12273
+ `gap`, `font-size`, `font-weight` and `color` here only ever painted the literal "Important";
12274
+ with the label removed they style nothing (the badge sets its own), and left in place they are
12275
+ the kind of inert rule a later reader restores a label to fit. What survives is the LAYOUT this
12276
+ wrapper is actually for: `margin-left: auto` pushes it to the right edge so the total lines up
12277
+ with the per-view badges directly below it, and the class name itself is contract C1's hook
12278
+ (`.cg-views-important.is-partial` hangs the dashed-purple partial mark off it, and B's flyout
12279
+ row is named against it). */
12280
  .cg-views-important {
12281
  margin-left: auto;
12282
  display: inline-flex;
12283
  align-items: center;
 
 
 
 
12284
  white-space: nowrap;
12285
  }
12286
+ /* R1's "SMALLER", as a box rather than as type. β›” THE FONT CANNOT SHRINK AND THAT IS A REAL
12287
+ CONSTRAINT, NOT AN OMISSION: `--lp-fs-3xs` (11px) is the smallest size the scale declares, and
12288
+ `verify_ui`'s R5 leg reds ANY literal `font-size` in `web/src` CSS, so the only way to go below
12289
+ it is to add a fourth micro size to the type scale for one badge. 20x20 -> 16x16 is a 36% cut
12290
+ in area on top of losing the label, which is what the ruling asked for; inventing a 10px step
12291
+ under the scale's own floor is not. ⚠ SCOPED BY PARENT on purpose: `.cg-view-count` is shared
12292
+ with the per-view badges (and with `.is-unknown`), and shrinking it unscoped would resize every
12293
+ count in the rail from a ruling about the header. */
12294
+ .cg-views-important .cg-view-count {
12295
+ min-width: 16px;
12296
+ height: 16px;
12297
+ padding: 0 2px;
12298
+ }
12299
  /* A PARTIAL total wears the same purple as a whole one β€” the number it shows IS real, it is just
12300
  not all of them β€” and says so with a `+` and its title. Muting it would read as "less
12301
  important" rather than "less complete". */
web/src/query/QueryPage.tsx CHANGED
@@ -1,24 +1,41 @@
1
- import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
 
3
  import type { QueryOpenDetail } from "../apiContract";
4
  import { QUERY_OPEN_EVENT } from "../apiContract";
5
  import CustomerGrid from "../customer-grid/CustomerGrid";
6
  import { OverlayProvider } from "../customer-grid/OverlaySurface";
7
  import { queryCitationLabel } from "../customer-grid/queryPreview";
8
- import { retryEmit } from "../inbox/inboxModel";
9
  import { DbHead, gridScopeFor } from "../shell/dbFrame";
10
  import { databaseEntries } from "../shell/nav";
11
  import type { NavEntry } from "../shell/nav";
12
  import { bindingFor, deleteQuery, fetchQueries, QUERY_BINDING_EVENT } from "./queryApi";
13
  import type { QueryCitation, SavedQuery } from "./queryApi";
14
- import { QueryEmpty, QueryProvenance, QueryRail } from "./queryParts";
15
  import type { QueryGroup } from "./queryParts";
16
  import "./query.css";
17
 
18
- export interface QueryPageProps { granted: NavEntry[]; }
19
-
20
- /** Long enough for a cold grid, without giving the Assistant a native-view event path. */
21
- export const VIEW_SELECT_RETRY_MS = [0, 250, 700, 1500, 2600, 5000, 9000, 14000, 21000] as const;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  /**
24
  * Query is a database frame over a virtual artefact. Its public contract for B is the
@@ -28,43 +45,17 @@ export const VIEW_SELECT_RETRY_MS = [0, 250, 700, 1500, 2600, 5000, 9000, 14000,
28
  * ⭐⭐ OWNER ITEM 3 (2026-08-15) β€” THE RAIL IS THE NAVIGATION. See `queryParts.QueryRail`: the
29
  * top-right dropdown is gone AND replaced, rather than gone and left as an implicit `views[0]`.
30
  */
31
- export default function QueryPage({ granted }: QueryPageProps) {
32
  const [views, setViews] = useState<SavedQuery[]>([]);
33
  const [citations, setCitations] = useState<QueryCitation[]>([]);
34
  const [activeId, setActiveId] = useState("");
35
  const [loaded, setLoaded] = useState(false);
36
- const emitCancel = useRef<(() => void) | null>(null);
37
  const entries = databaseEntries(granted);
38
  const entryOf = (key: string) => entries.find((entry) => entry.key === key);
39
  const known = views.filter((view) => entryOf(view.source.database));
40
  const active = known.find((view) => view.id === activeId) ?? known[0] ?? null;
41
 
42
- /**
43
- * ⚠ GROUPED IN THE ORDER THE DATABASES ARE GRANTED, not by artefact age. The rail is a list of
44
- * DATABASES first (owner: "each View correspond to the relevant database"), so a new answer
45
- * about Customers must not move the Customers heading β€” it appears under it. Within a database
46
- * the newest is first, which is the order the server already sends.
47
- */
48
- const groups = useMemo<QueryGroup[]>(() => {
49
- const byDatabase = new Map<string, SavedQuery[]>();
50
- for (const view of known) {
51
- const list = byDatabase.get(view.source.database);
52
- if (list) list.push(view);
53
- else byDatabase.set(view.source.database, [view]);
54
- }
55
- const out: QueryGroup[] = [];
56
- for (const entry of entries) {
57
- const list = byDatabase.get(entry.key);
58
- if (!list || entry.kind === "group") continue;
59
- out.push({
60
- database: entry.key,
61
- label: list[0].source.label || entry.label,
62
- ...(entry.icon ? { icon: entry.icon } : {}),
63
- views: list,
64
- });
65
- }
66
- return out;
67
- }, [known, entries]);
68
 
69
  const reload = useCallback(async () => {
70
  const result = await fetchQueries();
@@ -77,18 +68,42 @@ export default function QueryPage({ granted }: QueryPageProps) {
77
 
78
  useEffect(() => { void reload(); }, [reload]);
79
 
 
 
 
 
 
 
 
 
 
80
  useEffect(() => {
81
  if (known.length && !known.some((view) => view.id === activeId)) setActiveId(known[0].id);
82
  }, [known, activeId]);
83
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  useEffect(() => {
85
- if (!active) return;
86
- emitCancel.current?.();
87
- emitCancel.current = retryEmit(() => {
88
- window.dispatchEvent(new CustomEvent(QUERY_BINDING_EVENT, { detail: bindingFor(active, citations) }));
89
- }, undefined, VIEW_SELECT_RETRY_MS);
90
- return () => emitCancel.current?.();
91
- }, [active, citations]);
92
 
93
  useEffect(() => {
94
  const onOpen = (event: Event) => {
@@ -105,6 +120,11 @@ export default function QueryPage({ granted }: QueryPageProps) {
105
  return () => window.removeEventListener(QUERY_OPEN_EVENT, onOpen);
106
  }, [views, reload]);
107
 
 
 
 
 
 
108
  const remove = useCallback(async (qid: string) => {
109
  const result = await deleteQuery(qid);
110
  if (!result.ok) return;
@@ -122,12 +142,16 @@ export default function QueryPage({ granted }: QueryPageProps) {
122
  <DbHead label={active.source.label} {...(entry?.icon ? { icon: entry.icon } : {})} />
123
  <div className="shell-grid-host">
124
  <div className="qy-workspace">
125
- <QueryRail groups={groups} activeId={active.id} onSelect={setActiveId} onDelete={(id) => void remove(id)} />
 
 
 
126
  <div className="qy-surface">
127
  <QueryProvenance view={active} detail={provenance} />
128
  <div className="qy-grid-host">
129
  <OverlayProvider>
130
- <CustomerGrid key={gridScopeFor(active.source.database)} scope={gridScopeFor(active.source.database)} />
 
131
  </OverlayProvider>
132
  </div>
133
  </div>
 
1
+ import { useCallback, useEffect, useMemo, useState } from "react";
2
 
3
  import type { QueryOpenDetail } from "../apiContract";
4
  import { QUERY_OPEN_EVENT } from "../apiContract";
5
  import CustomerGrid from "../customer-grid/CustomerGrid";
6
  import { OverlayProvider } from "../customer-grid/OverlaySurface";
7
  import { queryCitationLabel } from "../customer-grid/queryPreview";
 
8
  import { DbHead, gridScopeFor } from "../shell/dbFrame";
9
  import { databaseEntries } from "../shell/nav";
10
  import type { NavEntry } from "../shell/nav";
11
  import { bindingFor, deleteQuery, fetchQueries, QUERY_BINDING_EVENT } from "./queryApi";
12
  import type { QueryCitation, SavedQuery } from "./queryApi";
13
+ import { QueryEmpty, queryGroups, QueryProvenance, QueryRail } from "./queryParts";
14
  import type { QueryGroup } from "./queryParts";
15
  import "./query.css";
16
 
17
+ export interface QueryPageProps {
18
+ granted: NavEntry[];
19
+ /**
20
+ * ⭐ R14 β€” the merged Assistant surface hosts the view list in its OWN left panel, so Query
21
+ * renders no rail here. It is a flag rather than a second component because the SELECTION
22
+ * contract does not change: the host emits `QUERY_OPEN_EVENT` through `openBuiltView`, which is
23
+ * the same public qid hand-off an external link uses, and the listener below answers both.
24
+ */
25
+ hostedRail?: boolean;
26
+ /** Bumped by the host after it deletes a view, so the two lists cannot drift apart. */
27
+ refreshToken?: number;
28
+ /**
29
+ * β›” THE HOST'S SELECTION, AND IT IS A GUARANTEE THE EVENT CANNOT GIVE. `QUERY_OPEN_EVENT` stays
30
+ * the published qid contract and still answers external callers, but it is a RACE: `retryEmit`'s
31
+ * default ladder tops out at 2,600 ms, tuned for a click into a grid that is usually warm, while
32
+ * the hosted workspace is a LAZY chunk over a cold grid that CLAUDE.md measures painting at
33
+ * ~12 s. Every dispatch lands before the listener exists, none is acked, and the rail then
34
+ * highlights a view the main column is not showing, 200 OK and silent. A prop arrives in the
35
+ * same commit as the mount.
36
+ */
37
+ selectedId?: string;
38
+ }
39
 
40
  /**
41
  * Query is a database frame over a virtual artefact. Its public contract for B is the
 
45
  * ⭐⭐ OWNER ITEM 3 (2026-08-15) β€” THE RAIL IS THE NAVIGATION. See `queryParts.QueryRail`: the
46
  * top-right dropdown is gone AND replaced, rather than gone and left as an implicit `views[0]`.
47
  */
48
+ export default function QueryPage({ granted, hostedRail, refreshToken, selectedId }: QueryPageProps) {
49
  const [views, setViews] = useState<SavedQuery[]>([]);
50
  const [citations, setCitations] = useState<QueryCitation[]>([]);
51
  const [activeId, setActiveId] = useState("");
52
  const [loaded, setLoaded] = useState(false);
 
53
  const entries = databaseEntries(granted);
54
  const entryOf = (key: string) => entries.find((entry) => entry.key === key);
55
  const known = views.filter((view) => entryOf(view.source.database));
56
  const active = known.find((view) => view.id === activeId) ?? known[0] ?? null;
57
 
58
+ const groups = useMemo<QueryGroup[]>(() => queryGroups(views, entries), [views, entries]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
 
60
  const reload = useCallback(async () => {
61
  const result = await fetchQueries();
 
68
 
69
  useEffect(() => { void reload(); }, [reload]);
70
 
71
+ /** The host owns the rail in the merged surface; a delete there must not leave this list stale. */
72
+ useEffect(() => { if (refreshToken) void reload(); }, [refreshToken, reload]);
73
+
74
+ /**
75
+ * Set BEFORE the fetch lands, deliberately: `known` is empty until then, so the fallback below
76
+ * does not fire, and by the time it can the id it would replace is already the right one.
77
+ */
78
+ useEffect(() => { if (selectedId) setActiveId(selectedId); }, [selectedId]);
79
+
80
  useEffect(() => {
81
  if (known.length && !known.some((view) => view.id === activeId)) setActiveId(known[0].id);
82
  }, [known, activeId]);
83
 
84
+ /**
85
+ * β›”β›” THE BINDING GOES DOWN AS A PROP, AND THAT IS NOT A TIDY-UP.
86
+ * `CustomerGrid` registers its `QUERY_BINDING_EVENT` listener only when `isQueryPreviewRoute()`
87
+ * is true, i.e. only while the hash starts `#/query`. R14 moves this surface INSIDE
88
+ * `#/assistant`, where that predicate is false β€” so on the event path alone the grid would
89
+ * receive no binding, `isQueryPreview` would be false, `previewReadOnly` with it, and the
90
+ * merged surface would render a fully EDITABLE database grid over an immutable AI artefact,
91
+ * 200 OK, with the immutability contract intact on paper. Every WRITE guard in the grid keys on
92
+ * `queryBinding`; only the listener keys on the route. A prop is gated on neither.
93
+ *
94
+ * ⚠ Memoised because `CustomerGrid`'s preview effect lists `queryBinding` in its deps: a fresh
95
+ * object per render re-enters it on every unrelated state change on this page.
96
+ */
97
+ const binding = useMemo(
98
+ () => (active ? bindingFor(active, citations) : undefined), [active, citations]);
99
+
100
  useEffect(() => {
101
+ if (!binding) return;
102
+ // Still published for B, which is what `QUERY_BINDING_EVENT` is: one dispatch, not a ladder.
103
+ // The 21 s retry ladder that used to be here existed for ONE failure β€” the grid mounting
104
+ // after the event was fired β€” and a prop delivered in the same commit cannot have it.
105
+ window.dispatchEvent(new CustomEvent(QUERY_BINDING_EVENT, { detail: binding }));
106
+ }, [binding]);
 
107
 
108
  useEffect(() => {
109
  const onOpen = (event: Event) => {
 
120
  return () => window.removeEventListener(QUERY_OPEN_EVENT, onOpen);
121
  }, [views, reload]);
122
 
123
+ /** A renamed or duplicated artefact, merged in place: the rail owns the call, the page the list. */
124
+ const upsert = useCallback((view: SavedQuery) => {
125
+ setViews((current) => [view, ...current.filter((row) => row.id !== view.id)]);
126
+ }, []);
127
+
128
  const remove = useCallback(async (qid: string) => {
129
  const result = await deleteQuery(qid);
130
  if (!result.ok) return;
 
142
  <DbHead label={active.source.label} {...(entry?.icon ? { icon: entry.icon } : {})} />
143
  <div className="shell-grid-host">
144
  <div className="qy-workspace">
145
+ {hostedRail ? null : (
146
+ <QueryRail groups={groups} activeId={active.id} onSelect={setActiveId}
147
+ onDelete={(id) => void remove(id)} onChanged={upsert} />
148
+ )}
149
  <div className="qy-surface">
150
  <QueryProvenance view={active} detail={provenance} />
151
  <div className="qy-grid-host">
152
  <OverlayProvider>
153
+ <CustomerGrid key={gridScopeFor(active.source.database)}
154
+ scope={gridScopeFor(active.source.database)} queryBinding={binding} />
155
  </OverlayProvider>
156
  </div>
157
  </div>
web/src/query/query.css CHANGED
@@ -64,6 +64,114 @@
64
 
65
  .qy-rail-group { margin-bottom: 10px; }
66
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
67
  .qy-rail-db {
68
  display: flex;
69
  align-items: center;
@@ -103,6 +211,9 @@
103
  .qy-rail-row {
104
  position: relative;
105
  display: flex;
 
 
 
106
  align-items: center;
107
  min-width: 0;
108
  border-radius: var(--lp-r-md);
 
64
 
65
  .qy-rail-group { margin-bottom: 10px; }
66
 
67
+ /* ── R20: the per-view menu, the same vocabulary a database view offers ───── */
68
+
69
+ .qy-rail-more {
70
+ flex: 0 0 auto;
71
+ width: 24px;
72
+ height: 24px;
73
+ margin-right: 4px;
74
+ display: inline-flex;
75
+ align-items: center;
76
+ justify-content: center;
77
+ border: 0;
78
+ border-radius: 6px;
79
+ background: transparent;
80
+ color: var(--lp-muted);
81
+ opacity: 0;
82
+ cursor: pointer;
83
+ }
84
+
85
+ .qy-rail-more svg { fill: currentColor; stroke: none; }
86
+
87
+ .qy-rail-row:hover .qy-rail-more,
88
+ .qy-rail-row.is-active .qy-rail-more,
89
+ .qy-rail-more:focus-visible,
90
+ .qy-rail-more[aria-expanded="true"] { opacity: 1; }
91
+
92
+ /* Sits above the rows, under the menu, so a click anywhere else closes it without every row
93
+ needing its own outside-click listener. */
94
+ .qy-rail-scrim {
95
+ position: fixed;
96
+ inset: 0;
97
+ z-index: 20;
98
+ border: 0;
99
+ padding: 0;
100
+ background: transparent;
101
+ cursor: default;
102
+ }
103
+
104
+ /* FIXED, not absolute: its natural parent `.qy-rail-scroll` is `overflow-y: auto`, which clips
105
+ both axes, so a menu opened on a row near the bottom of the list would render cut off or
106
+ invisible. `top`/`right` are supplied inline from the button's own rect. */
107
+ .qy-rail-menu {
108
+ position: fixed;
109
+ z-index: 21;
110
+ min-width: 168px;
111
+ margin-top: 2px;
112
+ padding: 4px;
113
+ display: flex;
114
+ flex-direction: column;
115
+ border: 1px solid var(--lp-line);
116
+ border-radius: var(--lp-r-md);
117
+ background: var(--lp-surface);
118
+ box-shadow: 0 6px 18px rgba(20, 24, 40, 0.12);
119
+ }
120
+
121
+ .qy-rail-item {
122
+ display: block;
123
+ padding: 6px 10px;
124
+ border: 0;
125
+ border-radius: 6px;
126
+ background: transparent;
127
+ color: var(--lp-ink);
128
+ font: inherit;
129
+ font-size: var(--lp-fs-2xs);
130
+ text-align: left;
131
+ text-decoration: none;
132
+ cursor: pointer;
133
+ }
134
+
135
+ .qy-rail-item:hover { background: var(--lp-surface-2); }
136
+ .qy-rail-item.is-danger { color: var(--lp-red-deep); }
137
+
138
+ .qy-rail-edit { flex: 1 1 auto; min-width: 0; padding: 2px 4px; }
139
+
140
+ .qy-rail-input {
141
+ width: 100%;
142
+ box-sizing: border-box;
143
+ padding: 5px 8px;
144
+ border: 1px solid var(--lp-blue-solid);
145
+ border-radius: 6px;
146
+ background: var(--lp-surface);
147
+ color: var(--lp-ink);
148
+ font: inherit;
149
+ font-size: var(--lp-fs-2xs);
150
+ }
151
+
152
+ .qy-rail-note {
153
+ flex: 1 0 100%;
154
+ margin: 0 0 4px;
155
+ padding: 0 10px;
156
+ color: var(--lp-muted);
157
+ font-size: var(--lp-fs-2xs);
158
+ line-height: var(--lp-lh);
159
+ }
160
+
161
+ .qy-rail-problem {
162
+ margin: 0 6px 6px;
163
+ color: var(--lp-red-deep);
164
+ font-size: var(--lp-fs-2xs);
165
+ }
166
+
167
+ /* A confirmation, not a refusal. Same slot, different voice. */
168
+ .qy-rail-notice {
169
+ margin: 0 6px 6px;
170
+ color: var(--lp-muted);
171
+ font-size: var(--lp-fs-2xs);
172
+ line-height: var(--lp-lh);
173
+ }
174
+
175
  .qy-rail-db {
176
  display: flex;
177
  align-items: center;
 
211
  .qy-rail-row {
212
  position: relative;
213
  display: flex;
214
+ /* R20's description sits on its own line under the name, so the row wraps. `position: relative`
215
+ was already here and is what the menu anchors to. */
216
+ flex-wrap: wrap;
217
  align-items: center;
218
  min-width: 0;
219
  border-radius: var(--lp-r-md);
web/src/query/queryApi.ts CHANGED
@@ -35,6 +35,8 @@ export interface SavedQuery {
35
  viewId: string;
36
  scope: string;
37
  name: string;
 
 
38
  kind: string;
39
  question: string;
40
  explain: string;
@@ -71,6 +73,9 @@ export interface QueryMessage {
71
  viewId?: string | null;
72
  citationIds?: string[];
73
  numeric?: SavedQuery["numeric"] | null;
 
 
 
74
  }
75
 
76
  /**
@@ -88,12 +93,26 @@ export interface QuerySourceStatus {
88
  reason: string;
89
  }
90
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  export interface QueryIndex {
92
  threads: QueryThread[];
93
  messages: QueryMessage[];
94
  views: SavedQuery[];
95
  citations: QueryCitation[];
96
  models: string[];
 
97
  sources: QuerySourceStatus[];
98
  }
99
 
@@ -229,9 +248,10 @@ async function call<T>(path: string, init: RequestInit, read: (body: unknown) =>
229
  return { ok: true, value: read(body) };
230
  }
231
 
232
- const json = (data: unknown): RequestInit => ({
233
- method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(data),
234
  });
 
235
  const str = (value: unknown): string => (typeof value === "string" ? value : "");
236
  const rows = (value: unknown): Record<string, unknown>[] =>
237
  Array.isArray(value) ? value.filter((row): row is Record<string, unknown> => !!row && typeof row === "object") : [];
@@ -255,7 +275,7 @@ function saved(value: unknown): SavedQuery | null {
255
  if (!row || !src || !str(row.id) || !str(row.viewId) || !str(row.threadId) || row.virtual !== true) return null;
256
  return {
257
  id: str(row.id), viewId: str(row.viewId), scope: str(row.scope) || src.database,
258
- name: str(row.name) || "Query", kind: str(row.kind) || "grid",
259
  question: str(row.question), explain: str(row.explain), threadId: str(row.threadId),
260
  createdAt: str(row.createdAt), virtual: true, source: src,
261
  view: (row.view && typeof row.view === "object" ? row.view : {}) as Record<string, unknown>,
@@ -276,6 +296,8 @@ function message(value: unknown): QueryMessage | null {
276
  reason: str(row.reason) || null, viewId: str(row.viewId) || null,
277
  citationIds: (row.citationIds as unknown[] || []).filter((v): v is string => typeof v === "string"),
278
  numeric: (row.numeric && typeof row.numeric === "object" ? row.numeric : null) as QueryMessage["numeric"],
 
 
279
  };
280
  }
281
 
@@ -307,6 +329,13 @@ export function parseIndex(body: unknown): QueryIndex {
307
  views: rows(row?.views).map(saved).filter((v): v is SavedQuery => !!v),
308
  citations: rows(row?.citations).map(citation).filter((v): v is QueryCitation => !!v),
309
  models: (row?.models as unknown[] || []).filter((v): v is string => typeof v === "string"),
 
 
 
 
 
 
 
310
  sources: rows(row?.sources).filter((item) => !!str(item.database)).map((item) => ({
311
  database: str(item.database), answerable: item.answerable === true, reason: str(item.reason),
312
  })),
@@ -341,6 +370,70 @@ export function deleteThread(id: string): Promise<Result<true>> {
341
  return call(`/query/threads/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const);
342
  }
343
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
344
  /**
345
  * The sole mutation path for a virtual Query workspace. This intentionally never falls through
346
  * to the native grid event transport and never sends `dataScope`: the opaque Query key is the authority.
 
35
  viewId: string;
36
  scope: string;
37
  name: string;
38
+ /** R20: a label the reader owns. The SPEC beside it stays immutable, which is the whole split. */
39
+ description: string;
40
  kind: string;
41
  question: string;
42
  explain: string;
 
73
  viewId?: string | null;
74
  citationIds?: string[];
75
  numeric?: SavedQuery["numeric"] | null;
76
+ /** R20's thumbs. Read by the server's own replay, not only written. */
77
+ rating?: "up" | "down" | null;
78
+ ratingReason?: string;
79
  }
80
 
81
  /**
 
93
  reason: string;
94
  }
95
 
96
+ /**
97
+ * A model this deployment OFFERS, and whether it can actually be called.
98
+ *
99
+ * ⭐ The same shape `QuerySourceStatus` uses, for the same reason one control to the left: a
100
+ * picker that lists a model whose key is not set spends a click and a turn to be told
101
+ * "unavailable" by a server that knew before the click.
102
+ */
103
+ export interface QueryModelStatus {
104
+ model: string;
105
+ available: boolean;
106
+ reason: string;
107
+ }
108
+
109
  export interface QueryIndex {
110
  threads: QueryThread[];
111
  messages: QueryMessage[];
112
  views: SavedQuery[];
113
  citations: QueryCitation[];
114
  models: string[];
115
+ modelStatus: QueryModelStatus[];
116
  sources: QuerySourceStatus[];
117
  }
118
 
 
248
  return { ok: true, value: read(body) };
249
  }
250
 
251
+ const sends = (method: string, data: unknown): RequestInit => ({
252
+ method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data),
253
  });
254
+ const json = (data: unknown): RequestInit => sends("POST", data);
255
  const str = (value: unknown): string => (typeof value === "string" ? value : "");
256
  const rows = (value: unknown): Record<string, unknown>[] =>
257
  Array.isArray(value) ? value.filter((row): row is Record<string, unknown> => !!row && typeof row === "object") : [];
 
275
  if (!row || !src || !str(row.id) || !str(row.viewId) || !str(row.threadId) || row.virtual !== true) return null;
276
  return {
277
  id: str(row.id), viewId: str(row.viewId), scope: str(row.scope) || src.database,
278
+ name: str(row.name) || "Query", description: str(row.description), kind: str(row.kind) || "grid",
279
  question: str(row.question), explain: str(row.explain), threadId: str(row.threadId),
280
  createdAt: str(row.createdAt), virtual: true, source: src,
281
  view: (row.view && typeof row.view === "object" ? row.view : {}) as Record<string, unknown>,
 
296
  reason: str(row.reason) || null, viewId: str(row.viewId) || null,
297
  citationIds: (row.citationIds as unknown[] || []).filter((v): v is string => typeof v === "string"),
298
  numeric: (row.numeric && typeof row.numeric === "object" ? row.numeric : null) as QueryMessage["numeric"],
299
+ rating: row.rating === "up" || row.rating === "down" ? row.rating : null,
300
+ ratingReason: str(row.ratingReason),
301
  };
302
  }
303
 
 
329
  views: rows(row?.views).map(saved).filter((v): v is SavedQuery => !!v),
330
  citations: rows(row?.citations).map(citation).filter((v): v is QueryCitation => !!v),
331
  models: (row?.models as unknown[] || []).filter((v): v is string => typeof v === "string"),
332
+ // ⚠ ABSENT β‡’ TREATED AS AVAILABLE, the same way an absent source row is treated as answerable:
333
+ // the server is the authority and its refusal is still the wall. A client that greyed out
334
+ // every model it had not heard about would disable the whole picker the moment this field
335
+ // failed to arrive.
336
+ modelStatus: rows(row?.modelStatus).filter((item) => !!str(item.model)).map((item) => ({
337
+ model: str(item.model), available: item.available !== false, reason: str(item.reason),
338
+ })),
339
  sources: rows(row?.sources).filter((item) => !!str(item.database)).map((item) => ({
340
  database: str(item.database), answerable: item.answerable === true, reason: str(item.reason),
341
  })),
 
370
  return call(`/query/threads/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const);
371
  }
372
 
373
+ /**
374
+ * ⭐⭐ R20 β€” THE THREE DOORS THAT ARE **NOT** `mutateQueryWorkspace`, and the distinction is the
375
+ * whole design rather than a routing detail.
376
+ *
377
+ * `QUERY_MUTATION_POLICY` above is unchanged and still refuses create and update: the generated
378
+ * SPEC and its provenance are immutable, because a cited number is only worth citing if the thing
379
+ * it was computed from cannot be edited underneath it. What R20 opens is the LABELS beside it,
380
+ * and a copy, and a read. Each gets its own named endpoint with a server-side allow-list, because
381
+ * `routeQueryViewMutation` refuses `view_upsert` on a query binding LOCALLY, before any request
382
+ * leaves the browser: opening that transport would give one question two answers.
383
+ */
384
+ export function renameQuery(id: string, patch: { name?: string; description?: string }): Promise<Result<SavedQuery>> {
385
+ return call(`/query/${encodeURIComponent(id)}`, sends("PATCH", patch),
386
+ (raw) => saved(raw) as SavedQuery);
387
+ }
388
+
389
+ export function duplicateQuery(id: string): Promise<Result<SavedQuery>> {
390
+ return call(`/query/${encodeURIComponent(id)}/duplicate`, json({}), (raw) => saved(raw) as SavedQuery);
391
+ }
392
+
393
+ /**
394
+ * A DOWNLOAD, not a fetch: an ordinary same-origin link carries the session cookie, and the server
395
+ * names the file through `Content-Disposition`. Building a Blob here would mean holding the whole
396
+ * export in memory to hand it straight back to the browser.
397
+ */
398
+ export function exportQueryHref(id: string): string {
399
+ return `${API_V1}/query/${encodeURIComponent(id)}/export`;
400
+ }
401
+
402
+ /**
403
+ * ⭐ R20's thumbs. `null` CLEARS the rating: a mis-click that cannot be taken back is worse than
404
+ * no control, and the server reads a thumbs-down back into the next turn of the same thread, so
405
+ * an uncorrected one keeps steering.
406
+ */
407
+ /** What the database's own validator would not take, named rather than quietly missing. */
408
+ export interface QuerySaveResult {
409
+ scope: string;
410
+ viewId: string;
411
+ name: string;
412
+ dropped: string[];
413
+ }
414
+
415
+ /**
416
+ * ⭐ R20: the answer becomes an ordinary view of its own database.
417
+ *
418
+ * β›” The server does NOT trust the write's own 200: `view_upsert` returns the same shape when it
419
+ * saved and when it silently refused, so it reads the store back and refuses out loud instead.
420
+ * There is nothing for this client to retry, which is the point.
421
+ */
422
+ export function saveQueryToDatabase(id: string): Promise<Result<QuerySaveResult>> {
423
+ return call(`/query/${encodeURIComponent(id)}/save-to-database`, json({}), (raw) => {
424
+ const row = raw as Record<string, unknown> | null;
425
+ return {
426
+ scope: str(row?.scope), viewId: str(row?.viewId), name: str(row?.name),
427
+ dropped: (row?.dropped as unknown[] || []).filter((v): v is string => typeof v === "string"),
428
+ };
429
+ });
430
+ }
431
+
432
+ export function rateMessage(id: string, rating: "up" | "down" | null, reason?: string): Promise<Result<QueryMessage>> {
433
+ return call(`/query/messages/${encodeURIComponent(id)}/rating`,
434
+ json({ rating, ...(reason ? { reason } : {}) }), (raw) => message(raw) as QueryMessage);
435
+ }
436
+
437
  /**
438
  * The sole mutation path for a virtual Query workspace. This intentionally never falls through
439
  * to the native grid event transport and never sends `dataScope`: the opaque Query key is the authority.
web/src/query/queryParts.tsx CHANGED
@@ -4,8 +4,33 @@ import { FolderMark } from "../customer-grid/icons";
4
  import { DbIcon } from "../shell/dbFrame";
5
  import { ASSISTANT_ROUTE } from "../shell/nav";
6
  import type { NavEntry } from "../shell/nav";
 
7
  import type { SavedQuery } from "./queryApi";
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9
  /** Query has no top-right view dropdown: the rail below IS the navigation (owner item 3). */
10
  export function QueryEmpty({ loaded, unnamed = 0 }: { loaded: boolean; unnamed?: number }) {
11
  return (
@@ -39,6 +64,43 @@ export interface QueryGroup {
39
  views: SavedQuery[];
40
  }
41
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
42
  /**
43
  * ⭐⭐ OWNER ITEM 3 (2026-08-15) β€” GROUPED BY SOURCE DATABASE, WHICH IS THE WHOLE INSTRUCTION.
44
  * Verbatim: *"these AI generated query should live under the query module. And that each View
@@ -59,18 +121,75 @@ export interface QueryGroup {
59
  * because THIS list spans several databases and the grid's rail, by construction, cannot: the
60
  * grid is mounted at one scope.
61
  */
62
- export function QueryRail({ groups, activeId, onSelect, onDelete }: {
63
  groups: QueryGroup[];
64
  activeId: string;
65
  onSelect: (id: string) => void;
66
  onDelete: (id: string) => void;
 
 
 
 
 
 
 
67
  }) {
68
  // Two-click delete, in the row. A modal for "remove one of my own saved answers" is heavier
69
  // than the act; a single click with no confirm loses work to a mis-click on a hover control.
70
  const [confirming, setConfirming] = useState("");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  return (
72
  <aside className="qy-rail" aria-label="Query views">
73
  <p className="qy-rail-head">Views</p>
 
 
 
 
 
 
74
  <div className="qy-rail-scroll">
75
  {groups.map((group) => (
76
  <section className="qy-rail-group" key={group.database}>
@@ -82,29 +201,83 @@ export function QueryRail({ groups, activeId, onSelect, onDelete }: {
82
  </p>
83
  {group.views.map((view) => (
84
  <div className={"qy-rail-row" + (view.id === activeId ? " is-active" : "")} key={view.id}>
85
- <button
86
- type="button"
87
- className="qy-rail-view"
88
- aria-current={view.id === activeId ? "true" : undefined}
89
- onClick={() => { setConfirming(""); onSelect(view.id); }}
90
- title={view.question}
91
- >
92
- <span className="qy-rail-name">{view.name}</span>
93
- <span className="qy-rail-kind">{KIND_MARK[view.kind] ?? view.kind}</span>
94
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
95
  {confirming === view.id ? (
96
  <button type="button" className="qy-rail-confirm"
97
  onClick={() => { setConfirming(""); onDelete(view.id); }}>
98
  Delete
99
  </button>
100
  ) : (
101
- <button type="button" className="qy-rail-del" aria-label={`Delete ${view.name}`}
102
- onClick={() => setConfirming(view.id)}>
 
 
 
 
 
 
103
  <svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13">
104
- <path d="M3.5 4.5h9M6.5 4.5V3.2h3v1.3M5 4.5l.6 8h4.8l.6-8" />
 
105
  </svg>
106
  </button>
107
  )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  </div>
109
  ))}
110
  </section>
 
4
  import { DbIcon } from "../shell/dbFrame";
5
  import { ASSISTANT_ROUTE } from "../shell/nav";
6
  import type { NavEntry } from "../shell/nav";
7
+ import { duplicateQuery, exportQueryHref, renameQuery, saveQueryToDatabase } from "./queryApi";
8
  import type { SavedQuery } from "./queryApi";
9
 
10
+ /** Which label of which row is being typed into. One at a time, by construction. */
11
+ interface Editing { id: string; field: "name" | "description"; value: string; }
12
+
13
+ /**
14
+ * β›” THE MENU IS `position: fixed`, AND THAT IS NOT A STYLE PREFERENCE. Its natural home,
15
+ * `.qy-rail-scroll`, is `overflow-y: auto`, which clips BOTH axes: an absolutely positioned menu
16
+ * on a row near the bottom of the list renders cut off or not at all, so the reader clicks and
17
+ * nothing appears. It fails worst on the rows most likely to be used, the ones you scrolled to.
18
+ * Fixed positioning takes it out of the scroll box entirely, which costs one measurement.
19
+ *
20
+ * ⚠ `MENU_H` is an estimate used ONLY to decide whether to flip upward near the viewport floor. It
21
+ * does not size anything, so being a little wrong shifts the menu rather than breaking it.
22
+ */
23
+ const MENU_H = 190;
24
+
25
+ function menuAnchor(button: HTMLElement): { top: number; right: number } {
26
+ const rect = button.getBoundingClientRect();
27
+ const below = rect.bottom + 4;
28
+ return {
29
+ top: below + MENU_H > window.innerHeight ? Math.max(8, rect.top - MENU_H - 4) : below,
30
+ right: Math.max(8, window.innerWidth - rect.right),
31
+ };
32
+ }
33
+
34
  /** Query has no top-right view dropdown: the rail below IS the navigation (owner item 3). */
35
  export function QueryEmpty({ loaded, unnamed = 0 }: { loaded: boolean; unnamed?: number }) {
36
  return (
 
64
  views: SavedQuery[];
65
  }
66
 
67
+ /**
68
+ * ⚠ GROUPED IN THE ORDER THE DATABASES ARE GRANTED, not by artefact age. The rail is a list of
69
+ * DATABASES first (owner: "each View correspond to the relevant database"), so a new answer about
70
+ * Customers must not move the Customers heading β€” it appears under it. Within a database the
71
+ * newest is first, which is the order the server already sends.
72
+ *
73
+ * ⭐ R14 GAVE THIS LIST A SECOND HOST, which is why the loop is a function and lives HERE rather
74
+ * than inside `QueryPage`. The merged Assistant surface renders the same rail in its own left
75
+ * panel; two hosts computing "which views belong to which database" from two copies of one loop
76
+ * is one question with two normalizers, and the copy that drifts is the one nobody is looking at.
77
+ * It sits beside `QueryGroup`, the type it builds, and in a module that does NOT import the grid
78
+ * β€” a static import from `QueryPage.tsx` would pull `CustomerGrid` into the chat bundle and undo
79
+ * the lazy boundary the Assistant keeps around it.
80
+ *
81
+ * A view whose source database is not in `entries` is dropped: the caller's grant is the wall.
82
+ */
83
+ export function queryGroups(views: SavedQuery[], entries: NavEntry[]): QueryGroup[] {
84
+ const byDatabase = new Map<string, SavedQuery[]>();
85
+ for (const view of views) {
86
+ const list = byDatabase.get(view.source.database);
87
+ if (list) list.push(view);
88
+ else byDatabase.set(view.source.database, [view]);
89
+ }
90
+ const out: QueryGroup[] = [];
91
+ for (const entry of entries) {
92
+ const list = byDatabase.get(entry.key);
93
+ if (!list || entry.kind === "group") continue;
94
+ out.push({
95
+ database: entry.key,
96
+ label: list[0].source.label || entry.label,
97
+ ...(entry.icon ? { icon: entry.icon } : {}),
98
+ views: list,
99
+ });
100
+ }
101
+ return out;
102
+ }
103
+
104
  /**
105
  * ⭐⭐ OWNER ITEM 3 (2026-08-15) β€” GROUPED BY SOURCE DATABASE, WHICH IS THE WHOLE INSTRUCTION.
106
  * Verbatim: *"these AI generated query should live under the query module. And that each View
 
121
  * because THIS list spans several databases and the grid's rail, by construction, cannot: the
122
  * grid is mounted at one scope.
123
  */
124
+ export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
125
  groups: QueryGroup[];
126
  activeId: string;
127
  onSelect: (id: string) => void;
128
  onDelete: (id: string) => void;
129
+ /**
130
+ * ⭐ The rail OWNS the rename/duplicate calls and hands the host the resulting artefact to
131
+ * upsert. Two hosts render this list (the standalone page and the merged Assistant surface), so
132
+ * putting the calls in the hosts would be one question with two implementations, and the copy
133
+ * that drifts is the one nobody is looking at.
134
+ */
135
+ onChanged?: (view: SavedQuery) => void;
136
  }) {
137
  // Two-click delete, in the row. A modal for "remove one of my own saved answers" is heavier
138
  // than the act; a single click with no confirm loses work to a mis-click on a hover control.
139
  const [confirming, setConfirming] = useState("");
140
+ const [menuFor, setMenuFor] = useState("");
141
+ const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null);
142
+ const [editing, setEditing] = useState<Editing | null>(null);
143
+ // ⚠ TWO STATES, NOT ONE. A refusal and a confirmation read differently and are coloured
144
+ // differently; sharing one slot painted "Saved as ..." in the refusal red.
145
+ const [problem, setProblem] = useState("");
146
+ const [notice, setNotice] = useState("");
147
+ const said = (message: string) => { setNotice(message); setProblem(""); };
148
+ const failed = (message: string) => { setProblem(message); setNotice(""); };
149
+
150
+ const commit = async () => {
151
+ if (!editing) return;
152
+ const current = editing;
153
+ setEditing(null);
154
+ const result = await renameQuery(current.id, current.field === "name"
155
+ ? { name: current.value } : { description: current.value });
156
+ if (!result.ok) { failed(result.message); return; }
157
+ said("");
158
+ onChanged?.(result.value);
159
+ };
160
+
161
+ /**
162
+ * ⚠ THE REPLY IS SHOWN WHETHER OR NOT ANYTHING WAS DROPPED, and the dropped half is the useful
163
+ * one: a database view carries no aggregation, so "sum of deal value" lands as the rows and not
164
+ * the sum. Saying so beats a view that looks complete and answers a different question.
165
+ */
166
+ const saveToDatabase = async (view: SavedQuery) => {
167
+ const result = await saveQueryToDatabase(view.id);
168
+ if (!result.ok) { failed(result.message); return; }
169
+ said(result.value.dropped.length
170
+ ? `Saved as "${result.value.name}". Not carried over: ${result.value.dropped.join(", ")}.`
171
+ : `Saved as "${result.value.name}" in this database's own views.`);
172
+ };
173
+
174
+ const duplicate = async (id: string) => {
175
+ const result = await duplicateQuery(id);
176
+ // ⚠ The refusal is the useful half: MAX_ARTIFACTS names its cause and the remedy, and a copy
177
+ // that silently did not happen is the failure mode this whole module is written against.
178
+ if (!result.ok) { failed(result.message); return; }
179
+ said("");
180
+ onChanged?.(result.value);
181
+ onSelect(result.value.id);
182
+ };
183
+
184
  return (
185
  <aside className="qy-rail" aria-label="Query views">
186
  <p className="qy-rail-head">Views</p>
187
+ {menuFor ? (
188
+ <button type="button" className="qy-rail-scrim" aria-label="Close menu"
189
+ onClick={() => setMenuFor("")} />
190
+ ) : null}
191
+ {problem ? <p className="qy-rail-problem" role="status">{problem}</p> : null}
192
+ {notice ? <p className="qy-rail-notice" role="status">{notice}</p> : null}
193
  <div className="qy-rail-scroll">
194
  {groups.map((group) => (
195
  <section className="qy-rail-group" key={group.database}>
 
201
  </p>
202
  {group.views.map((view) => (
203
  <div className={"qy-rail-row" + (view.id === activeId ? " is-active" : "")} key={view.id}>
204
+ {editing && editing.id === view.id ? (
205
+ /* Inline, like the database sidebar's own rename. A window.prompt would block
206
+ every other event on the page and cannot carry a placeholder or a cancel. */
207
+ <form className="qy-rail-edit" onSubmit={(event) => { event.preventDefault(); void commit(); }}>
208
+ <input className="qy-rail-input" autoFocus value={editing.value}
209
+ aria-label={editing.field === "name" ? "View name" : "View description"}
210
+ placeholder={editing.field === "name" ? "Name" : "Description"}
211
+ onChange={(event) => setEditing({ ...editing, value: event.currentTarget.value })}
212
+ onBlur={() => void commit()}
213
+ onKeyDown={(event) => { if (event.key === "Escape") setEditing(null); }} />
214
+ </form>
215
+ ) : (
216
+ <button
217
+ type="button"
218
+ className="qy-rail-view"
219
+ aria-current={view.id === activeId ? "true" : undefined}
220
+ onClick={() => { setConfirming(""); onSelect(view.id); }}
221
+ title={view.description || view.question}
222
+ >
223
+ <span className="qy-rail-name">{view.name}</span>
224
+ <span className="qy-rail-kind">{KIND_MARK[view.kind] ?? view.kind}</span>
225
+ </button>
226
+ )}
227
  {confirming === view.id ? (
228
  <button type="button" className="qy-rail-confirm"
229
  onClick={() => { setConfirming(""); onDelete(view.id); }}>
230
  Delete
231
  </button>
232
  ) : (
233
+ <button type="button" className="qy-rail-more" aria-haspopup="menu"
234
+ aria-expanded={menuFor === view.id}
235
+ aria-label={`Actions for ${view.name}`}
236
+ onClick={(event) => {
237
+ if (menuFor === view.id) { setMenuFor(""); return; }
238
+ setAnchor(menuAnchor(event.currentTarget));
239
+ setMenuFor(view.id);
240
+ }}>
241
  <svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13">
242
+ <circle cx="3.2" cy="8" r="1.1" /><circle cx="8" cy="8" r="1.1" />
243
+ <circle cx="12.8" cy="8" r="1.1" />
244
  </svg>
245
  </button>
246
  )}
247
+ {menuFor === view.id ? (
248
+ /* R20: the same vocabulary a database view offers, minus the ones that mean
249
+ nothing here. Import writes rows; Share and Mark important belong to a view a
250
+ team navigates; the lock family describes a cohort. What is left is what a
251
+ person actually does with an answer they kept. */
252
+ <div className="qy-rail-menu" role="menu"
253
+ style={anchor ? { top: anchor.top, right: anchor.right } : undefined}>
254
+ {/* "Export", the database sidebar's own word. Theirs then opens a format
255
+ submenu; a submenu holding one row would be chrome for its own sake, so
256
+ this downloads the CSV the server names. */}
257
+ <a className="qy-rail-item" role="menuitem" href={exportQueryHref(view.id)}
258
+ download onClick={() => setMenuFor("")}>Export</a>
259
+ <button type="button" className="qy-rail-item" role="menuitem"
260
+ onClick={() => { setMenuFor(""); setEditing({ id: view.id, field: "name", value: view.name }); }}>
261
+ Rename
262
+ </button>
263
+ <button type="button" className="qy-rail-item" role="menuitem"
264
+ onClick={() => { setMenuFor(""); setEditing({ id: view.id, field: "description", value: view.description }); }}>
265
+ {view.description ? "Edit description" : "Add description"}
266
+ </button>
267
+ <button type="button" className="qy-rail-item" role="menuitem"
268
+ onClick={() => { setMenuFor(""); void duplicate(view.id); }}>Duplicate</button>
269
+ {/* R20's one difference from a database view's menu: this one can BECOME one. */}
270
+ <button type="button" className="qy-rail-item" role="menuitem"
271
+ onClick={() => { setMenuFor(""); void saveToDatabase(view); }}>
272
+ Save into the database
273
+ </button>
274
+ <button type="button" className="qy-rail-item is-danger" role="menuitem"
275
+ onClick={() => { setMenuFor(""); setConfirming(view.id); }}>Delete</button>
276
+ </div>
277
+ ) : null}
278
+ {view.description && !(editing && editing.id === view.id) ? (
279
+ <p className="qy-rail-note">{view.description}</p>
280
+ ) : null}
281
  </div>
282
  ))}
283
  </section>
web/src/settings/permsModel.ts CHANGED
@@ -211,6 +211,14 @@ const FIELD_TYPE_TABLE: Record<FieldType, true> = {
211
  // and this listing cannot land in two changes. Nothing about the permissions wall treats a
212
  // code column specially β€” it is granted and hidden like any other column.
213
  code: true,
 
 
 
 
 
 
 
 
214
  };
215
 
216
  export const KNOWN_FIELD_TYPES: ReadonlySet<string> = new Set(Object.keys(FIELD_TYPE_TABLE));
 
211
  // and this listing cannot land in two changes. Nothing about the permissions wall treats a
212
  // code column specially β€” it is granted and hidden like any other column.
213
  code: true,
214
+ // ⭐⭐ Wave-34 (owner ruling R13) β€” `ai_enrich`, the same one-key edit for the same reason the
215
+ // four notes above give: the exhaustiveness alarm is a COMPILE error, so the union and this
216
+ // listing cannot land in two changes. `settings/**` is in NO lane's fence this wave, which
217
+ // makes it less of an exception than `json` and `code` were, not more.
218
+ // ⚠ Nothing about the permissions wall treats an enrichment column specially: it is granted
219
+ // and hidden like any other column. The thing that IS special about it (a human-edited cell is
220
+ // never overwritten by the agent) is a WRITE law in `core.user_tables`, not a grant.
221
+ ai_enrich: true,
222
  };
223
 
224
  export const KNOWN_FIELD_TYPES: ReadonlySet<string> = new Set(Object.keys(FIELD_TYPE_TABLE));
web/src/shell/LoginPage.tsx CHANGED
@@ -1,145 +1,161 @@
1
- // ---------------------------------------------------------------------------
2
- // shell/LoginPage.tsx β€” X5: the branded door.
3
- //
4
- // THE CARD LANGUAGE IS `gate()`'s, DELIBERATELY (app.py:1228). Same lockup
5
- // (mark + "Loopable" wordmark, side by side β€” wave-9 I5), same 410px card with
6
- // ONE hairline closing all four sides (wave-10 I25a removed the gold top-rule:
7
- // "there is an orange line at the top of the sign-in place… it's ugly" β€” do not
8
- // reintroduce an accent edge here), same two fields and one primary button.
9
- // Two doors into one product must not look like two products, and this is the
10
- // door that outlives the other: `gate()` dies with app.py at EXIT-6.
11
- //
12
- // WHAT IT DOES NOT INHERIT: `gate()`'s `st.empty()` slot dance is a workaround
13
- // for Streamlit replacing elements POSITIONALLY, labelled in its own docstring
14
- // as something "the successor shell deletes". This is that successor. React
15
- // unmounts what it unmounts; there is no ghost card to chase.
16
- //
17
- // NO CLIENT-SIDE BACKOFF (X5: the server owns it). The only local rule is
18
- // single-flight β€” a second submit while one is in the air is dropped, which is
19
- // double-click protection, not rate limiting.
20
- // ---------------------------------------------------------------------------
21
-
22
- import { useState } from "react";
23
- import type { FormEvent } from "react";
24
- import { Brand } from "./Brand";
25
- import { login } from "./session";
26
- import type { SessionUser } from "./session";
27
-
28
- /** The reveal toggle the host's password field ships natively (BaseWeb's eye).
29
- * Two doors, one affordance. */
30
- function EyeIcon({ off }: { off: boolean }) {
31
- return (
32
- <svg viewBox="0 0 20 20" aria-hidden="true">
33
- <path d="M2.2 10s2.8-4.6 7.8-4.6S17.8 10 17.8 10 15 14.6 10 14.6 2.2 10 2.2 10z" />
34
- <circle cx="10" cy="10" r="2.3" />
35
- {off ? <path d="M4 16 16 4" /> : null}
36
- </svg>
37
- );
38
- }
39
-
40
- export default function LoginPage({ onSignedIn }: { onSignedIn: (user: SessionUser) => void }) {
41
- const [username, setUsername] = useState("");
42
- const [password, setPassword] = useState("");
43
- const [error, setError] = useState("");
44
- const [busy, setBusy] = useState(false);
45
- const [reveal, setReveal] = useState(false);
46
-
47
- async function submit(e: FormEvent) {
48
- e.preventDefault();
49
- if (busy) return;
50
- setBusy(true);
51
- setError("");
52
- const result = await login(username, password);
53
- if (result.ok) {
54
- // No setBusy(false): the parent unmounts this tree on success, and
55
- // flipping state on the way out is how "update on an unmounted
56
- // component" warnings are born.
57
- onSignedIn(result.user);
58
- return;
59
- }
60
- setBusy(false);
61
- setError(result.message);
62
- setPassword("");
63
- }
64
-
65
- // An error that survives the correction is an error that reads as sticky.
66
- function edit(set: (v: string) => void) {
67
- return (e: { target: { value: string } }) => {
68
- if (error) setError("");
69
- set(e.target.value);
70
- };
71
- }
72
-
73
- return (
74
- <div className="login-root">
75
- <div className="login-card">
76
- <Brand size={44} className="login-brand" />
77
-
78
- <form className="login-form" onSubmit={submit} noValidate>
79
- {/* Labels are present and hidden, not absent. `gate()` collapses them
80
- because Streamlit gives no other way to get a placeholder-only
81
- field; here the label exists for assistive tech and the visual is
82
- identical. */}
83
- {/* No autoFocus: the other door cannot autofocus (Streamlit), and the
84
- focus ring it would paint on load reads as a highlighted error on
85
- an untouched form. */}
86
- <label className="lp-sr-only" htmlFor="login-username">Username</label>
87
- <input
88
- id="login-username"
89
- className="login-input"
90
- name="username"
91
- type="text"
92
- autoComplete="username"
93
- placeholder="Username"
94
- value={username}
95
- onChange={edit(setUsername)}
96
- disabled={busy}
97
- aria-invalid={error ? true : undefined}
98
- />
99
-
100
- <label className="lp-sr-only" htmlFor="login-password">Password</label>
101
- <div className="login-field">
102
- <input
103
- id="login-password"
104
- className="login-input"
105
- name="password"
106
- type={reveal ? "text" : "password"}
107
- autoComplete="current-password"
108
- placeholder="Password"
109
- value={password}
110
- onChange={edit(setPassword)}
111
- disabled={busy}
112
- aria-invalid={error ? true : undefined}
113
- />
114
- {/* tabIndex βˆ’1: Tab goes Username β†’ Password β†’ Log in; the reveal is
115
- a mouse affordance, same as the host's. */}
116
- <button
117
- type="button"
118
- className="login-eye"
119
- aria-label={reveal ? "Hide password" : "Show password"}
120
- onClick={() => setReveal((v) => !v)}
121
- disabled={busy}
122
- tabIndex={-1}
123
- >
124
- <EyeIcon off={reveal} />
125
- </button>
126
- </div>
127
-
128
- {/* Disabled through THREE channels (the real attribute, the cursor and
129
- the opacity), never opacity alone β€” a control that only looks dead
130
- is a control that still takes the click. */}
131
- <button className="login-submit" type="submit" disabled={busy}>
132
- {busy ? "Signing in…" : "Log in"}
133
- </button>
134
- </form>
135
-
136
- {/* The message line is ALWAYS in the layout (min-height: 1lh) so an
137
- arriving error does not shove the card. `role="status"` rather than
138
- "alert": polite announcement, no interruption. */}
139
- <p className="login-msg" role="status" aria-live="polite">
140
- {error}
141
- </p>
142
- </div>
143
- </div>
144
- );
145
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // shell/LoginPage.tsx β€” X5: the branded door.
3
+ //
4
+ // THE CARD LANGUAGE IS `gate()`'s, DELIBERATELY (app.py:1228). Same lockup
5
+ // (mark + "Loopable" wordmark, side by side β€” wave-9 I5), same 410px card with
6
+ // ONE hairline closing all four sides (wave-10 I25a removed the gold top-rule:
7
+ // "there is an orange line at the top of the sign-in place… it's ugly" β€” do not
8
+ // reintroduce an accent edge here), same two fields and one primary button.
9
+ // Two doors into one product must not look like two products, and this is the
10
+ // door that outlives the other: `gate()` dies with app.py at EXIT-6.
11
+ //
12
+ // WHAT IT DOES NOT INHERIT: `gate()`'s `st.empty()` slot dance is a workaround
13
+ // for Streamlit replacing elements POSITIONALLY, labelled in its own docstring
14
+ // as something "the successor shell deletes". This is that successor. React
15
+ // unmounts what it unmounts; there is no ghost card to chase.
16
+ //
17
+ // NO CLIENT-SIDE BACKOFF (X5: the server owns it). The only local rule is
18
+ // single-flight β€” a second submit while one is in the air is dropped, which is
19
+ // double-click protection, not rate limiting.
20
+ // ---------------------------------------------------------------------------
21
+
22
+ import { useState } from "react";
23
+ import type { FormEvent } from "react";
24
+ import { Brand } from "./Brand";
25
+ import { login } from "./session";
26
+ import type { SessionUser } from "./session";
27
+
28
+ /** The reveal toggle the host's password field ships natively (BaseWeb's eye).
29
+ * Two doors, one affordance. */
30
+ function EyeIcon({ off }: { off: boolean }) {
31
+ return (
32
+ <svg viewBox="0 0 20 20" aria-hidden="true">
33
+ <path d="M2.2 10s2.8-4.6 7.8-4.6S17.8 10 17.8 10 15 14.6 10 14.6 2.2 10 2.2 10z" />
34
+ <circle cx="10" cy="10" r="2.3" />
35
+ {off ? <path d="M4 16 16 4" /> : null}
36
+ </svg>
37
+ );
38
+ }
39
+
40
+ export default function LoginPage({ onSignedIn }: { onSignedIn: (user: SessionUser) => void }) {
41
+ const [username, setUsername] = useState("");
42
+ const [password, setPassword] = useState("");
43
+ const [error, setError] = useState("");
44
+ const [busy, setBusy] = useState(false);
45
+ const [reveal, setReveal] = useState(false);
46
+
47
+ async function submit(e: FormEvent) {
48
+ e.preventDefault();
49
+ if (busy) return;
50
+ setBusy(true);
51
+ setError("");
52
+ const result = await login(username, password);
53
+ if (result.ok) {
54
+ // No setBusy(false): the parent unmounts this tree on success, and
55
+ // flipping state on the way out is how "update on an unmounted
56
+ // component" warnings are born.
57
+ onSignedIn(result.user);
58
+ return;
59
+ }
60
+ setBusy(false);
61
+ setError(result.message);
62
+ setPassword("");
63
+ }
64
+
65
+ // An error that survives the correction is an error that reads as sticky.
66
+ function edit(set: (v: string) => void) {
67
+ return (e: { target: { value: string } }) => {
68
+ if (error) setError("");
69
+ set(e.target.value);
70
+ };
71
+ }
72
+
73
+ return (
74
+ <div className="login-root">
75
+ <div className="login-card">
76
+ <Brand size={44} className="login-brand" />
77
+
78
+ {/* ⭐ WAVE 34 R9 β€” THE ONE LINE OF SELL ON THIS SCREEN. Owner, 2026-08-16:
79
+ *"Come up with selling points: Automate all business workflows, add it to the login
80
+ page. Don't make Royal Imports think im selling to other businesses though, so just
81
+ add a catchy slogan."*
82
+
83
+ β›” THE SECOND SENTENCE IS THE HARD CONSTRAINT AND IT IS EASY TO MISS. Royal Imports is
84
+ tenant #0 and their team signs in here every day; a line that reads like marketing
85
+ copy aimed at OTHER companies tells them, correctly, that they are looking at a demo
86
+ of a product being sold elsewhere. So this is a PROMISE ABOUT THEIR OWN TOOL, phrased
87
+ with no audience in it: no "for teams", no "businesses", no "your company", no plan
88
+ names, no call to action. "Every workflow, automated." is a claim the app makes about
89
+ itself, which happens to also be the selling point.
90
+
91
+ ⚠ No dash of any kind (R6). A slogan is exactly where an em dash wants to appear. */}
92
+ <p className="login-tagline">Every workflow, automated.</p>
93
+
94
+ <form className="login-form" onSubmit={submit} noValidate>
95
+ {/* Labels are present and hidden, not absent. `gate()` collapses them
96
+ because Streamlit gives no other way to get a placeholder-only
97
+ field; here the label exists for assistive tech and the visual is
98
+ identical. */}
99
+ {/* No autoFocus: the other door cannot autofocus (Streamlit), and the
100
+ focus ring it would paint on load reads as a highlighted error on
101
+ an untouched form. */}
102
+ <label className="lp-sr-only" htmlFor="login-username">Username</label>
103
+ <input
104
+ id="login-username"
105
+ className="login-input"
106
+ name="username"
107
+ type="text"
108
+ autoComplete="username"
109
+ placeholder="Username"
110
+ value={username}
111
+ onChange={edit(setUsername)}
112
+ disabled={busy}
113
+ aria-invalid={error ? true : undefined}
114
+ />
115
+
116
+ <label className="lp-sr-only" htmlFor="login-password">Password</label>
117
+ <div className="login-field">
118
+ <input
119
+ id="login-password"
120
+ className="login-input"
121
+ name="password"
122
+ type={reveal ? "text" : "password"}
123
+ autoComplete="current-password"
124
+ placeholder="Password"
125
+ value={password}
126
+ onChange={edit(setPassword)}
127
+ disabled={busy}
128
+ aria-invalid={error ? true : undefined}
129
+ />
130
+ {/* tabIndex βˆ’1: Tab goes Username β†’ Password β†’ Log in; the reveal is
131
+ a mouse affordance, same as the host's. */}
132
+ <button
133
+ type="button"
134
+ className="login-eye"
135
+ aria-label={reveal ? "Hide password" : "Show password"}
136
+ onClick={() => setReveal((v) => !v)}
137
+ disabled={busy}
138
+ tabIndex={-1}
139
+ >
140
+ <EyeIcon off={reveal} />
141
+ </button>
142
+ </div>
143
+
144
+ {/* Disabled through THREE channels (the real attribute, the cursor and
145
+ the opacity), never opacity alone β€” a control that only looks dead
146
+ is a control that still takes the click. */}
147
+ <button className="login-submit" type="submit" disabled={busy}>
148
+ {busy ? "Signing in…" : "Log in"}
149
+ </button>
150
+ </form>
151
+
152
+ {/* The message line is ALWAYS in the layout (min-height: 1lh) so an
153
+ arriving error does not shove the card. `role="status"` rather than
154
+ "alert": polite announcement, no interruption. */}
155
+ <p className="login-msg" role="status" aria-live="polite">
156
+ {error}
157
+ </p>
158
+ </div>
159
+ </div>
160
+ );
161
+ }
web/src/shell/NavExtras.tsx CHANGED
@@ -12,8 +12,8 @@
12
 
13
  import { useEffect, useRef, useState } from "react";
14
  import type { ReactNode } from "react";
15
- import { fetchSchema } from "./nav";
16
- import type { NavFolder, SchemaPayload, TableFootprint } from "./nav";
17
  // ⚠ WAVE 19 R8 / C1 β€” IMPORTED, NEVER REDEFINED, and the contract says so in as
18
  // many words ("Shapes/tones = the grid's existing vocabulary verbatim (12
19
  // shapes, 5 tones); B imports, never redefines"). A second copy of this
@@ -735,6 +735,7 @@ export function CreateNewRow({
735
  export function FolderHead({
736
  folder,
737
  count,
 
738
  open,
739
  collapsed,
740
  onToggle,
@@ -745,6 +746,10 @@ export function FolderHead({
745
  }: {
746
  folder: NavFolder;
747
  count: number;
 
 
 
 
748
  open: boolean;
749
  collapsed: boolean;
750
  onToggle: () => void;
@@ -785,6 +790,22 @@ export function FolderHead({
785
  <FolderIcon />
786
  <span className="shell-nav-label">{folder.name}</span>
787
  <span className="shell-nav-foldercount">{count}</span>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
788
  </button>
789
  <button
790
  type="button"
 
12
 
13
  import { useEffect, useRef, useState } from "react";
14
  import type { ReactNode } from "react";
15
+ import { fetchSchema, importantBadge } from "./nav";
16
+ import type { NavFolder, NavImportant, SchemaPayload, TableFootprint } from "./nav";
17
  // ⚠ WAVE 19 R8 / C1 β€” IMPORTED, NEVER REDEFINED, and the contract says so in as
18
  // many words ("Shapes/tones = the grid's existing vocabulary verbatim (12
19
  // shapes, 5 tones); B imports, never redefines"). A second copy of this
 
735
  export function FolderHead({
736
  folder,
737
  count,
738
+ important,
739
  open,
740
  collapsed,
741
  onToggle,
 
746
  }: {
747
  folder: NavFolder;
748
  count: number;
749
+ /** ⭐⭐ WAVE 34 R1 / C1 β€” the SUM of this folder's members' mark-important numbers, set by
750
+ * `foldNav` for a CLOSED folder only. Absent β‡’ draw nothing, which is both the open case and
751
+ * the far more common "nobody marked anything in here" case. */
752
+ important?: NavImportant;
753
  open: boolean;
754
  collapsed: boolean;
755
  onToggle: () => void;
 
790
  <FolderIcon />
791
  <span className="shell-nav-label">{folder.name}</span>
792
  <span className="shell-nav-foldercount">{count}</span>
793
+ {/* ⭐⭐ WAVE 34 R1 / C1 β€” *"a minimized folder should show the SUM of the numbers"*.
794
+ β›” BESIDE `count`, NEVER INSTEAD OF IT. `count` is how many BLOCKS the folder holds and
795
+ has meant that since wave 14; this is a sum of RECORDS. Collapsing them into one slot
796
+ would make the same numeral mean two things depending on whether anything inside had
797
+ ever been marked, which is worse than two numbers.
798
+ ⚠ The `title` is what tells them apart for a reader who sees two numerals; the glyph
799
+ cannot, and DESIGN.md's "never over-explain" law is about copy on screen, not about a
800
+ tooltip that only appears on hover. */}
801
+ {importantBadge(important) ? (
802
+ <span
803
+ className="shell-nav-count"
804
+ title={`${importantBadge(important)} in the important views inside this folder`}
805
+ >
806
+ {importantBadge(important)}
807
+ </span>
808
+ ) : null}
809
  </button>
810
  <button
811
  type="button"
web/src/shell/Shell.tsx CHANGED
The diff for this file is too large to render. See raw diff
 
web/src/shell/nav.ts CHANGED
@@ -113,6 +113,80 @@ export interface NavPage {
113
  * never a control that silently disappears.
114
  */
115
  locked?: boolean;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
116
  }
117
 
118
  /**
@@ -190,6 +264,8 @@ export function parsePages(body: unknown): NavPage[] {
190
  // ⭐ WAVE 27 item 3 / C9 β€” same `=== true` strictness. A locked database is a padlock
191
  // beside a name, so a drifted server sending a truthy string must not paint one.
192
  ...(p.locked === true ? { locked: true } : {}),
 
 
193
  });
194
  }
195
  return out;
@@ -583,9 +659,22 @@ export async function deleteTable(key: string): Promise<{ ok: boolean; error?: s
583
  }
584
  }
585
 
586
- /** One rendered row of the folded nav: a folder head, or an entry (optionally inside one). */
 
 
 
 
 
 
 
587
  export type NavRow =
588
- | { kind: "folder"; folder: NavFolder; count: number; open: boolean }
 
 
 
 
 
 
589
  | { kind: "entry"; entry: NavEntry; folderId?: string };
590
 
591
  /**
@@ -628,10 +717,40 @@ export function foldNav(
628
  for (const c of b.children)
629
  out.push({ kind: "entry", entry: c, ...(folderId ? { folderId } : {}) });
630
  };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
631
  for (const f of prefs.folders) {
632
  const members = byFolder.get(f.id) ?? [];
633
  const open = !closed.has(f.id);
634
- out.push({ kind: "folder", folder: f, count: members.length, open });
 
 
 
 
 
635
  if (open) for (const b of members) emit(b, f.id);
636
  }
637
  for (const b of loose) emit(b);
@@ -738,6 +857,11 @@ export interface NavEntry {
738
  /** ⭐ WAVE 27 item 3 / C9 β€” a LOCKED database: no new records, fields still fine (see the
739
  * long note on `NavPage.locked`). Absent β‡’ unlocked. */
740
  locked?: boolean;
 
 
 
 
 
741
  /**
742
  * ⭐ WAVE 25 (D-54) β€” THIS ROW IS A SURFACE, NOT A DATABASE.
743
  *
@@ -897,6 +1021,13 @@ export function shapeNav(pages: NavPage[], appBase: string): NavEntry[] {
897
  // ⭐ WAVE 27 item 3 / C9 β€” THE SECOND HALF, for the reason stated directly above: a flag
898
  // parsed onto the payload and dropped here reaches no row anybody renders.
899
  ...(p.locked ? { locked: true } : {}),
 
 
 
 
 
 
 
900
  // ⭐ D-54 β€” the databases/surfaces split, stamped at the ONE place rows are built.
901
  // Not `kind`: a surface is `native` (this tree renders it) and so are the grids, so the
902
  // existing discriminator cannot answer this question and widening it would have made
@@ -1057,8 +1188,59 @@ export const LEGACY_ROUTES: Readonly<Record<string, string>> = {
1057
  * Home β€” a worse answer to the same question.
1058
  */
1059
  "ai-agent": ASSISTANT_ROUTE,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1060
  };
1061
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1062
  /** The current key for a hash that may name a retired one. Identity for everything else. */
1063
  export function canonicalRoute(route: string): string {
1064
  return LEGACY_ROUTES[route] ?? route;
 
113
  * never a control that silently disappears.
114
  */
115
  locked?: boolean;
116
+ /**
117
+ * ⭐⭐ WAVE 34 (ruling R1, contract C1) β€” HOW MANY RECORDS THIS DATABASE'S **IMPORTANT** VIEWS
118
+ * RESOLVE TO.
119
+ *
120
+ * R1 moves the mark-important number OFF the view sidebar's header and INTO the database
121
+ * navigation, and that move is what forces this onto the wire: the flyout has no grid, so the
122
+ * client cannot compute it the way `CustomerGrid::importantTotal` does.
123
+ *
124
+ * β›” THE THREE FIELDS ARE NOT REDUNDANT AND `partial` IS THE LOAD-BEARING ONE. `marked` counts
125
+ * VIEWS, `counted` counts RECORDS, and `partial` says at least one marked view could not be
126
+ * counted at all. `/nav` holds a rows-free projection, so a FILTERED view is uncountable there
127
+ * by construction; a sum that silently skipped those would be short by an unknown amount, which
128
+ * is exactly the silent absence `D-205` exists to end, one level up.
129
+ *
130
+ * ⚠ PER USER, never tenant-wide. Views live in a per-database bucket split by username, so the
131
+ * same database answers `{marked: 1, counted: 13}` for one account and `{marked: 0}` for
132
+ * another. Measured on tenant #0: `leadership` vs `admin` on `customer_data`.
133
+ *
134
+ * ⚠ ABSENT means "not a database, or the server could not read its views" β€” the second case is
135
+ * named in `degraded`, exactly like the `ut_*` merge. It does NOT mean zero: a database with
136
+ * nothing marked ships `{marked: 0, counted: 0, partial: false}`, because a key a consumer has
137
+ * to test for is a key a consumer forgets to test for.
138
+ */
139
+ important?: NavImportant;
140
+ }
141
+
142
+ /** C1's per-database important payload. See `NavPage.important` for why all three fields exist. */
143
+ export interface NavImportant {
144
+ marked: number;
145
+ counted: number;
146
+ partial: boolean;
147
+ }
148
+
149
+ /**
150
+ * The wire's important block, validated like every other flag on this payload: prune, never
151
+ * invent. A malformed one reads as ABSENT, which renders no badge β€” the safe direction, because
152
+ * the alternative is a confident number derived from junk.
153
+ */
154
+ export function parseNavImportant(raw: unknown): NavImportant | undefined {
155
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
156
+ const r = raw as { marked?: unknown; counted?: unknown; partial?: unknown };
157
+ const int = (v: unknown): number | null =>
158
+ typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : null;
159
+ const marked = int(r.marked);
160
+ const counted = int(r.counted);
161
+ if (marked === null || counted === null) return undefined;
162
+ // `=== true`, the same strictness `manage`/`canDelete`/`locked` use: a drifted server sending a
163
+ // truthy string must not turn a real count into a `+`.
164
+ return { marked, counted, partial: r.partial === true };
165
+ }
166
+
167
+ /**
168
+ * ⭐⭐ THE BADGE TEXT, AND IT IS ONE FUNCTION BECAUSE THERE ARE TWO CALLERS.
169
+ *
170
+ * A database row and a COLLAPSED FOLDER both draw this number, from different inputs (one entry's
171
+ * block, a sum over a folder's members). Two renderers deciding "is there a number here" by their
172
+ * own rules is [[one-question-two-normalizers]] with the answer on screen, so the rule lives here
173
+ * and both call it.
174
+ *
175
+ * The four cases, and the third is the one the ticket calls out by name:
176
+ * Β· nothing marked -> `null`. R1's own clause: NOT a zero. Most databases are this.
177
+ * Β· a real count -> the number, plus `+` when at least one view could not be counted.
178
+ * Β· nothing countable -> `"+"` ALONE. β›” NEVER `"0+"`: that string is `D-205` itself, the
179
+ * thing the owner photographed, and rendering it here would be the
180
+ * same defect moved into the nav. A bare `+` says "there is something
181
+ * here we could not count" without claiming the something is zero.
182
+ * Β· a genuine zero -> `"0"`. A marked view that matches no records is a fact worth
183
+ * seeing, and it is distinguishable from the case above because
184
+ * nothing was skipped.
185
+ */
186
+ export function importantBadge(imp?: NavImportant): string | null {
187
+ if (!imp || imp.marked <= 0) return null;
188
+ if (imp.counted > 0) return `${imp.counted}${imp.partial ? "+" : ""}`;
189
+ return imp.partial ? "+" : "0";
190
  }
191
 
192
  /**
 
264
  // ⭐ WAVE 27 item 3 / C9 β€” same `=== true` strictness. A locked database is a padlock
265
  // beside a name, so a drifted server sending a truthy string must not paint one.
266
  ...(p.locked === true ? { locked: true } : {}),
267
+ // ⭐⭐ WAVE 34 R1 / C1 β€” the important block, validated on the way in like the icon.
268
+ ...(parseNavImportant(p.important) ? { important: parseNavImportant(p.important)! } : {}),
269
  });
270
  }
271
  return out;
 
659
  }
660
  }
661
 
662
+ /** One rendered row of the folded nav: a folder head, or an entry (optionally inside one).
663
+ *
664
+ * ⚠ `count` and `important` are DIFFERENT NUMBERS and neither is a version of the other.
665
+ * `count` is how many BLOCKS the folder holds (a top-level entry plus its depth-1 children counts
666
+ * as one), which is the number that has been drawn beside a folder name since wave 14.
667
+ * `important` is R1's aggregate over its members' record counts. Putting them in one slot would
668
+ * make a folder head say "3" and mean two different things depending on whether anything inside
669
+ * it was ever marked. */
670
  export type NavRow =
671
+ | {
672
+ kind: "folder";
673
+ folder: NavFolder;
674
+ count: number;
675
+ open: boolean;
676
+ important?: NavImportant;
677
+ }
678
  | { kind: "entry"; entry: NavEntry; folderId?: string };
679
 
680
  /**
 
717
  for (const c of b.children)
718
  out.push({ kind: "entry", entry: c, ...(folderId ? { folderId } : {}) });
719
  };
720
+ // ⭐⭐ WAVE 34 R1 / C1 β€” a COLLAPSED folder carries the SUM of its members' numbers.
721
+ //
722
+ // β›” SUMMED FROM THE SAME `members` ARRAY THAT PRODUCES `count`, in the same pass, and that is
723
+ // the whole correctness of it. The alternative β€” a second lookup in `Shell.tsx` keyed off
724
+ // `dbEntries` β€” would derive the number from one list while the rows came from another, which is
725
+ // the defect `shell.test.ts` already pins for `count` ("a folder whose only member was filtered
726
+ // out counts 0, not 1"). It bites hardest during SEARCH, when the caller passes
727
+ // `EMPTY_NAV_PREFS` and no folder renders at all.
728
+ //
729
+ // ⚠ CHILDREN CONTRIBUTE. A block is a head plus its depth-1 children, and a child is its own
730
+ // database with its own marked views, so the head's number would be short without them.
731
+ const aggregate = (blocks: Block[]): NavImportant | undefined => {
732
+ let marked = 0;
733
+ let counted = 0;
734
+ let partial = false;
735
+ for (const b of blocks) {
736
+ for (const e of [b.head, ...b.children]) {
737
+ if (!e.important) continue;
738
+ marked += e.important.marked;
739
+ counted += e.important.counted;
740
+ partial = partial || e.important.partial;
741
+ }
742
+ }
743
+ return marked > 0 ? { marked, counted, partial } : undefined;
744
+ };
745
  for (const f of prefs.folders) {
746
  const members = byFolder.get(f.id) ?? [];
747
  const open = !closed.has(f.id);
748
+ // β›” ONLY WHEN CLOSED. An open folder shows its members' own numbers directly underneath, so a
749
+ // head number there would be the same records counted twice on one screen β€” the ticket's
750
+ // "an expanded one does not double-count", enforced at the source rather than in the renderer.
751
+ const imp = open ? undefined : aggregate(members);
752
+ out.push({ kind: "folder", folder: f, count: members.length, open,
753
+ ...(imp ? { important: imp } : {}) });
754
  if (open) for (const b of members) emit(b, f.id);
755
  }
756
  for (const b of loose) emit(b);
 
857
  /** ⭐ WAVE 27 item 3 / C9 β€” a LOCKED database: no new records, fields still fine (see the
858
  * long note on `NavPage.locked`). Absent β‡’ unlocked. */
859
  locked?: boolean;
860
+ /** ⭐⭐ WAVE 34 R1 / C1 β€” the mark-important counts (see the long note on `NavPage.important`).
861
+ * THE SECOND HALF of that flag, and it is here for the reason `canDelete` and `locked` are:
862
+ * a value parsed onto the payload and dropped in `shapeNav` reaches no row anybody renders,
863
+ * which is precisely how wave 20 shipped four wirings that went nowhere. */
864
+ important?: NavImportant;
865
  /**
866
  * ⭐ WAVE 25 (D-54) β€” THIS ROW IS A SURFACE, NOT A DATABASE.
867
  *
 
1021
  // ⭐ WAVE 27 item 3 / C9 β€” THE SECOND HALF, for the reason stated directly above: a flag
1022
  // parsed onto the payload and dropped here reaches no row anybody renders.
1023
  ...(p.locked ? { locked: true } : {}),
1024
+ // ⭐⭐ WAVE 34 R1 / C1 β€” THE SECOND HALF AGAIN, AND IT WAS MISSED ON THE FIRST TRY.
1025
+ // `parsePages` validated `important` onto the payload and this function dropped it, so the
1026
+ // number reached the wire, survived validation, and never reached a row. The `shapeNav`
1027
+ // leg in `shell.test.ts` is what found it, minutes after being written; the two comments
1028
+ // above say this exact thing about two earlier flags and it happened anyway, which is the
1029
+ // argument for the leg rather than for the comment.
1030
+ ...(p.important ? { important: p.important } : {}),
1031
  // ⭐ D-54 β€” the databases/surfaces split, stamped at the ONE place rows are built.
1032
  // Not `kind`: a surface is `native` (this tree renders it) and so are the grids, so the
1033
  // existing discriminator cannot answer this question and widening it would have made
 
1188
  * Home β€” a worse answer to the same question.
1189
  */
1190
  "ai-agent": ASSISTANT_ROUTE,
1191
+ /**
1192
+ * ⭐⭐ WAVE 34 (ruling R14, contract C2) β€” QUERY IS MERGED INTO THE ASSISTANT.
1193
+ *
1194
+ * Owner: *"Combine the AI assistant module AND the Query module. In the secondary navigation at
1195
+ * the top, just above '+ New chat', an easily accessible toggle between Chat / Query."* So there
1196
+ * is ONE route and one rail row, and `#/query` is a hash people hold: bookmarked, pasted, and
1197
+ * left in an open tab since wave 32.
1198
+ *
1199
+ * ⚠ THIS ENTRY IS THE INSTANT-RESOLVE HALF ONLY. It stops the frame rendering Home for the one
1200
+ * paint before the URL is rewritten; `REWRITTEN_ROUTES` below is what actually puts
1201
+ * `?mode=query` on the address bar, and the Query LIST only opens because of that.
1202
+ */
1203
+ [QUERY_ROUTE]: ASSISTANT_ROUTE,
1204
+ };
1205
+
1206
+ /**
1207
+ * ⭐⭐ WAVE 34 R14 / C2 β€” retired hashes the shell REWRITES rather than merely resolves.
1208
+ *
1209
+ * β›” WHY THIS IS NOT JUST ANOTHER `LEGACY_ROUTES` ROW. A legacy row is an internal translation:
1210
+ * the address bar keeps saying `#/ai-agent` and nothing downstream cares. Query cannot work that
1211
+ * way, because the merged surface decides WHICH LIST to show from `?mode=` on the hash β€” so a
1212
+ * resolve that never touched the URL would land the reader on the Chat list, which is the opposite
1213
+ * of what their bookmark asked for.
1214
+ */
1215
+ export const REWRITTEN_ROUTES: Readonly<Record<string, string>> = {
1216
+ [QUERY_ROUTE]: `${ASSISTANT_ROUTE}?mode=query`,
1217
  };
1218
 
1219
+ /**
1220
+ * The ROUTE KEY in a hash, with any query string or nested fragment stripped.
1221
+ *
1222
+ * β›” THE BUG THIS EXISTS TO PREVENT WAS REPORTED BY LANE D BEFORE IT COULD HAPPEN (post D-1), and
1223
+ * it is invisible until you try it: the shell read the WHOLE remainder of the hash as the key, so
1224
+ * `#/assistant?mode=query` resolved to the key `assistant?mode=query`, matched no route, and fell
1225
+ * through to Home. The redirect below would have "worked" β€” right URL, wrong screen β€” and every
1226
+ * gate keyed on the redirect's target string would have been green.
1227
+ */
1228
+ export function routeKeyOf(hash: string): string {
1229
+ return hash.replace(/^#\/?/, "").split("?")[0].split("#")[0];
1230
+ }
1231
+
1232
+ /**
1233
+ * The hash this one must BECOME, or null when it is already right.
1234
+ *
1235
+ * ⚠ RETURNS NULL FOR AN ALREADY-REWRITTEN HASH, which is the whole loop-safety argument: writing
1236
+ * `location.hash` fires `hashchange`, the caller asks again, the key is now `assistant`, and this
1237
+ * answers null. One rewrite, never a cycle.
1238
+ */
1239
+ export function rewrittenHash(hash: string): string | null {
1240
+ const target = REWRITTEN_ROUTES[routeKeyOf(hash)];
1241
+ return target ? `#/${target}` : null;
1242
+ }
1243
+
1244
  /** The current key for a hash that may name a retired one. Identity for everything else. */
1245
  export function canonicalRoute(route: string): string {
1246
  return LEGACY_ROUTES[route] ?? route;
web/src/shell/navExtras.css CHANGED
@@ -61,6 +61,19 @@
61
  display: none;
62
  }
63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  .shell-rail-skeleton {
65
  display: flex;
66
  flex-direction: column;
@@ -193,6 +206,43 @@
193
  font-variant-numeric: tabular-nums;
194
  }
195
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
  /* ── wave 14 C-NAVFOLD: drag placement (items 4/6) ───────────────────────── */
197
 
198
  .shell-nav-row.is-dragging {
 
61
  display: none;
62
  }
63
 
64
+ /* ⭐ WAVE 34 R15 β€” "then a subtle line separation". SUBTLE is the whole specification, so this
65
+ is a hairline at the rail's own border token and nothing else: no margin collapse games, no
66
+ inset, no second colour. It groups Home+Inbox / Assistant+Agents / Database+Connectors.
67
+ ⚠ It is a direct child of `.shell-nav`, which is what makes the `is-rail-loading` rule above
68
+ hide it with the rows. Do not wrap the groups in `<div>`s to get the same look: that would put
69
+ the rows one level deeper and the `> *` in that selector would stop reaching them, silently
70
+ bringing back the two-row-states defect the whole rule exists to prevent. */
71
+ .shell-nav-sep {
72
+ height: 1px;
73
+ margin: 6px 8px;
74
+ background: var(--lp-line);
75
+ }
76
+
77
  .shell-rail-skeleton {
78
  display: flex;
79
  flex-direction: column;
 
206
  font-variant-numeric: tabular-nums;
207
  }
208
 
209
+ /* ── ⭐⭐ WAVE 34 R1 / contract C1: the mark-important number, in the database list ──────────
210
+ Owner, 2026-08-16: *"the mark important number should be moved into the database navigation,
211
+ so when we click database, it shows the list of the databases, each showing their number"* and
212
+ *"we don't need the important word, just show the number, make it smaller"*.
213
+
214
+ Drawn by TWO callers: a database row (`Shell.tsx`'s flyout) and a COLLAPSED folder head
215
+ (`FolderHead` above), both through `nav.ts::importantBadge` so the "is there a number here"
216
+ rule is written once.
217
+
218
+ ⚠ `--lp-fs-4xs` (0.625rem = 10px against the 17px root), NOT `--lp-fs-3xs` (11.69px) which the
219
+ sibling `.shell-nav-foldercount` uses. Two reasons and the first is the ruling: R1 says SMALLER,
220
+ and a badge at the same size as the count already beside it is not smaller, it is a second copy.
221
+ The second is arithmetic: C1 asks for <=11px and `--lp-fs-3xs` computes to 11.69px, which is
222
+ over. The font gate enforces token-or-nothing, never a computed ceiling, so nothing in code
223
+ catches picking the wrong token; it is checked here instead.
224
+
225
+ ⚠ `margin-left: auto` ON THE ROW, RESET INSIDE A FOLDER HEAD. A database row needs the number
226
+ at the right edge so a list of them reads as a column; a folder head already gives `auto` to
227
+ `.shell-nav-foldercount`, and a SECOND `auto` there splits the free space between the two
228
+ numbers and pushes them apart with a gap in the middle. Hence the adjacency rule below. */
229
+ .shell-nav-count {
230
+ flex: none;
231
+ margin-left: auto;
232
+ padding-left: 6px;
233
+ font-size: var(--lp-fs-4xs);
234
+ font-weight: 400;
235
+ color: var(--lp-muted);
236
+ font-variant-numeric: tabular-nums;
237
+ /* A row is a flex line with an ellipsised label; the number must never be the thing that
238
+ shrinks, or a "13" becomes a "1" with no way to tell. */
239
+ white-space: nowrap;
240
+ }
241
+ /* Inside a folder head the number follows the member count, which already holds the right edge. */
242
+ .shell-nav-foldercount + .shell-nav-count {
243
+ margin-left: 0;
244
+ }
245
+
246
  /* ── wave 14 C-NAVFOLD: drag placement (items 4/6) ───────────────────────── */
247
 
248
  .shell-nav-row.is-dragging {