fsanyoto commited on
Commit
852b0d3
Β·
verified Β·
1 Parent(s): fc71889

Deploy AIOS web (React glide grid + FastAPI slice)

Browse files
RELEASES.json CHANGED
@@ -1,5 +1,5 @@
1
  {
2
- "current": "93740ac",
3
  "releases": [
4
  {
5
  "version": "v25",
 
1
  {
2
+ "current": "abfbfbe",
3
  "releases": [
4
  {
5
  "version": "v25",
VERSION CHANGED
@@ -1 +1 @@
1
- 93740ac
 
1
+ abfbfbe
api/deps.py CHANGED
@@ -10,6 +10,7 @@ epoch-revoked, or naming an unknown tenant β†’ **401**. Authenticated but not gr
10
  **403**. Never an empty 200: an empty list is a legitimate answer meaning "no rows", and using it
11
  to mean "you are not allowed" is how a permission bug becomes invisible.
12
  """
 
13
  import os
14
  import sys
15
  import time
@@ -68,6 +69,382 @@ class Session:
68
  f"this workspace does not include {module_key}")
69
 
70
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
71
  def _user_for(claims):
72
  """The account named by a verified cookie, or None β€” with the epoch check that makes a
73
  stateless cookie revocable.
 
10
  **403**. Never an empty 200: an empty list is a legitimate answer meaning "no rows", and using it
11
  to mean "you are not allowed" is how a permission bug becomes invisible.
12
  """
13
+ import copy
14
  import os
15
  import sys
16
  import time
 
69
  f"this workspace does not include {module_key}")
70
 
71
 
72
+ # ── Assistant: app-stored, permissioned snapshot boundary (W33-T71 / C8) ───────────────────────
73
+ #
74
+ # This is deliberately an ordinary function rather than a route. E owns the Assistant's tool
75
+ # routes and model/provider choices; every one of those tools must come through this one boundary
76
+ # before it can see a database. Keeping it here gives the reader the already-verified Session and
77
+ # prevents Query's schema-only `_target` helper from becoming an accidental data authority.
78
+ #
79
+ # The important absence is as much part of the contract as the code below: this module never calls
80
+ # the Customer/Product pool builders, connector routes, provider routes, Odoo, or an environment
81
+ # lookup from this boundary. A cache miss is a named refusal, never permission to refresh from the
82
+ # source of truth. That is what "data the app already stores or serves" means in executable form.
83
+ _ASSISTANT_BUILT_INS = {
84
+ "customer_data": {"label": "Odoo customers", "cache_prefix": "pool"},
85
+ "product_data": {"label": "Odoo products", "cache_prefix": "product_pool"},
86
+ }
87
+
88
+
89
+ def _assistant_refuse(status, code, message):
90
+ """Raise the public error shape without exposing a source implementation detail."""
91
+ raise err(status, code, message)
92
+
93
+
94
+ def _assistant_tenant_allows(session: Session, database: str) -> bool:
95
+ """Read the served tenant catalogue without reusing the legacy account gate."""
96
+ tenant = getattr(getattr(session, "runtime", None), "tenant", None)
97
+ config = getattr(tenant, "config", None) or {}
98
+ modules = config.get("modules", "all") if isinstance(config, dict) else "all"
99
+ return modules == "all" or str(database) in {str(key) for key in (modules or ())}
100
+
101
+
102
+ def _assistant_preflight_request(session: Session, database: str, canonical_fields,
103
+ requested_fields, filters, *, require_explicit_grant: bool):
104
+ """Refuse an unresolved grant, field or filter before a stored record is touched."""
105
+ import core.perm_scope as perm_scope
106
+
107
+ grant = perm_scope.assistant_entry(session.user, database)
108
+ if require_explicit_grant and grant is None:
109
+ _assistant_refuse(403, "assistant_forbidden",
110
+ "your current permissions do not allow this database")
111
+ visible = (perm_scope.assistant_visible_fields(canonical_fields, session.user, database)
112
+ if grant is not None else list(canonical_fields or ()))
113
+ try:
114
+ perm_scope.validate_assistant_filter((grant or {}).get("filter"), canonical_fields)
115
+ _assistant_requested_fields(requested_fields, visible)
116
+ perm_scope.validate_assistant_filter(filters, visible)
117
+ except ValueError as exc:
118
+ _assistant_refuse(400, "assistant_bad_filter", str(exc))
119
+ return grant, visible
120
+
121
+
122
+ def _assistant_cached_builtin(session: Session, database: str, requested_fields=None, filters=None):
123
+ """The exact in-process snapshot for one built-in database, or a loud cache-miss refusal.
124
+
125
+ This intentionally reads `runtime.pool_cache` directly. Calling either route's `_pool_for`
126
+ would make a first Assistant request call Odoo; calling a cache helper would make a stale entry
127
+ refresh. Neither is data already stored/served by this process.
128
+ """
129
+ import core.perm_scope as perm_scope
130
+
131
+ # Unlike an interactive grid, the Assistant has no legacy-grant compatibility path: the
132
+ # caller needs a declared, migrated database grant. `may_access` retains the old wall for
133
+ # the grid rollout, so it is necessary but not sufficient at this boundary.
134
+ declared_access = perm_scope.assistant_entry(session.user, database) is not None
135
+ if not _assistant_tenant_allows(session, database) or not declared_access:
136
+ _assistant_refuse(403, "assistant_forbidden",
137
+ "your current permissions do not allow this database")
138
+
139
+ # These are static field contracts, not the route assemblies: route assemblies can add
140
+ # per-user workspace state and can call a source builder. The Assistant begins with the
141
+ # already-stored base rows only; resolve their grant/field/filter contract *before* the cache
142
+ # read so a denied field or malformed operand cannot touch a stored record.
143
+ import aios_grid
144
+ fields = aios_grid.FIELDS if database == "customer_data" else aios_grid.product_fields()
145
+ _assistant_preflight_request(session, database, fields, requested_fields, filters,
146
+ require_explicit_grant=True)
147
+
148
+ team_id, agent = perm_scope.derive_pool_scope(session.user, database)
149
+ cache_key = (("pool", team_id, agent) if database == "customer_data"
150
+ else ("product_pool", team_id))
151
+ cache = getattr(session.runtime, "pool_cache", None)
152
+ entry = cache.get(cache_key) if isinstance(cache, dict) else None
153
+ if (not isinstance(entry, tuple) or len(entry) < 2
154
+ or not isinstance(entry[1], (list, tuple))
155
+ or any(not isinstance(row, dict) for row in entry[1])):
156
+ _assistant_refuse(409, "assistant_snapshot_unavailable",
157
+ "this database has no app-stored snapshot for your current scope")
158
+ return list(entry[1]), [dict(field) for field in fields if isinstance(field, dict)], entry[0]
159
+
160
+
161
+ def _assistant_user_table(session: Session, database: str, requested_fields=None, filters=None):
162
+ """Read a materialised user-table document after a rows-free database grant check."""
163
+ from core import user_tables
164
+
165
+ # A guessed `ut_*` key must never make `user_tables.get()` copy the tenant's rows before the
166
+ # table resolver refuses it. `all_defs` can fall back to a whole-document read for older
167
+ # stores, so use the runtime's projection primitive directly and fail closed if it is absent.
168
+ # The existing `may_open` remains THE resolver; it is lent this rows-dropped document.
169
+ try:
170
+ definitions = session.runtime.get_projection('user_tables', drop=('rows',))
171
+ except Exception:
172
+ _assistant_refuse(409, "assistant_snapshot_unavailable",
173
+ "this database has no readable definition snapshot")
174
+ if not isinstance(definitions, dict):
175
+ _assistant_refuse(409, "assistant_snapshot_unavailable",
176
+ "this database has no readable definition snapshot")
177
+ definition = definitions.get(database)
178
+ if not isinstance(definition, dict):
179
+ _assistant_refuse(404, "assistant_unknown_database", "that database is not available")
180
+ definition_runtime = user_tables.lend(session.runtime, user_tables=definitions)
181
+ if not user_tables.may_open(database, session.uname, session.admin, st=definition_runtime):
182
+ # Deliberately no label/owner in this response: knowing a key must not become an oracle
183
+ # for somebody else's database.
184
+ _assistant_refuse(403, "assistant_forbidden",
185
+ "your current permissions do not allow this database")
186
+
187
+ fields = [dict(field) for field in (definition.get("fields") or [])
188
+ if isinstance(field, dict) and field.get("key")]
189
+ _assistant_preflight_request(session, database, fields, requested_fields, filters,
190
+ require_explicit_grant=False)
191
+
192
+ # Only an admitted table may now cross the app-stored record seam. This call still has no
193
+ # upstream fallback: it is the tenant's materialised document already held by the runtime.
194
+ table = user_tables.get(database, st=session.runtime)
195
+ if not isinstance(table, dict):
196
+ _assistant_refuse(409, "assistant_snapshot_unavailable",
197
+ "this database changed before its stored snapshot could be read")
198
+ if not user_tables.materialises(database, st=session.runtime, defn=table):
199
+ # Read-through rows belong to the app's DuckDB mirror, but this fence does not own its
200
+ # tenant-checked query binding. Returning an empty table would be a false answer; E must
201
+ # consume the mirror through its own `runtime.mirror_cursor`-guarded seam.
202
+ _assistant_refuse(409, "assistant_snapshot_unavailable",
203
+ "this database is mirror-served and needs its materialised reader")
204
+
205
+ # A table's field contract was authorized from the rows-free definition. If it changed while
206
+ # the materialised document was fetched, fail rather than projecting a schema that was never
207
+ # checked.
208
+ actual_fields = [dict(field) for field in (table.get("fields") or [])
209
+ if isinstance(field, dict) and field.get("key")]
210
+ if actual_fields != fields:
211
+ _assistant_refuse(409, "assistant_snapshot_unavailable",
212
+ "this database changed before its stored snapshot could be read")
213
+ raw_rows = table.get("rows") or {}
214
+ if not isinstance(raw_rows, dict):
215
+ _assistant_refuse(409, "assistant_snapshot_unavailable",
216
+ "this database does not have a readable stored snapshot")
217
+ keys = {field["key"] for field in fields}
218
+ rows = []
219
+ for record_id, raw in raw_rows.items():
220
+ if not str(record_id).isdigit() or not isinstance(raw, dict):
221
+ continue
222
+ # Matches `routes_tables.scoped_pool`: user-table permission is table-level, and only
223
+ # declared fields cross this boundary. `pid` is the opaque record identity required to
224
+ # bind a returned record to a single-source Query artefact; it is not a selectable field.
225
+ rows.append({"pid": int(record_id),
226
+ **{key: raw.get(key) for key in keys if key in raw}})
227
+ rows.sort(key=lambda row: row["pid"])
228
+ return rows, fields, (table.get("updated") or table.get("created")), table.get("label") or database
229
+
230
+
231
+ def _assistant_requested_fields(fields, visible_fields):
232
+ """Resolve an explicit field selection without turning an unknown key into an omission."""
233
+ by_key = {str(field.get("key")): field for field in visible_fields
234
+ if isinstance(field, dict) and field.get("key")}
235
+ if fields is None:
236
+ return list(by_key)
237
+ if not isinstance(fields, (list, tuple)):
238
+ _assistant_refuse(400, "assistant_bad_fields", "fields must be a list of field keys")
239
+ out = []
240
+ for raw in fields:
241
+ key = raw.strip() if isinstance(raw, str) else ""
242
+ if not key or key not in by_key or key in out:
243
+ _assistant_refuse(400, "assistant_unknown_field",
244
+ "every requested field must be visible in this database")
245
+ out.append(key)
246
+ return out
247
+
248
+
249
+ def assistant_read_scope(session: Session, database, fields=None, filters=None):
250
+ """Return a detached, permissioned snapshot for one Assistant tool call.
251
+
252
+ Signature (the C8 contract for E):
253
+ assistant_read_scope(session, database, fields, filters) -> {
254
+ database, label, source_kind, source_version, retrieved_at,
255
+ permission_scope_applied, fields, filters, records
256
+ }
257
+
258
+ `database` is exactly one source. `fields` is an explicit list of visible field keys (or
259
+ ``None`` for every visible field). `filters` is ``None`` or the strict tree accepted by
260
+ `core.perm_scope.validate_assistant_filter`; it rejects unknown, hidden, inactive and
261
+ unsupported operands before a row is read. The returned dictionaries are deep copies, never
262
+ a live cache/store object, cursor, connector, route, provider, or credential.
263
+ """
264
+ import core.perm_scope as perm_scope
265
+ from harness import filter_eval
266
+
267
+ key = str(database or "").strip()
268
+ if key in _ASSISTANT_BUILT_INS:
269
+ rows, canonical_fields, source_version = _assistant_cached_builtin(session, key, fields, filters)
270
+ label = _ASSISTANT_BUILT_INS[key]["label"]
271
+ source_kind = "runtime_cache"
272
+ elif key.startswith("ut_"):
273
+ rows, canonical_fields, source_version, label = _assistant_user_table(session, key, fields, filters)
274
+ source_kind = "materialised_user_table"
275
+ else:
276
+ _assistant_refuse(404, "assistant_unknown_database", "that database is not available")
277
+
278
+ # Permanent permissions run against the full source contract. A permission filter may name
279
+ # a field that is intentionally hidden from the returned schema; resolving it after the field
280
+ # projection would make that filter unanswerable and (depending on the evaluator) could widen.
281
+ # Validate even that grant before evaluating it: `permits` would deny an unknown operand row
282
+ # by row, but the Assistant contract requires an explicit refusal for unresolved grants.
283
+ grant = perm_scope.assistant_entry(session.user, key)
284
+ if source_kind != "materialised_user_table" and grant is None:
285
+ # `_assistant_cached_builtin` already made this decision before touching its cache. Keep
286
+ # this guard adjacent to the field/row projection so a future source cannot forget it.
287
+ _assistant_refuse(403, "assistant_forbidden",
288
+ "your current permissions do not allow this database")
289
+ try:
290
+ perm_scope.validate_assistant_filter((grant or {}).get("filter"), canonical_fields)
291
+ except ValueError as exc:
292
+ _assistant_refuse(400, "assistant_bad_filter", str(exc))
293
+ permitted_rows = (perm_scope.assistant_apply_row_scope(rows, session.user, key, canonical_fields)
294
+ if grant is not None else list(rows or ()))
295
+ visible = (perm_scope.assistant_visible_fields(canonical_fields, session.user, key)
296
+ if grant is not None else list(canonical_fields))
297
+ selected_keys = _assistant_requested_fields(fields, visible)
298
+ try:
299
+ requested_filter = perm_scope.validate_assistant_filter(filters, visible)
300
+ except ValueError as exc:
301
+ _assistant_refuse(400, "assistant_bad_filter", str(exc))
302
+ if requested_filter is not None:
303
+ permitted_rows = [row for row in permitted_rows
304
+ if filter_eval.permits(requested_filter, row, visible)]
305
+
306
+ records = []
307
+ for row in permitted_rows:
308
+ if not isinstance(row, dict) or row.get("pid") is None:
309
+ continue
310
+ records.append({"pid": copy.deepcopy(row["pid"]),
311
+ **{field: copy.deepcopy(row.get(field)) for field in selected_keys}})
312
+
313
+ return {
314
+ "database": key,
315
+ "label": str(label),
316
+ "source_kind": source_kind,
317
+ "source_version": copy.deepcopy(source_version),
318
+ "retrieved_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
319
+ "permission_scope_applied": True,
320
+ "fields": [copy.deepcopy(field) for field in visible
321
+ if field.get("key") in selected_keys],
322
+ "filters": copy.deepcopy(requested_filter),
323
+ "records": records,
324
+ }
325
+
326
+
327
+ def assistant_permitted_sources(session: Session, databases):
328
+ """Which of `databases` the caller may still read β€” WITHOUT reading one record.
329
+
330
+ β›” THIS EXISTS BECAUSE `GET /query` LISTS ARTEFACTS, AND AN ARTEFACT IS NOT A READ.
331
+ `routes_query._public_state` has to drop every saved view whose source the caller has since
332
+ lost. The obvious way to ask that question is `assistant_read_scope(..., fields=[])` β€” and it
333
+ is the wrong one: for a `ut_*` source that call runs `user_tables.get()`, i.e. a WHOLE-DOCUMENT
334
+ read of the tenant store (measured at **28.6 MB / 703 ms warm on tenant #0**, and 99.9% of those
335
+ bytes are `rows` this question does not look at), then projects every row down to its `pid` and
336
+ throws the result away. Once per saved view, up to `MAX_ARTIFACTS = 200` of them, on a route
337
+ that BOTH the Assistant and Query call on mount. That is `/nav`'s old 1+N whole-document read
338
+ (D-175, an 11.0 s median) rebuilt inside a new route, and every fixture with two artefacts
339
+ passes it [[measure-the-real-call]].
340
+
341
+ So this asks the permission question and ONLY the permission question:
342
+ * the rows-free `user_tables` projection is read at most ONCE for the whole batch, and lent
343
+ to `may_open` exactly as `_assistant_user_table` lends it;
344
+ * a built-in resolves from the tenant catalogue and the declared grant, with no `pool_cache`
345
+ read β€” a cache MISS is a data-availability fact, not a permission one, and refusing to list
346
+ an artefact because its snapshot is cold would silently delete the user's history from the
347
+ rail every time the process restarted [[empty-answer-vs-unfinished-answer]].
348
+
349
+ ⚠ It is deliberately NOT a second permission wall. Every predicate here is the same call
350
+ `assistant_read_scope` makes for the same source; nothing may be admitted here that the read
351
+ boundary would refuse, which is what `verify_query.py` asserts in both directions.
352
+ """
353
+ return {key for key, row in assistant_source_status(session, databases).items()
354
+ if row["permitted"]}
355
+
356
+
357
+ def assistant_source_status(session: Session, databases=None):
358
+ """`{key: {permitted, answerable, reason}}` β€” WITHOUT reading one record.
359
+
360
+ ⭐⭐ `answerable` EXISTS BECAUSE `permitted` IS NOT THE QUESTION THE READER IS ASKING.
361
+ Measured on tenant #0, signed in, 2026-08-15: the Assistant offered **twelve** source chips
362
+ and could answer for **two**. The other ten are the `ut_odoo_*` grids, and
363
+ `_assistant_user_table` refuses every one of them β€” *"this database is mirror-served and needs
364
+ its materialised reader"* β€” because their rows live UNCAPPED in the DuckDB mirror and are read
365
+ through it (W30/R6 is exactly why). The refusal is correct, named and fail-closed. The defect
366
+ is that it arrives one spent prompt at a time: a picker that offers ten doors which cannot
367
+ open is the same shape as the rail that painted two rows differently from five.
368
+ ⚠ It is R6's SECOND SENTENCE, in the family that rule was written for β€” a limit that cannot be
369
+ removed must be REPORTED with its cause, never silently enforced.
370
+
371
+ β›” AND IT COSTS NOTHING, WHICH IS WHY IT IS HERE RATHER THAN IN THE REGISTER.
372
+ `user_tables.materialises()` reads the `readThrough` flag off the DEFINITION, and the
373
+ rows-free projection this function already reads carries it. So the batch that answers
374
+ "may they?" answers "can it?" in the same pass, with no extra store read.
375
+
376
+ ⚠ `databases=None` enumerates every source this tenant HAS (the two built-ins plus every
377
+ `ut_*` in the projection) rather than taking a caller-supplied list. A client list would make
378
+ the answer depend on what the caller thought to ask about, and the nav has already told them
379
+ what they hold.
380
+ """
381
+ import core.perm_scope as perm_scope
382
+ from core import user_tables
383
+
384
+ definitions = None
385
+ read_definitions = False
386
+
387
+ def _definitions():
388
+ nonlocal definitions, read_definitions
389
+ if not read_definitions:
390
+ read_definitions = True
391
+ try:
392
+ candidate = session.runtime.get_projection('user_tables', drop=('rows',))
393
+ except Exception:
394
+ candidate = None
395
+ definitions = candidate if isinstance(candidate, dict) else None
396
+ return definitions
397
+
398
+ if databases is None:
399
+ wanted = list(_ASSISTANT_BUILT_INS) + [
400
+ key for key in sorted((_definitions() or {}))
401
+ if isinstance(key, str) and key.startswith("ut_")
402
+ ]
403
+ else:
404
+ wanted = [str(key or "").strip() for key in (databases or ())]
405
+ wanted = list(dict.fromkeys(key for key in wanted if key))
406
+
407
+ out = {}
408
+ for key in wanted:
409
+ row = {"permitted": False, "answerable": False, "reason": ""}
410
+ out[key] = row
411
+ if key in _ASSISTANT_BUILT_INS:
412
+ if not (_assistant_tenant_allows(session, key)
413
+ and perm_scope.assistant_entry(session.user, key) is not None):
414
+ continue
415
+ row["permitted"] = True
416
+ # ⚠ A CACHE MISS IS A DATA-AVAILABILITY FACT, NOT A PERMISSION ONE, and it must not
417
+ # drop the source from the list β€” an artefact already built on it stays listed. It is
418
+ # reported as not-yet-answerable, with the action that fixes it. MEASURED: the pool is
419
+ # filled by a grid REQUEST, not by `AIOS_PREWARM` β€” a fresh process refuses until
420
+ # somebody opens the database once.
421
+ team_id, agent = perm_scope.derive_pool_scope(session.user, key)
422
+ cache_key = (("pool", team_id, agent) if key == "customer_data"
423
+ else ("product_pool", team_id))
424
+ cache = getattr(session.runtime, "pool_cache", None)
425
+ entry = cache.get(cache_key) if isinstance(cache, dict) else None
426
+ if isinstance(entry, tuple) and len(entry) >= 2 and isinstance(entry[1], (list, tuple)):
427
+ row["answerable"] = True
428
+ else:
429
+ row["reason"] = "no snapshot loaded yet β€” open this database once, then ask"
430
+ continue
431
+ if not key.startswith("ut_"):
432
+ continue
433
+ defs = _definitions()
434
+ definition = (defs or {}).get(key)
435
+ if not isinstance(definition, dict):
436
+ continue
437
+ lent = user_tables.lend(session.runtime, user_tables=defs)
438
+ if not user_tables.may_open(key, session.uname, session.admin, st=lent):
439
+ continue
440
+ row["permitted"] = True
441
+ if user_tables.materialises(key, st=lent, defn=definition):
442
+ row["answerable"] = True
443
+ else:
444
+ row["reason"] = "served live from its connector mirror β€” the assistant cannot read it yet"
445
+ return out
446
+
447
+
448
  def _user_for(claims):
449
  """The account named by a verified cookie, or None β€” with the epoch check that makes a
450
  stateless cookie revocable.
api/routes_query.py CHANGED
@@ -1,713 +1,666 @@
1
- """routes_query.py β€” WAVE 32 item 7 (ruling R1, contract C5): the Query module's doors.
2
-
3
- POST /api/v1/query/build {question, scope} -> {spec, explain, refused} WRITES NOTHING
4
- POST /api/v1/query/save {question, scope, spec} -> the saved view
5
- GET /api/v1/query -> the AI-built views this caller may open
6
- DELETE /api/v1/query/{qid} -> forget one
7
-
8
- **The AI turns a question about ONE granted database into a view SPEC. It may only build what the
9
- backend already supports** (R1), and the wall on which database it may name is the EXISTING one.
10
-
11
- β›” THE FOUNDATION IS NOT `harness/semantic.py` β€” MEASURED, W32-T50
12
- (`.claude/wiki/waves/wave32/proto/query-spike/README.md`). That layer is bound to 8 hand-authored
13
- YAML topics over Odoo mirror tables: **0** are `ut_`-bound, and a `ut_` key answers
14
- `ModelError: topic 'ut_…' has no store binding` from both `store_columns` and `store_query`. Its
15
- own `save_view` writes `platform/data/store/views.json`, a chart workspace the product's grid
16
- cannot read. What it lends this file is its POSTURE, quoted from its system prompt: *the model
17
- never writes SQL and never invents a field name β€” it only picks registry keys.*
18
-
19
- The real foundation is the view stack that already ships, and this module is `routes_templates.py`
20
- with a model where the curated registry used to be:
21
-
22
- Β· the wall `routes_templates._target_or_refuse` (imported, never re-implemented)
23
- Β· the column contract `core.view_templates.columns_named` / `missing_columns`
24
- Β· the view skeleton `core.view_templates._view` (one definition of what a view is)
25
- Β· the validators `aios_grid._clean_display` / `clean_filter_tree`
26
- Β· the writer `core.table_store.make(ws).save_view(..., shared=False)`
27
-
28
- β›”β›” THE VALIDATORS DROP; THEY DO NOT REFUSE β€” and C5 requires a REFUSAL. `_clean_display` returns
29
- None for an unknown mode, and a view with no display block IS A GRID; `clean_filter_tree` drops a
30
- leaf naming an unknown column, and a view whose only condition was dropped SHOWS EVERY ROW under a
31
- name promising a shortlist (`view_templates`' own scar, and `_seed_wave17`'s before it). So
32
- membership is tested HERE, first and explicitly, and the cleaners run second as a belt. They are
33
- the vocabulary ORACLE, never the refusal.
34
-
35
- β›” THE REFUSAL PATH IS A MODEL PROPERTY, so this module declares its OWN provider order rather than
36
- inheriting `analyst.available_providers()`' cheap-first one. Measured over 4 arms x 9 questions
37
- (`proto/query-spike/refusal-arms.json`): `cerebras/gpt-oss-120b` refuses 4/4 unanswerable questions
38
- with a usable sentence; `groq/llama-3.3-70b` refuses **0/4** and instead emits a fully valid spec
39
- answering a different question ("Revenue by Creator" against a database holding no revenue).
40
- Changing refusal from an enum value to its own tool changed cerebras not at all and made groq
41
- worse.
42
- ⚠ AND THE LADDER STILL FALLS THROUGH ON A 429, WHICH IS A COMPROMISE, NOT AN OVERSIGHT β€” stated
43
- here because the first draft of this header claimed the opposite. A door that dies whenever one
44
- free tier rate-limits is worse than one that answers with a weaker model behind the SAME validator
45
- and the SAME human read-back. What "fail closed" means precisely: when the whole ladder is
46
- unreachable the answer is a sentence and never a view. Which provider answered rides back in the
47
- response so a wrong answer stays diagnosable (`_call_model`'s note).
48
-
49
- β›” AND A PROVIDER `400` CAN BE THE MODEL'S REFUSAL. Groq validates the model's tool arguments
50
- against the tool schema server-side and answers `400 tool_use_failed` carrying the model's real
51
- output in `failed_generation` β€” so a refusal that omits a `required` property arrives as an HTTP
52
- error. `analyst._live_chat` calls `raise_for_status()` and discards that body, which is why this
53
- file has its own small ladder: `required` is `["kind"]` ALONE (a refusal must be a legal argument
54
- object), and a 400 body is READ before it is treated as transport.
55
-
56
- ⚠ WHAT NO VALIDATOR CAN SEE, stated so nobody thinks the gate covers it: a spec whose every column
57
- is real, every op legal, kind in the vocabulary and target granted, which answers a DIFFERENT
58
- QUESTION. That class is invisible to server-side validation. The mechanism against it is C5's own
59
- shape β€” `build` returns `{spec, explain}` and WRITES NOTHING, `explain` is DERIVED from the
60
- validated spec (never asked of the model, which would let it flatter its own output), and saving is
61
- a second, explicit call. The person is the last validator, by design.
62
- """
63
- import datetime as _dt
64
- import hashlib
65
- import json
66
- import os
67
- import re
68
-
69
- from fastapi import APIRouter, Body, Depends
70
-
71
- from deps import Session, err, require_session
72
-
73
- router = APIRouter(prefix="/api/v1")
74
-
75
- #: The store key holding the REGISTRY of AI-built views, per tenant: {qid: {...}}. The view itself
76
- #: is a real saved view in its target's own workspace β€” this is only the index, so listing the
77
- #: Query module never has to read every granted table's workspace to find them. Same split
78
- #: `core/alerts.py` draws between definitions and the inbox.
79
- QUERY_KEY = 'query_views'
80
-
81
- MAX_QUESTION = 500
82
- MAX_VISIBLE = 24
83
- MAX_REGISTRY = 200 #: a working list, not an archive (alerts' MAX_NOTIFICATIONS rule)
84
-
85
- #: ⭐ THE KINDS THE AI MAY EMIT. Held here as a NAMED SET, and `verify_query.py` asserts it is a
86
- #: subset of BOTH `aios_grid.DISPLAY_MODES` (the stored vocabulary) and the client's
87
- #: `CREATABLE_MODES` (which means "has a mounted renderer" β€” `verify_icons::mode_parity` enforces
88
- #: that both ways), AND that the excluded remainder is exactly `QUERY_EXCLUDED`. So a mode that
89
- #: lands in `CREATABLE_MODES` later REDS the gate instead of being silently un-offered β€” the
90
- #: `swipe`/`form` failure ("whole, correct and unreachable") in reverse.
91
- QUERY_KINDS = ('grid', 'chart', 'calendar', 'kanban', 'timeseries', 'map', 'list')
92
-
93
- #: Creatable modes the AI deliberately may NOT emit, each with the reason it cannot be derived
94
- #: from a question about existing records. A reason per member, because "excluded" with no cause
95
- #: is how a hold outlives its own justification.
96
- QUERY_EXCLUDED = {
97
- 'form': "a form is a door records come IN through and shows none β€” a question about existing "
98
- "records cannot describe one, and its spec carries emails and a public token",
99
- 'catalog': "a catalog is a published artifact needing page authoring and product codes "
100
- "(MAX_CATALOG_CODES is spent in page order), not a shape of the current rows",
101
- 'swipe': "a swipe deck needs an empty single-select plus a chosen option per direction β€” two "
102
- "product decisions a question does not contain",
103
- }
104
-
105
- #: Mode-required refs, and the field FAMILY each one demands. ⚠ STRICTER THAN `_clean_display` ON
106
- #: PURPOSE. That function accepts any ref naming a real field and leaves the meaning to the client,
107
- #: which "degrades a wrong-typed ref to that surface's default rather than to an error" β€” correct
108
- #: for a person clicking a picker who SEES the result, wrong for a generated spec nobody has looked
109
- #: at yet. Measured: a free model stacked a kanban on `category` (a text column) for a contract
110
- #: with ZERO select columns, and every validator passed it.
111
- _MODE_REFS = {
112
- 'kanban': [('stackField', ('select',), True)],
113
- 'calendar': [('dateField', ('date',), True)],
114
- 'timeseries': [('dateField', ('date',), True)],
115
- 'map': [('colorField', ('select',), False), ('sizeField', ('int', 'currency', 'pct'), False)],
116
- }
117
-
118
-
119
- def _now_iso():
120
- """UTC WITH the offset β€” a naive stamp is unsubtractable in a browser (D-18, alerts' rule)."""
121
- return _dt.datetime.now(_dt.timezone.utc).isoformat()
122
-
123
-
124
- def _grid():
125
- import aios_grid
126
- return aios_grid
127
-
128
-
129
- def _templates():
130
- import core.view_templates as view_templates
131
- return view_templates
132
-
133
-
134
- def _target(session, scope):
135
- """`(fields, source)` for a database this session may open β€” or a refusal.
136
-
137
- β›” THE WALL IS `routes_templates._target_or_refuse`, IMPORTED AND NOT COPIED. It is the same
138
- predicate the nav uses, per topic (`ut_*` -> `user_tables.may_open`; a built-in -> the module
139
- grant `session.require`; anything else a 404 that is not a directory of what exists), and T51's
140
- instruction is explicit that the wall is re-used, never re-implemented beside itself. The
141
- private name is deliberate: an alias would mean editing a file no lane's fence covers.
142
-
143
- It returns field KEYS; this door also needs each field's TYPE (for the prompt and for
144
- `_MODE_REFS`), so the types are read from the same source the wall consulted β€” never from a
145
- list assembled here.
146
- """
147
- import routes_templates
148
- keys, source = routes_templates._target_or_refuse(session, scope)
149
- key = str(scope or '').strip()
150
- defs = []
151
- if key.startswith('ut_'):
152
- import core.user_tables as user_tables
153
- defn = user_tables.get(key, st=session.runtime) or {}
154
- by_key = {str(f.get('key')): f for f in (defn.get('fields') or []) if f.get('key')}
155
- else:
156
- g = _grid()
157
- raw = g.product_fields() if key == 'product_data' else g.FIELDS
158
- by_key = {str(f.get('key')): f for f in raw if f.get('key')}
159
- for k in keys:
160
- f = by_key.get(k) or {}
161
- defs.append({'key': k, 'label': str(f.get('label') or k)[:80],
162
- 'type': str(f.get('type') or 'text')})
163
- return defs, source
164
-
165
-
166
- # ── the model ───────────────────────────────────────────────────────────────────────────────
167
-
168
- #: This module's OWN provider order β€” cerebras first. See the header: the refusal is a model
169
- #: property, and the ladder's cheap-first order is exactly wrong for a contract that includes one.
170
- #: Override with `AIOS_QUERY_PROVIDER`. The credentials themselves stay in ONE place
171
- #: (`analyst.PROVIDERS`), so a rotated key or a new model reaches this file too.
172
- QUERY_PROVIDER_ORDER = ('cerebras', 'groq', 'openrouter')
173
-
174
-
175
- def _providers():
176
- import harness.analyst as analyst
177
- pin = os.environ.get("AIOS_QUERY_PROVIDER")
178
- by_name = {p["name"]: p for p in analyst.PROVIDERS}
179
- order = [pin] if pin else list(QUERY_PROVIDER_ORDER)
180
- return [by_name[n] for n in order
181
- if n in by_name and os.environ.get(by_name[n]["env"])]
182
-
183
-
184
- def _spec_schema(field_keys):
185
- """The tool schema the model fills. β›” `required` IS `["kind"]` ALONE β€” see the header: a
186
- refusal carries no name, and groq rejects a schema-violating refusal with a 400 that reads
187
- exactly like a transport failure."""
188
- ops = sorted(_grid().FILTER_OPS)
189
- col = {"type": "string", "enum": sorted(field_keys)}
190
- return {
191
- "type": "object",
192
- "properties": {
193
- "kind": {"type": "string", "enum": list(QUERY_KINDS) + ["refused"],
194
- "description": "refused = this database cannot answer the question"},
195
- "name": {"type": "string", "description": "a short title for the view"},
196
- "refusal": {"type": "string",
197
- "description": "when kind=refused: ONE plain sentence naming what is "
198
- "missing"},
199
- "visible": {"type": "array", "items": col},
200
- "filters": {"type": "array", "items": {
201
- "type": "object",
202
- "properties": {"colId": col, "op": {"type": "string", "enum": ops},
203
- "value": {"type": "string"}},
204
- "required": ["colId", "op"]}},
205
- "filterConj": {"type": "string", "enum": ["and", "or"]},
206
- "sorts": {"type": "array", "items": {
207
- "type": "object",
208
- "properties": {"colId": col,
209
- "dir": {"type": "string", "enum": ["asc", "desc"]}},
210
- "required": ["colId", "dir"]}},
211
- "groupBy": col,
212
- "stackField": col,
213
- "dateField": col,
214
- "colorField": col,
215
- "sizeField": col,
216
- },
217
- "required": ["kind"],
218
- }
219
-
220
-
221
- def _system_prompt(fields):
222
- """The standing instructions. The vocabulary is DERIVED (the kinds set, `FILTER_OPS`, the
223
- target's own columns) β€” the model is never told about a key this server cannot honour."""
224
- cols = json.dumps([{"key": f["key"], "label": f["label"], "type": f["type"]}
225
- for f in fields], separators=(",", ":"))
226
- kinds = ", ".join(QUERY_KINDS)
227
- ops = ", ".join(sorted(_grid().FILTER_OPS))
228
- selects = [f["key"] for f in fields if f["type"] in ('select', 'multiselect')] or ["(none)"]
229
- dates = [f["key"] for f in fields if f["type"] == 'date'] or ["(none)"]
230
- return f"""You turn a question about ONE database into a VIEW SPEC. You never write SQL and you
231
- never invent a column or a kind of view.
232
-
233
- THE DATABASE ({len(fields)} columns) β€” you may name ONLY these column keys:
234
- {cols}
235
-
236
- VIEW KINDS you may name, and nothing else: {kinds}
237
- FILTER OPS you may name, and nothing else: {ops}
238
- The only select-type columns (a kanban MUST stack on one of these): {", ".join(selects)}
239
- The only date columns (a calendar or timeseries MUST use one of these): {", ".join(dates)}
240
-
241
- RULES:
242
- 1. Emit ONE call to build_view. Every colId in visible/filters/sorts/groupBy and every field ref
243
- MUST be one of the keys above.
244
- 2. Pick the kind by what the question asks for: a list of records -> grid (or list); totals or
245
- counts by a category -> chart with groupBy; cards by a stage -> kanban (stackField REQUIRED,
246
- and it must be a select column); something over time -> calendar or timeseries (dateField
247
- REQUIRED, and it must be a date column); geography -> map.
248
- 3. `visible` is the columns the question is about, most important first β€” not every column.
249
- 4. Set kind="refused" and put ONE plain sentence in `refusal` when the question needs a column
250
- this database does not have, a number it does not store, or a picture that is not in the kind
251
- list. REFUSING IS A CORRECT ANSWER. A view that answers a DIFFERENT question than the one
252
- asked is the worst possible answer β€” worse than saying you cannot build it. When in doubt,
253
- refuse.
254
- 5. Every `value` is a string."""
255
-
256
-
257
- def _call_model(question, fields, chat=None):
258
- """`(spec, error_sentence, provider)` β€” one bounded call down THIS module's ladder.
259
-
260
- `chat` is injectable so `verify_query.py` proves this door end to end with no key and no
261
- tokens (`analyst.ask`'s own posture, and the reason its loop is testable offline).
262
-
263
- ⚠ THE LADDER IS A DELIBERATE COMPROMISE, AND THE PROVIDER RIDES BACK BECAUSE OF IT. Only the
264
- FIRST provider is measured to refuse honestly (README section 4b); the ones below it answer
265
- unanswerable questions with plausible specs. Falling through on a 429 is still the right call β€”
266
- a Query module that dies whenever one free tier rate-limits is worse than one that answers with
267
- a weaker model behind the same validator and the same human read-back β€” but *which* model
268
- answered must be knowable, or a wrong answer is undiagnosable after the fact. Failing closed is
269
- what happens when the whole ladder is unreachable, not when the best rung is.
270
- """
271
- tools = [{"type": "function", "function": {
272
- "name": "build_view",
273
- "description": "Emit the view spec, or refuse.",
274
- "parameters": _spec_schema([f["key"] for f in fields])}}]
275
- messages = [{"role": "system", "content": _system_prompt(fields)},
276
- {"role": "user", "content": question}]
277
- if chat is not None:
278
- return chat(messages, tools), None, "injected"
279
-
280
- provs = _providers()
281
- if not provs:
282
- # No key at all: say so. An AI feature that silently does nothing is indistinguishable
283
- # from one that was never built ([[flag-shipped-without-its-writer]]).
284
- return None, ("the assistant is not configured on this deployment, so a view cannot be "
285
- "built from a question yet"), None
286
- import requests
287
- last = None
288
- for p in provs:
289
- try:
290
- r = requests.post(p["url"], timeout=60,
291
- headers={"Authorization": f"Bearer {os.environ[p['env']]}"},
292
- json={"model": p["model"], "messages": messages, "tools": tools,
293
- "tool_choice": "required", "temperature": 0.1,
294
- "max_tokens": 1200})
295
- except Exception as e: # network blip β€” try the next provider
296
- last = f"{p['name']}: {type(e).__name__}"
297
- continue
298
- if r.status_code == 400:
299
- # β›” THE MODEL'S ANSWER CAN BE IN HERE. Groq validates tool arguments against the
300
- # schema and returns the generation it rejected. Reading it turns a "transport
301
- # failure" back into the refusal it actually was.
302
- spec = _spec_from_400(r.text)
303
- if spec is not None:
304
- return spec, None, p["name"]
305
- last = f"{p['name']}: 400"
306
- continue
307
- if r.status_code != 200:
308
- last = f"{p['name']}: HTTP {r.status_code}"
309
- continue # 429 included: the NEXT provider, not a sleep
310
- try:
311
- msg = r.json()["choices"][0]["message"]
312
- calls = msg.get("tool_calls") or []
313
- if not calls:
314
- last = f"{p['name']}: no tool call"
315
- continue
316
- return json.loads(calls[0]["function"].get("arguments") or "{}"), None, p["name"]
317
- except Exception as e:
318
- last = f"{p['name']}: unreadable answer ({type(e).__name__})"
319
- continue
320
- # β›” FAIL CLOSED. The first provider in the order is the one measured to refuse honestly; if it
321
- # is rate-limited and the rest are too, the answer is a sentence, never a fabricated view.
322
- return None, ("the assistant could not be reached just now (" + (last or "no provider") +
323
- ") β€” try the question again in a moment"), None
324
-
325
-
326
- _FAILED_GEN = re.compile(r'<function=build_view>(\{.*?\})\s*</function>', re.S)
327
-
328
-
329
- def _spec_from_400(body):
330
- """The spec out of a `tool_use_failed` body's `failed_generation`, or None."""
331
- try:
332
- err_obj = (json.loads(body) or {}).get("error") or {}
333
- except Exception:
334
- return None
335
- gen = str(err_obj.get("failed_generation") or "")
336
- if not gen:
337
- return None
338
- m = _FAILED_GEN.search(gen)
339
- raw = m.group(1) if m else gen.strip()
340
- try:
341
- spec = json.loads(raw)
342
- except Exception:
343
- return None
344
- return spec if isinstance(spec, dict) else None
345
-
346
-
347
- # ── validation: the REFUSAL this contract promises ───────────────────────────────────────────
348
-
349
- def _validate(spec, fields):
350
- """`(clean, refusal, code)` β€” exactly one of `clean` / `refusal` is set.
351
-
352
- Membership FIRST and explicitly (the header's whole subject), then the product's own cleaners
353
- as a belt. A refusal is a SENTENCE, because C5's user-facing promise is a plain sentence and
354
- never a broken view.
355
- """
356
- g = _grid()
357
- if not isinstance(spec, dict):
358
- return None, "the assistant did not answer with a view", "no_spec"
359
- by_key = {f['key']: f['type'] for f in fields}
360
- keys = set(by_key)
361
- kind = spec.get('kind')
362
-
363
- if kind == 'refused':
364
- return None, (str(spec.get('refusal') or '').strip()
365
- or "this database cannot answer that question"), "model_refused"
366
- if kind in QUERY_EXCLUDED:
367
- return None, (f"a {kind} view cannot be built from a question β€” "
368
- + QUERY_EXCLUDED[kind]), "unsupported_kind"
369
- if kind not in QUERY_KINDS:
370
- # β›” NOT `_clean_display`'s job: it would DROP this and hand back a grid under a name
371
- # promising something else.
372
- return None, (f"{kind!r} is not a kind of view this product can build"
373
- if kind else "the assistant did not name a kind of view"), "unsupported_kind"
374
-
375
- named = set(spec.get('visible') or ())
376
- for s in spec.get('sorts') or ():
377
- if isinstance(s, dict) and s.get('colId'):
378
- named.add(str(s['colId']))
379
- for f in spec.get('filters') or ():
380
- if isinstance(f, dict) and f.get('colId'):
381
- named.add(str(f['colId']))
382
- if spec.get('groupBy'):
383
- named.add(str(spec['groupBy']))
384
- for ref in ('stackField', 'dateField', 'colorField', 'sizeField'):
385
- if spec.get(ref):
386
- named.add(str(spec[ref]))
387
- missing = sorted(named - keys)
388
- if missing:
389
- # NAMED, not counted (`routes_templates`' rule): the column keys are what tell the reader
390
- # this is the wrong database for this question.
391
- return None, ("this database does not have the columns that answer needs: "
392
- + ", ".join(missing)), "unknown_columns"
393
-
394
- visible = [k for k in (spec.get('visible') or ()) if k in keys][:MAX_VISIBLE]
395
- if not visible:
396
- # An empty projection is not a view. `_default_view_config` would fill one in, which is
397
- # how a question about two columns becomes a 285-column wall.
398
- return None, ("that question did not name anything to show β€” try naming the columns you "
399
- "want to see"), "no_columns"
400
-
401
- # β›” D-137, GUARDED WHERE IT REACHES THIS FEATURE. "The chart cannot key a SET-LIKE or CHECKBOX
402
- # column the way the grid does" β€” a set row lands in several buckets, so a sum means something
403
- # else, and a checkbox reads "(blank)". The defect lives in the CLIENT's `chartData` bucket
404
- # keying, which is in no lane's fence this wave (booked, with that reason). What IS in this
405
- # lane's power is refusing to GENERATE the broken case: the AI must only build what the backend
406
- # and the renderer already support, which is R1's own sentence. Delete this guard when D-137
407
- # closes β€” the refusal names the debt so the reader knows it is a limit, not a rule.
408
- if kind == 'chart' and by_key.get(spec.get('groupBy')) in ('multiselect', 'checkbox'):
409
- return None, (f"a chart cannot group by {spec.get('groupBy')!r} yet β€” a column that holds "
410
- f"several values at once, or a tick box, does not bucket the way a chart "
411
- f"needs. Try a table grouped by it instead"), "chart_key_unsupported"
412
-
413
- for ref, families, required in _MODE_REFS.get(kind, ()):
414
- val = spec.get(ref)
415
- if val and by_key.get(val) not in families:
416
- return None, (f"a {kind} view needs {ref[:-5]} to be a "
417
- f"{' or '.join(families)} column, and {val!r} is a "
418
- f"{by_key.get(val)} column"), "wrong_ref_type"
419
- if required and not val:
420
- return None, (f"a {kind} view needs a {' or '.join(families)} column and this "
421
- f"database has none that fits"), "missing_ref"
422
-
423
- raw_filters = [f for f in (spec.get('filters') or ()) if isinstance(f, dict)]
424
- filters = g.clean_filter_tree(raw_filters, keys)
425
- if len(filters) != len(raw_filters):
426
- # The belt caught something membership did not β€” an unknown op. Refuse rather than save a
427
- # view whose condition was silently dropped (the widening this file exists to prevent).
428
- return None, ("part of that filter is not something this product can express β€” try "
429
- "asking it more simply"), "filter_dropped"
430
-
431
- display = {'mode': kind}
432
- for ref, _families, _req in _MODE_REFS.get(kind, ()):
433
- if spec.get(ref):
434
- display[ref] = spec[ref]
435
- cleaned_display = g._clean_display(display, keys) if kind != 'grid' else None
436
- if kind != 'grid' and not cleaned_display:
437
- return None, (f"this product could not build a {kind} view from that question"
438
- ), "display_dropped"
439
-
440
- clean = {
441
- 'kind': kind,
442
- 'name': ' '.join(str(spec.get('name') or 'Query').split())[:60] or 'Query',
443
- 'visible': visible,
444
- 'filters': filters,
445
- 'filterConj': 'or' if spec.get('filterConj') == 'or' else 'and',
446
- 'sorts': [{'colId': s['colId'], 'dir': 'desc' if s.get('dir') == 'desc' else 'asc'}
447
- for s in (spec.get('sorts') or ()) if isinstance(s, dict)
448
- and s.get('colId') in keys][:3],
449
- 'groupBy': spec['groupBy'] if spec.get('groupBy') in keys else None,
450
- 'display': cleaned_display,
451
- }
452
- return clean, None, None
453
-
454
-
455
- def _explain(clean, fields):
456
- """A plain-language read-back of what the view WILL do β€” DERIVED from the validated spec.
457
-
458
- β›” NEVER ASKED OF THE MODEL. A model describing its own output describes what it MEANT, which
459
- is precisely the failure this sentence exists to expose: a spec whose every column is real and
460
- which answers a different question is invisible to validation, so the only thing that catches
461
- it is a person reading an honest description of the spec that was actually accepted.
462
- """
463
- label = {f['key']: f['label'] for f in fields}
464
- ops = {'eq': 'is', 'neq': 'is not', 'gt': 'is over', 'gte': 'is at least',
465
- 'lt': 'is under', 'lte': 'is at most', 'contains': 'contains',
466
- 'doesNotContain': 'does not contain', 'isEmpty': 'is empty',
467
- 'isNotEmpty': 'is not empty', 'between': 'is between', 'within': 'is within',
468
- 'topN': 'is in the top', 'bottomN': 'is in the bottom'}
469
- kinds = {'grid': 'a table', 'list': 'a list', 'chart': 'a chart', 'kanban': 'a board',
470
- 'calendar': 'a calendar', 'timeseries': 'a time series', 'map': 'a map'}
471
- parts = [f"{kinds.get(clean['kind'], clean['kind'])} of "
472
- + ", ".join(label.get(k, k) for k in clean['visible'][:6])
473
- + (f" and {len(clean['visible']) - 6} more columns"
474
- if len(clean['visible']) > 6 else "")]
475
- if clean['filters']:
476
- joiner = " or " if clean['filterConj'] == 'or' else " and "
477
- parts.append("showing only records where " + joiner.join(
478
- f"{label.get(f['colId'], f['colId'])} {ops.get(f['op'], f['op'])}"
479
- + (f" {f['value']}" if f.get('value') else "")
480
- for f in clean['filters'] if isinstance(f, dict) and f.get('colId')))
481
- else:
482
- parts.append("showing every record")
483
- if clean['groupBy']:
484
- parts.append(f"grouped by {label.get(clean['groupBy'], clean['groupBy'])}")
485
- if clean['sorts']:
486
- s = clean['sorts'][0]
487
- parts.append(f"sorted by {label.get(s['colId'], s['colId'])}"
488
- + (" (highest first)" if s['dir'] == 'desc' else " (lowest first)"))
489
- for ref, word in (('stackField', 'in columns by'), ('dateField', 'placed in time by'),
490
- ('colorField', 'coloured by'), ('sizeField', 'sized by')):
491
- val = (clean.get('display') or {}).get(ref)
492
- if val:
493
- parts.append(f"{word} {label.get(val, val)}")
494
- return ", ".join(parts) + "."
495
-
496
-
497
- # ── the registry ────────────────────────────────────────────────────────────────────────────
498
-
499
- def _qid(scope, question):
500
- """PINNED per (database, question), the `view_templates` discipline: asking the same thing
501
- twice UPDATES one view instead of minting "Query 2" β€” `save_view` de-duplicates NAMES by
502
- appending a number, so an unpinned id fills the store with numbered near-duplicates."""
503
- h = hashlib.sha1(f"{scope}|{' '.join(str(question).split()).lower()}".encode("utf-8"))
504
- return "q_" + h.hexdigest()[:12]
505
-
506
-
507
- def _registry(session):
508
- try:
509
- recs = session.runtime.get(QUERY_KEY) or {}
510
- except Exception:
511
- return {}
512
- return recs if isinstance(recs, dict) else {}
513
-
514
-
515
- def _mine(session, rec):
516
- return session.admin or str(rec.get('createdBy') or '') == session.uname
517
-
518
-
519
- #: The BUILT-IN topics `routes_templates._target_or_refuse` accepts beside any `ut_*` key. β›” THE
520
- #: CLIENT'S PICKER MUST MIRROR THIS, and `GET /query` publishes it (`builtins`) rather than leaving
521
- #: the page to re-derive it: the nav hands `QueryPage` every granted entry, which is a WIDER set
522
- #: than this door accepts, so a picker filtered on "not a surface" alone would offer a database the
523
- #: build door 404s. `view_templates`' own rule β€” "a picker that offered a template the apply door
524
- #: would then refuse is a control that lies" β€” and it is the same predicate twice, published once.
525
- BUILTIN_SCOPES = ('customer_data', 'product_data')
526
-
527
-
528
- @router.get("/query")
529
- def list_queries(session: Session = Depends(require_session)):
530
- """The AI-built views this caller may open: their own, still-granted, still-existing.
531
-
532
- ⚠ PRUNED ON READ against the CURRENT wall, never merely on write β€” a grant can be withdrawn
533
- and a database deleted after a view was saved, and the Query module is chrome: it may render
534
- nothing the server would not grant today (`nav.ts`'s chrome law).
535
- """
536
- import routes_templates
537
- out = []
538
- for qid, rec in sorted(_registry(session).items(),
539
- key=lambda kv: str((kv[1] or {}).get('createdAt') or ''),
540
- reverse=True):
541
- if not isinstance(rec, dict) or not _mine(session, rec):
542
- continue
543
- try:
544
- routes_templates._target_or_refuse(session, rec.get('scope'))
545
- except Exception:
546
- continue # no longer granted, or gone
547
- out.append({"id": qid, "scope": rec.get('scope'), "viewId": rec.get('viewId'),
548
- "name": rec.get('name'), "kind": rec.get('kind'),
549
- "question": rec.get('question'), "explain": rec.get('explain'),
550
- "createdAt": rec.get('createdAt')})
551
- return {"views": out, "kinds": list(QUERY_KINDS), "builtins": list(BUILTIN_SCOPES)}
552
-
553
-
554
- @router.post("/query/build")
555
- def build_query(body: dict = Body(default=None),
556
- session: Session = Depends(require_session)):
557
- """A question + a granted database -> `{spec, explain, refused}`. **WRITES NOTHING.**
558
-
559
- A refusal is a 200 carrying a sentence, not a 5xx: "this database cannot answer that" is a
560
- successful answer to the person asking, and an error envelope would paint the error page R1
561
- forbids. The 4xx cases are the ones that ARE the caller's fault β€” an unnamed database, an
562
- over-long question, a database this session may not open.
563
-
564
- β›” THE MODEL SEAM IS **NOT** ON THIS SIGNATURE, AND THAT IS A CORRECTION. The first draft took
565
- `chat=None` as a parameter of the ROUTE β€” and FastAPI reads an un-annotated defaulted argument
566
- as a **QUERY PARAMETER**, so `chat` appeared in `openapi()["paths"]` (measured) and
567
- `POST /query/build?chat=x` would have reached `_call_model(..., chat="x")` and 500'd on
568
- `"x"(messages, tools)`. The gate never saw it because the gate calls the handler directly β€”
569
- which is exactly the shape of a test seam that becomes an input. The seam now lives on the
570
- module-level `_build` below: the gate calls that, and the wire cannot.
571
- """
572
- body = body if isinstance(body, dict) else {}
573
- return _build(body.get("question"), body.get("scope"), session)
574
-
575
-
576
- def _build(question, scope, session, chat=None):
577
- """`build_query`'s body, with the model injectable β€” see that route's note on why the seam is
578
- here and not on the signature FastAPI reads."""
579
- question = ' '.join(str(question or "").split())
580
- if not question:
581
- raise err(400, "bad_request", "no question was asked")
582
- if len(question) > MAX_QUESTION:
583
- raise err(400, "question_too_long",
584
- f"a question must be under {MAX_QUESTION} characters")
585
- fields, _source = _target(session, scope)
586
- if not fields:
587
- raise err(400, "no_columns", "that database has no columns to build a view from")
588
-
589
- spec, transport, provider = _call_model(question, fields, chat=chat)
590
- if transport:
591
- return {"spec": None, "explain": None, "refused": transport, "provider": provider}
592
- clean, refusal, code = _validate(spec, fields)
593
- if refusal:
594
- return {"spec": None, "explain": None, "refused": refusal, "reason": code,
595
- "provider": provider}
596
- return {"spec": clean, "explain": _explain(clean, fields),
597
- "refused": None, "scope": scope,
598
- "id": _qid(scope, question), "provider": provider,
599
- "fallback": _is_fallback(provider)}
600
-
601
-
602
- def _is_fallback(provider):
603
- """True when the answer came from a rung BELOW the top of this module's ladder.
604
-
605
- β›” THIS IS THE HALF THAT WAS MISSING, AND IT IS A READER, NOT A WRITER. `_call_model` has
606
- always returned WHICH provider answered, and its own docstring says exactly why that matters:
607
- *"Only the FIRST provider is measured to refuse honestly … the ones below it answer
608
- unanswerable questions with plausible specs."* So on a 429 the ladder falls through β€” correctly,
609
- a Query module that dies when one free tier rate-limits is worse β€” and the person then reads a
610
- confident, well-formed view spec built by a model that does not know how to say "this database
611
- cannot answer that". **Nothing on the wire told them.** The value existed and had no consumer.
612
- ⚠ `injected` (the gate's stubbed chat) is NOT a fallback: it is the test seam, and reporting it
613
- as a degraded answer would make every gate assertion about this flag meaningless.
614
- """
615
- if not provider or provider == "injected":
616
- return False
617
- order = [p["name"] for p in _providers()]
618
- return bool(order) and provider != order[0]
619
-
620
-
621
- @router.post("/query/save")
622
- def save_query(body: dict = Body(default=None), session: Session = Depends(require_session)):
623
- """Save an accepted spec as the CALLING USER's own view, and index it under Query.
624
-
625
- β›” THE SPEC IS RE-VALIDATED HERE, against the target's contract as it is NOW. The build door
626
- wrote nothing, so this is the first write and it is the only place authorisation and validity
627
- have to hold together β€” and a client is not a wall. An unsupported kind or an unknown column
628
- is a **400 with a named code** on this door (the caller sent it), where the same fact is a
629
- plain sentence on `build` (the model produced it).
630
- """
631
- body = body if isinstance(body, dict) else {}
632
- scope = str(body.get("scope") or '').strip()
633
- question = ' '.join(str(body.get("question") or "").split())[:MAX_QUESTION]
634
- fields, _source = _target(session, scope)
635
- clean, refusal, code = _validate(body.get("spec"), fields)
636
- if refusal:
637
- raise err(400, code or "bad_spec", refusal)
638
- if not session.runtime.available():
639
- raise err(503, "store_unavailable", "the tenant store is unavailable β€” nothing was saved")
640
-
641
- vt = _templates()
642
- ws_key = vt.workspace_key(scope)
643
- if not ws_key:
644
- raise err(404, "unknown_table", "that database does not exist")
645
- qid = _qid(scope, question) if question else _qid(scope, clean['name'])
646
-
647
- # β›” THE SKELETON IS `view_templates._view`, NOT A SECOND ONE. "A view assembled from a
648
- # DIFFERENT skeleton is a second definition of what a view is β€” and the two drift on the day
649
- # one of them gains a key." It also puts the mode at `config.display.mode`, which is the key
650
- # the grid actually reads (`config.displayMode` was the first draft, and nothing reads it).
651
- payload = vt._view(qid, clean['name'], f"Built by the assistant from: {question}"[:300],
652
- clean['visible'], mode=clean['kind'],
653
- sorts=clean['sorts'], filters=clean['filters'],
654
- group_by=clean['groupBy'], conj=clean['filterConj'])
655
- if clean.get('display'):
656
- payload['config']['display'] = dict(clean['display'])
657
- payload['createdBy'] = session.uname
658
-
659
- # β›” THE CAP REFUSES; IT DOES NOT EVICT β€” and the first draft evicted. Dropping the oldest entry
660
- # would leave its saved VIEW behind in the table's own workspace: "stranded in the target's view
661
- # list with no way back to the question that made it", which is the thing `delete_query` below
662
- # exists to prevent. And an eviction nobody was told about is W30/R6's second sentence broken β€”
663
- # a limit that cannot be removed must be REPORTED with its cause. So the 201st is a refusal
664
- # naming the number and the way out, checked BEFORE the view is written.
665
- existing = _registry(session)
666
- if qid not in existing and len(existing) >= MAX_REGISTRY:
667
- raise err(400, "query_limit",
668
- f"you already have {len(existing)} saved questions, which is the limit "
669
- f"({MAX_REGISTRY}). Delete one you no longer need and save this again.")
670
-
671
- import core.table_store as table_store
672
- ops = table_store.make(ws_key, st=session.runtime)
673
- saved = ops.save_view(session.uname, payload, shared=False)
674
-
675
- entry = {"scope": scope, "viewId": qid, "name": saved.get('name') or clean['name'],
676
- "kind": clean['kind'], "question": question,
677
- "explain": _explain(clean, fields), "createdBy": session.uname,
678
- "createdAt": _now_iso()}
679
-
680
- def _up(data):
681
- data = data if isinstance(data, dict) else {}
682
- data[qid] = entry
683
- return data
684
-
685
- session.runtime.update(QUERY_KEY, _up, flush='async')
686
- return {"id": qid, **entry}
687
-
688
-
689
- @router.delete("/query/{qid}")
690
- def delete_query(qid: str, session: Session = Depends(require_session)):
691
- """Forget an AI-built view β€” the index entry AND the view itself.
692
-
693
- Leaving the view behind would strand it in the target's view list with no way back to the
694
- question that made it ([[a-record-can-outlive-its-subject]]).
695
- """
696
- rec = _registry(session).get(str(qid))
697
- if not isinstance(rec, dict) or not _mine(session, rec):
698
- raise err(404, "unknown_query", "that view does not exist")
699
- _target(session, rec.get('scope')) # the wall, again, on the write path
700
- vt = _templates()
701
- ws_key = vt.workspace_key(rec.get('scope'))
702
- if ws_key and session.runtime.available():
703
- import core.table_store as table_store
704
- table_store.make(ws_key, st=session.runtime).delete_view(
705
- session.uname, rec.get('viewId') or str(qid))
706
-
707
- def _up(data):
708
- data = data if isinstance(data, dict) else {}
709
- data.pop(str(qid), None)
710
- return data
711
-
712
- session.runtime.update(QUERY_KEY, _up, flush='async')
713
- return {"deleted": str(qid)}
 
1
+ """Assistant chat and Query-owned virtual artefacts (W33-T72 / C8).
2
+
3
+ Query reads only the detached, permission-filtered snapshot from
4
+ ``deps.assistant_read_scope``. It owns a separate per-user namespace for threads, messages,
5
+ citations and virtual views; it never opens or mutates a source workspace.
6
+ """
7
+ import copy
8
+ import datetime as _dt
9
+ import hashlib
10
+ import json
11
+ import os
12
+ import re
13
+ import uuid
14
+
15
+ from fastapi import APIRouter, Body, Depends
16
+
17
+ from deps import (Session, assistant_read_scope, assistant_source_status, err,
18
+ require_session)
19
+
20
+ router = APIRouter(prefix="/api/v1")
21
+
22
+ MAX_QUESTION = 500
23
+ MAX_VISIBLE = 24
24
+ MAX_ARTIFACTS = 200
25
+ MODEL_AUTO = "auto"
26
+ QUERY_PROVIDER_ORDER = ("cerebras", "groq", "openrouter")
27
+ QUERY_KINDS = ("grid", "chart", "calendar", "kanban", "timeseries", "map", "list")
28
+ QUERY_WORKSPACE_EVENTS = {"view_create", "view_upsert", "view_delete"}
29
+ QUERY_EXCLUDED = {
30
+ "form": "Forms collect new data and are not a read-only Query artefact.",
31
+ "catalog": "Catalog is a source-native presentation that Query cannot safely mutate.",
32
+ "swipe": "Swipe is an interactive source-native presentation, not an Assistant output.",
33
+ }
34
+ _MODE_REFS = {
35
+ "kanban": [("stackField", ("select",), True)],
36
+ "calendar": [("dateField", ("date",), True)],
37
+ "timeseries": [("dateField", ("date",), True)],
38
+ "map": [("colorField", ("select",), False), ("sizeField", ("int", "currency", "pct"), False)],
39
+ }
40
+
41
+
42
+ def _now_iso():
43
+ return _dt.datetime.now(_dt.timezone.utc).isoformat()
44
+
45
+
46
+ def _grid():
47
+ import aios_grid
48
+ return aios_grid
49
+
50
+
51
+ def _safe(value):
52
+ """Detach values before they enter a durable Query object or an API reply."""
53
+ return json.loads(json.dumps(value, default=str))
54
+
55
+
56
+ def _namespace_key(session):
57
+ """A tenant runtime has one isolated key for each user's Query-owned objects."""
58
+ principal = f"{session.tenant}:{session.uname}".encode("utf-8")
59
+ return "query_user_" + hashlib.sha256(principal).hexdigest()[:24]
60
+
61
+
62
+ def _blank_state():
63
+ return {"version": 1, "threads": {}, "messages": {}, "citations": {}, "views": {}}
64
+
65
+
66
+ def _state(session):
67
+ try:
68
+ raw = session.runtime.get(_namespace_key(session)) or {}
69
+ except Exception:
70
+ raw = {}
71
+ if not isinstance(raw, dict):
72
+ return _blank_state()
73
+ out = _blank_state()
74
+ for key in out:
75
+ if key == "version":
76
+ continue
77
+ if isinstance(raw.get(key), dict):
78
+ out[key] = copy.deepcopy(raw[key])
79
+ return out
80
+
81
+
82
+ def _new_id(prefix):
83
+ return f"{prefix}_{uuid.uuid4().hex[:16]}"
84
+
85
+
86
+ def model_choices():
87
+ """Choices are stable even when one is not configured, so explicit means explicit."""
88
+ return [MODEL_AUTO, *QUERY_PROVIDER_ORDER]
89
+
90
+
91
+ def _providers(model=MODEL_AUTO):
92
+ """Auto returns the permitted ladder; an explicit choice returns at most one provider."""
93
+ import harness.analyst as analyst
94
+
95
+ requested = str(model or MODEL_AUTO).strip().lower()
96
+ by_name = {p["name"]: p for p in analyst.PROVIDERS}
97
+ names = list(QUERY_PROVIDER_ORDER) if requested == MODEL_AUTO else [requested]
98
+ return [by_name[name] for name in names
99
+ if name in by_name and os.environ.get(by_name[name]["env"])]
100
+
101
+
102
+ def _spec_schema(field_keys):
103
+ col = {"type": "string", "enum": sorted(field_keys)}
104
+ return {
105
+ "type": "object",
106
+ "properties": {
107
+ "kind": {"type": "string", "enum": [*QUERY_KINDS, "refused"]},
108
+ "name": {"type": "string"},
109
+ "refusal": {"type": "string"},
110
+ "visible": {"type": "array", "items": col},
111
+ "filters": {"type": "array", "items": {"type": "object", "properties": {
112
+ "colId": col, "op": {"type": "string", "enum": sorted(_grid().FILTER_OPS)},
113
+ "value": {"type": "string"},
114
+ }, "required": ["colId", "op"]}},
115
+ "filterConj": {"type": "string", "enum": ["and", "or"]},
116
+ "sorts": {"type": "array", "items": {"type": "object", "properties": {
117
+ "colId": col, "dir": {"type": "string", "enum": ["asc", "desc"]},
118
+ }, "required": ["colId", "dir"]}},
119
+ "groupBy": col,
120
+ "aggregation": {"type": "object", "properties": {
121
+ "op": {"type": "string", "enum": ["count", "sum", "avg", "min", "max"]},
122
+ "field": col,
123
+ }, "required": ["op"]},
124
+ "stackField": col,
125
+ "dateField": col,
126
+ "colorField": col,
127
+ "sizeField": col,
128
+ },
129
+ "required": ["kind"],
130
+ }
131
+
132
+
133
+ def _system_prompt(snapshot):
134
+ fields = snapshot["fields"]
135
+ cols = json.dumps([{"key": field["key"], "label": field.get("label", field["key"]),
136
+ "type": field.get("type", "text")} for field in fields], separators=(",", ":"))
137
+ return f"""You turn a question about exactly one permitted database into a virtual view.
138
+ Never write SQL, invent fields, or name another database.
139
+
140
+ DATABASE: {snapshot['database']}
141
+ SNAPSHOT VERSION: {json.dumps(snapshot['source_version'], default=str)}
142
+ PERMISSION-FILTERED RECORD COUNT: {len(snapshot['records'])}
143
+ FIELDS: {cols}
144
+ VIEW KINDS: {", ".join(QUERY_KINDS)}
145
+
146
+ Choose visible fields, supported filters and an optional aggregation. Use count for record counts;
147
+ sum, avg, min and max require one numeric field. The server computes and cites every number from
148
+ this exact snapshot. Return kind=refused with one plain sentence if the database cannot answer."""
149
+
150
+
151
+ _FAILED_GEN = re.compile(r"<function=build_view>(\{.*?\})\s*</function>", re.S)
152
+
153
+
154
+ def _spec_from_400(body):
155
+ try:
156
+ value = ((json.loads(body) or {}).get("error") or {}).get("failed_generation") or ""
157
+ raw = (_FAILED_GEN.search(str(value)) or [str(value).strip()])[1]
158
+ result = json.loads(raw)
159
+ return result if isinstance(result, dict) else None
160
+ except Exception:
161
+ return None
162
+
163
+
164
+ def _call_model(question, snapshot, model=MODEL_AUTO, chat=None):
165
+ """Return ``(spec, sentence, provider, reason)`` without any source-data fallback."""
166
+ requested = str(model or MODEL_AUTO).strip().lower()
167
+ if requested not in model_choices():
168
+ return None, f"the selected model ({requested or model}) is unavailable", None, "model_unavailable"
169
+ tools = [{"type": "function", "function": {
170
+ "name": "build_view", "description": "Emit a virtual-view spec or a refusal.",
171
+ "parameters": _spec_schema([field["key"] for field in snapshot["fields"]]),
172
+ }}]
173
+ messages = [{"role": "system", "content": _system_prompt(snapshot)},
174
+ {"role": "user", "content": question}]
175
+ if chat is not None:
176
+ provider = requested if requested != MODEL_AUTO else "injected"
177
+ return chat(messages, tools), None, provider, None
178
+
179
+ providers = _providers(requested)
180
+ if not providers:
181
+ sentence = (f"the selected model ({requested}) is unavailable" if requested != MODEL_AUTO
182
+ else "the assistant is not configured on this deployment")
183
+ return None, sentence, None, "model_unavailable" if requested != MODEL_AUTO else None
184
+
185
+ import requests
186
+ last = None
187
+ for provider in providers:
188
+ try:
189
+ response = requests.post(
190
+ provider["url"], timeout=60,
191
+ headers={"Authorization": f"Bearer {os.environ[provider['env']]}"},
192
+ json={"model": provider["model"], "messages": messages, "tools": tools,
193
+ "tool_choice": "required", "temperature": 0.1, "max_tokens": 1200},
194
+ )
195
+ except Exception as exc:
196
+ last = f"{provider['name']}: {type(exc).__name__}"
197
+ continue
198
+ if response.status_code == 400:
199
+ spec = _spec_from_400(response.text)
200
+ if spec is not None:
201
+ return spec, None, provider["name"], None
202
+ last = f"{provider['name']}: 400"
203
+ continue
204
+ if response.status_code != 200:
205
+ last = f"{provider['name']}: HTTP {response.status_code}"
206
+ continue
207
+ try:
208
+ call = (response.json()["choices"][0]["message"].get("tool_calls") or [])[0]
209
+ spec = json.loads(call["function"].get("arguments") or "{}")
210
+ return spec, None, provider["name"], None
211
+ except Exception as exc:
212
+ last = f"{provider['name']}: unreadable answer ({type(exc).__name__})"
213
+ if requested != MODEL_AUTO:
214
+ return None, f"the selected model ({requested}) is unavailable", None, "model_unavailable"
215
+ return None, "the assistant could not be reached just now (" + (last or "no provider") + ")", None, None
216
+
217
+
218
+ def _validate(spec, fields):
219
+ """Return a cleaned, source-independent virtual-view config or a named refusal."""
220
+ if not isinstance(spec, dict):
221
+ return None, "the assistant did not answer with a view", "no_spec"
222
+ by_key = {str(field.get("key")): str(field.get("type") or "text") for field in fields}
223
+ keys = set(by_key)
224
+ kind = spec.get("kind")
225
+ if kind == "refused":
226
+ return None, str(spec.get("refusal") or "this database cannot answer that question"), "model_refused"
227
+ if kind in QUERY_EXCLUDED or kind not in QUERY_KINDS:
228
+ return None, "that kind of view cannot be built from this question", "unsupported_kind"
229
+
230
+ named = set(spec.get("visible") or ())
231
+ for item in spec.get("filters") or ():
232
+ if isinstance(item, dict) and item.get("colId"):
233
+ named.add(str(item["colId"]))
234
+ for item in spec.get("sorts") or ():
235
+ if isinstance(item, dict) and item.get("colId"):
236
+ named.add(str(item["colId"]))
237
+ for key in ("groupBy", "stackField", "dateField", "colorField", "sizeField"):
238
+ if spec.get(key):
239
+ named.add(str(spec[key]))
240
+ raw_aggregation = spec.get("aggregation") or {"op": "count"}
241
+ if isinstance(raw_aggregation, dict) and raw_aggregation.get("field"):
242
+ named.add(str(raw_aggregation["field"]))
243
+ missing = sorted(named - keys)
244
+ if missing:
245
+ return None, "this database does not have: " + ", ".join(missing), "unknown_columns"
246
+
247
+ visible = [str(key) for key in (spec.get("visible") or ()) if str(key) in keys][:MAX_VISIBLE]
248
+ if not visible:
249
+ return None, "that question did not name any fields to show", "no_columns"
250
+ raw_filters = [item for item in (spec.get("filters") or ()) if isinstance(item, dict)]
251
+ filters = _grid().clean_filter_tree(raw_filters, keys)
252
+ if len(filters) != len(raw_filters):
253
+ return None, "part of that filter is unsupported", "filter_dropped"
254
+
255
+ aggregation = raw_aggregation if isinstance(raw_aggregation, dict) else {}
256
+ op = str(aggregation.get("op") or "").lower()
257
+ field = aggregation.get("field")
258
+ if op not in {"count", "sum", "avg", "min", "max"}:
259
+ return None, "the assistant gave an unsupported aggregation", "bad_aggregation"
260
+ if op == "count":
261
+ field = None
262
+ elif field not in keys or by_key.get(field) not in {"int", "currency", "pct"}:
263
+ return None, "that aggregation needs one visible numeric field", "bad_aggregation"
264
+
265
+ display = {"mode": kind}
266
+ for ref, families, required in _MODE_REFS.get(kind, ()):
267
+ value = spec.get(ref)
268
+ if required and not value:
269
+ return None, f"a {kind} view needs {ref}", "missing_ref"
270
+ if value and by_key.get(value) not in families:
271
+ return None, f"{ref} has the wrong field type", "wrong_ref_type"
272
+ if value:
273
+ display[ref] = value
274
+ cleaned_display = _grid()._clean_display(display, keys) if kind != "grid" else None
275
+ if kind != "grid" and not cleaned_display:
276
+ return None, f"this product could not build a {kind} view", "display_dropped"
277
+
278
+ return {
279
+ "kind": kind,
280
+ "name": " ".join(str(spec.get("name") or "Query").split())[:60] or "Query",
281
+ "visible": visible,
282
+ "filters": filters,
283
+ "filterConj": "or" if spec.get("filterConj") == "or" else "and",
284
+ "sorts": [{"colId": item["colId"], "dir": "desc" if item.get("dir") == "desc" else "asc"}
285
+ for item in (spec.get("sorts") or []) if isinstance(item, dict)
286
+ and item.get("colId") in keys][:3],
287
+ "groupBy": spec.get("groupBy") if spec.get("groupBy") in keys else None,
288
+ "aggregation": {"op": op, "field": field},
289
+ "display": cleaned_display,
290
+ }, None, None
291
+
292
+
293
+ def _explain(view, fields):
294
+ label = {field["key"]: str(field.get("label") or field["key"]) for field in fields}
295
+ visible = ", ".join(label.get(key, key) for key in view["visible"][:6])
296
+ result = f"{view['kind']} view of {visible}"
297
+ if view["filters"]:
298
+ result += "; filtered records only"
299
+ if view.get("groupBy"):
300
+ result += f"; grouped by {label.get(view['groupBy'], view['groupBy'])}"
301
+ agg = view["aggregation"]
302
+ if agg["op"] != "count":
303
+ result += f"; {agg['op']} of {label.get(agg['field'], agg['field'])}"
304
+ return result + "."
305
+
306
+
307
+ def _numeric_result(view, records):
308
+ aggregation = view["aggregation"]
309
+ if aggregation["op"] == "count":
310
+ return {"label": "Matching records", "value": len(records), "contributing_record_count": len(records)}
311
+ values = []
312
+ for record in records:
313
+ try:
314
+ value = record.get(aggregation["field"])
315
+ if value is not None and not isinstance(value, bool):
316
+ values.append(float(value))
317
+ except (TypeError, ValueError):
318
+ continue
319
+ if not values:
320
+ return {"label": aggregation["op"], "value": None, "contributing_record_count": 0}
321
+ op = aggregation["op"]
322
+ value = {"sum": sum(values), "avg": sum(values) / len(values), "min": min(values), "max": max(values)}[op]
323
+ return {"label": f"{op.title()} of {aggregation['field']}", "value": value,
324
+ "contributing_record_count": len(values)}
325
+
326
+
327
+ def _referenced_fields(view):
328
+ """The citation names every source field that affected the displayed result."""
329
+ out = list(view.get("visible") or ())
330
+ for node in view.get("filters") or ():
331
+ if isinstance(node, dict) and node.get("colId"):
332
+ out.append(str(node["colId"]))
333
+ for node in view.get("sorts") or ():
334
+ if isinstance(node, dict) and node.get("colId"):
335
+ out.append(str(node["colId"]))
336
+ for key in ("groupBy",):
337
+ if view.get(key):
338
+ out.append(str(view[key]))
339
+ aggregation = view.get("aggregation") or {}
340
+ if aggregation.get("field"):
341
+ out.append(str(aggregation["field"]))
342
+ return list(dict.fromkeys(out))
343
+
344
+
345
+ def _effective_filters(snapshot, view):
346
+ """Keep the source request and generated-view predicates distinct in provenance."""
347
+ return {
348
+ "source": _safe(snapshot.get("filters")),
349
+ "view": {"conj": view.get("filterConj", "and"),
350
+ "nodes": _safe(view.get("filters") or [])},
351
+ }
352
+
353
+
354
+ def _view_records(snapshot, view):
355
+ """Apply the exact validated virtual-view filter before calculating a cited number."""
356
+ nodes = view.get("filters") or []
357
+ if not nodes:
358
+ return list(snapshot["records"])
359
+ from harness import filter_eval
360
+ tree = {"conj": view.get("filterConj", "and"), "nodes": nodes}
361
+ return [row for row in snapshot["records"]
362
+ if filter_eval.matches(tree, row, snapshot["fields"])]
363
+
364
+
365
+ def _citation(citation_id, snapshot, view, numeric, view_id):
366
+ return {
367
+ "id": citation_id,
368
+ "href": f"#/query?view={view_id}&citation={citation_id}",
369
+ "database": snapshot["database"],
370
+ "snapshot": {"kind": snapshot["source_kind"], "version": _safe(snapshot["source_version"])},
371
+ "fields": [field["key"] for field in snapshot["fields"]
372
+ if field.get("key") in set(_referenced_fields(view))],
373
+ "filters": _effective_filters(snapshot, view),
374
+ "permission_scope_applied": snapshot["permission_scope_applied"],
375
+ "aggregation": _safe(view["aggregation"]),
376
+ "contributing_record_count": numeric["contributing_record_count"],
377
+ "retrieved_at": snapshot["retrieved_at"],
378
+ }
379
+
380
+
381
+ def _citation_complete(citation):
382
+ """A numeric result is not publishable without complete provenance."""
383
+ required = {"database", "snapshot", "fields", "filters", "aggregation",
384
+ "contributing_record_count", "retrieved_at", "href"}
385
+ snapshot = citation.get("snapshot") if isinstance(citation, dict) else None
386
+ return (isinstance(citation, dict) and required <= set(citation)
387
+ and bool(citation["database"]) and isinstance(snapshot, dict)
388
+ and "version" in snapshot and bool(citation["retrieved_at"])
389
+ and isinstance(citation["fields"], list)
390
+ and isinstance(citation["aggregation"], dict))
391
+
392
+
393
+ def _public_view(view):
394
+ source = view["source"]
395
+ return {
396
+ "id": view["id"], "viewId": view["id"], "scope": source["database"],
397
+ "name": view["name"], "kind": view["view"]["kind"], "question": view["question"],
398
+ "explain": view["explain"], "threadId": view["threadId"], "createdAt": view["createdAt"],
399
+ "virtual": True, "source": _safe(source), "view": _safe(view["view"]),
400
+ "citationIds": list(view["citationIds"]), "numeric": _safe(view["numeric"]),
401
+ }
402
+
403
+
404
+ def _source_still_permitted(session, source, permitted=None):
405
+ """A persisted artefact never outlives the caller's current data permission.
406
+
407
+ ⚠ `permitted` is the BATCH answer from `deps.assistant_source_status` β€” one rows-free
408
+ resolution for every source at once, rather than one whole-document read per saved view. That
409
+ function's docstring carries the measurement. The single-source path below stays for callers
410
+ holding exactly one artefact (the workspace-event door), and asks the same helper.
411
+
412
+ β›” `permitted`, NEVER `answerable`. A source whose rows are served through the connector mirror
413
+ cannot be ASKED and can still be SEEN: the artefact was built from a snapshot that was legal
414
+ when it was taken, and hiding it because the reader can no longer make a NEW one would read as
415
+ deletion. The two verdicts are separate for that reason.
416
+ """
417
+ database = (source or {}).get("database") if isinstance(source, dict) else None
418
+ if not database:
419
+ return False
420
+ if permitted is None:
421
+ permitted = {key for key, row in assistant_source_status(session, [database]).items()
422
+ if row["permitted"]}
423
+ return database in permitted
424
+
425
+
426
+ def _public_state(state, session):
427
+ # ONE rows-free batch answers both questions: which sources this caller may still see (which
428
+ # artefacts stay listed) and which of them can actually be asked (which chips are live).
429
+ status = assistant_source_status(session)
430
+ for row in state["views"].values():
431
+ key = (row.get("source") or {}).get("database") if isinstance(row.get("source"), dict) else None
432
+ if key and key not in status:
433
+ # An artefact whose source is no longer enumerable still gets a verdict rather than a
434
+ # KeyError β€” it resolves to "not permitted" and the artefact drops out, which is the
435
+ # same answer the per-view wall gave.
436
+ status.update(assistant_source_status(session, [key]))
437
+ permitted = {key for key, row in status.items() if row["permitted"]}
438
+ allowed_views = [row for row in state["views"].values()
439
+ if _source_still_permitted(session, row.get("source"), permitted)]
440
+ allowed_ids = {row["id"] for row in allowed_views}
441
+ allowed_citations = {citation_id for row in allowed_views
442
+ for citation_id in row.get("citationIds") or []}
443
+ threads = sorted(state["threads"].values(), key=lambda row: row.get("updatedAt", ""), reverse=True)
444
+ messages = sorted(state["messages"].values(), key=lambda row: row.get("createdAt", ""))
445
+ views = sorted((_public_view(row) for row in allowed_views), key=lambda row: row["createdAt"], reverse=True)
446
+ messages = [row for row in messages if not row.get("viewId") or row.get("viewId") in allowed_ids]
447
+ return {"threads": _safe(threads), "messages": _safe(messages), "views": views,
448
+ "citations": _safe([row for key, row in state["citations"].items()
449
+ if key in allowed_citations]), "models": model_choices(),
450
+ # The chip row's own data: a source this caller holds but cannot ask, and WHY.
451
+ "sources": _safe([{"database": key, "answerable": row["answerable"],
452
+ "reason": row["reason"]}
453
+ for key, row in sorted(status.items()) if row["permitted"]])}
454
+
455
+
456
+ @router.get("/query")
457
+ def list_queries(session: Session = Depends(require_session)):
458
+ return _public_state(_state(session), session)
459
+
460
+
461
+ @router.post("/query/{qid}/events")
462
+ def mutate_query_workspace(qid: str, body: dict = Body(default=None),
463
+ session: Session = Depends(require_session)):
464
+ """The only grid-mutation transport for a virtual Query workspace.
465
+
466
+ Query artefacts are immutable snapshots: creation and changes belong to an Assistant prompt,
467
+ never a source-native grid workspace. Deleting the caller's personal artefact is the one
468
+ permitted mutation. The binding key, not a client-supplied source scope, identifies it.
469
+ """
470
+ qid = str(qid)
471
+ body = body if isinstance(body, dict) else {}
472
+ binding = body.get("workspaceBinding")
473
+ event = body.get("event")
474
+ if not (isinstance(binding, dict) and binding.get("kind") == "query"
475
+ and str(binding.get("key") or "") == qid):
476
+ raise err(400, "query_workspace_mismatch",
477
+ "the Query workspace binding must name this virtual artefact")
478
+ if not isinstance(event, dict) or str(event.get("type") or "") not in QUERY_WORKSPACE_EVENTS:
479
+ raise err(400, "unsupported_query_workspace_event",
480
+ "Query accepts only view_create, view_upsert, or view_delete events")
481
+
482
+ state = _state(session)
483
+ view = state["views"].get(qid)
484
+ if view is None or not _source_still_permitted(session, view.get("source")):
485
+ # A caller cannot use an opaque Query key to learn about a revoked artefact.
486
+ raise err(404, "unknown_query", "that Query artefact does not exist")
487
+
488
+ event_type = str(event["type"])
489
+ if event_type != "view_delete":
490
+ raise err(409, "query_workspace_immutable",
491
+ "AI-created Query views change only through a new Assistant prompt")
492
+ if str(event.get("viewId") or "") != qid:
493
+ raise err(400, "query_workspace_mismatch",
494
+ "a Query delete must name the same virtual artefact as its binding")
495
+
496
+ deleted = delete_query(qid, session)
497
+ return {"workspaceBinding": {"kind": "query", "key": qid},
498
+ "event": event_type, **deleted}
499
+
500
+
501
+ def _sources(body, target, session):
502
+ raw = body.get("sources") if isinstance(body, dict) else None
503
+ sources = [str(item).strip() for item in raw] if isinstance(raw, list) else []
504
+ sources = list(dict.fromkeys(item for item in sources if item))
505
+ if target not in sources:
506
+ sources.insert(0, target)
507
+ # Source chips are a permissioned selection, not untrusted labels remembered in a thread.
508
+ # The model still receives ONLY `target`'s snapshot below; these calls do not pass a second
509
+ # source to it and D's helper refuses cache misses, mirrors and unshared databases.
510
+ for source in sources:
511
+ assistant_read_scope(session, source, fields=[], filters=None)
512
+ return sources
513
+
514
+
515
+ def _submit(body, session, chat=None):
516
+ body = body if isinstance(body, dict) else {}
517
+ question = " ".join(str(body.get("question") or "").split())
518
+ target = str(body.get("database") or body.get("scope") or "").strip()
519
+ selected_model = str(body.get("model") or MODEL_AUTO).strip().lower()
520
+ if not question:
521
+ raise err(400, "bad_request", "no question was asked")
522
+ if len(question) > MAX_QUESTION:
523
+ raise err(400, "question_too_long", f"a question must be under {MAX_QUESTION} characters")
524
+ if not target:
525
+ raise err(400, "bad_request", "one target database must be selected")
526
+ if selected_model not in model_choices():
527
+ raise err(400, "unknown_model", "that model is not available in Query")
528
+
529
+ # This call happens before model selection and is the only permitted source-data read.
530
+ snapshot = assistant_read_scope(session, target, fields=None, filters=body.get("filters"))
531
+ sources = _sources(body, target, session)
532
+ state = _state(session)
533
+ thread_id = str(body.get("threadId") or "").strip()
534
+ if thread_id and thread_id not in state["threads"]:
535
+ raise err(404, "unknown_thread", "that chat does not exist")
536
+ if not thread_id:
537
+ thread_id = _new_id("thread")
538
+ now = _now_iso()
539
+ user_message_id = _new_id("message")
540
+ assistant_message_id = _new_id("message")
541
+ spec, sentence, provider, reason = _call_model(question, snapshot, model=selected_model, chat=chat)
542
+ view, refusal, refusal_code = _validate(spec, snapshot["fields"]) if spec is not None else (None, sentence, reason)
543
+
544
+ artifact = None
545
+ citation = None
546
+ if view is not None:
547
+ if len(state["views"]) >= MAX_ARTIFACTS:
548
+ raise err(400, "query_limit", f"you already have {len(state['views'])} Query artefacts")
549
+ view_id = _new_id("query")
550
+ citation_id = _new_id("citation")
551
+ numeric = _numeric_result(view, _view_records(snapshot, view))
552
+ citation = _citation(citation_id, snapshot, view, numeric, view_id)
553
+ if not _citation_complete(citation):
554
+ raise RuntimeError("Query refused to persist an incomplete numeric citation")
555
+ source = {"database": snapshot["database"], "label": snapshot["label"],
556
+ "source_kind": snapshot["source_kind"], "source_version": _safe(snapshot["source_version"]),
557
+ "retrieved_at": snapshot["retrieved_at"],
558
+ "fields": _safe(snapshot["fields"]), "filters": _safe(snapshot["filters"]),
559
+ "permission_scope_applied": snapshot["permission_scope_applied"]}
560
+ artifact = {"id": view_id, "threadId": thread_id, "name": view["name"], "question": question,
561
+ "explain": _explain(view, snapshot["fields"]), "createdAt": now, "source": source,
562
+ "view": _safe(view), "citationIds": [citation_id], "numeric": numeric,
563
+ "model": provider, "requestedModel": selected_model}
564
+
565
+ assistant_message = {
566
+ "id": assistant_message_id, "threadId": thread_id, "role": "assistant", "createdAt": now,
567
+ "content": artifact["explain"] if artifact else (refusal or "the assistant could not answer"),
568
+ "targetDatabase": target, "requestedModel": selected_model, "model": provider,
569
+ "reason": refusal_code, "viewId": artifact["id"] if artifact else None,
570
+ "citationIds": artifact["citationIds"] if artifact else [], "numeric": artifact["numeric"] if artifact else None,
571
+ }
572
+ user_message = {"id": user_message_id, "threadId": thread_id, "role": "user", "createdAt": now,
573
+ "content": question, "sources": sources, "targetDatabase": target,
574
+ "requestedModel": selected_model}
575
+
576
+ def update(raw):
577
+ current = _blank_state()
578
+ if isinstance(raw, dict):
579
+ for key in ("threads", "messages", "citations", "views"):
580
+ if isinstance(raw.get(key), dict):
581
+ current[key] = copy.deepcopy(raw[key])
582
+ thread = current["threads"].get(thread_id) or {"id": thread_id, "createdAt": now}
583
+ thread.update({"updatedAt": now, "title": question[:80], "sources": sources,
584
+ "model": selected_model, "activeViewId": artifact["id"] if artifact else thread.get("activeViewId")})
585
+ current["threads"][thread_id] = thread
586
+ current["messages"][user_message_id] = user_message
587
+ current["messages"][assistant_message_id] = assistant_message
588
+ if artifact:
589
+ current["views"][artifact["id"]] = artifact
590
+ current["citations"][citation["id"]] = citation
591
+ return current
592
+
593
+ if not session.runtime.available():
594
+ raise err(503, "store_unavailable", "the tenant store is unavailable; no chat was saved")
595
+ session.runtime.update(_namespace_key(session), update, flush="async")
596
+ return {"thread": _safe(update(state)["threads"][thread_id]), "userMessage": _safe(user_message),
597
+ "message": _safe(assistant_message),
598
+ "view": _public_view(artifact) if artifact else None, "citations": [citation] if citation else []}
599
+
600
+
601
+ @router.post("/query/chat")
602
+ def submit_chat(body: dict = Body(default=None), session: Session = Depends(require_session)):
603
+ return _submit(body, session)
604
+
605
+
606
+ @router.delete("/query/threads/{tid}")
607
+ def delete_thread(tid: str, session: Session = Depends(require_session)):
608
+ """Remove one of the caller's own chats, and only the chat.
609
+
610
+ β›” THE ARTEFACTS SURVIVE, AND THAT IS THE POINT. A Query view is a durable object in its own
611
+ right β€” it appears in Query's rail, other people's links can point at it, and the owner's
612
+ instruction was that AI views LIVE THERE rather than inside the conversation that happened to
613
+ produce them. Cascading the delete would make tidying up a chat silently destroy work. Views
614
+ are deleted from Query's own rail, one at a time, by `delete_query` below.
615
+
616
+ ⚠ It exists because the history had no prune. `delete_query` removed a view and left its
617
+ thread, so the panel could only ever grow β€” a navigation you cannot manage is half a
618
+ navigation, and a person clearing test chats found that out first.
619
+ """
620
+ tid = str(tid)
621
+ state = _state(session)
622
+ if tid not in state["threads"]:
623
+ raise err(404, "unknown_thread", "that chat does not exist")
624
+
625
+ def update(raw):
626
+ current = _blank_state()
627
+ if isinstance(raw, dict):
628
+ for key in ("threads", "messages", "citations", "views"):
629
+ if isinstance(raw.get(key), dict):
630
+ current[key] = copy.deepcopy(raw[key])
631
+ current["threads"].pop(tid, None)
632
+ for message_id in [key for key, row in current["messages"].items()
633
+ if (row or {}).get("threadId") == tid]:
634
+ current["messages"].pop(message_id, None)
635
+ # ⚠ THE VIEWS' `threadId` IS LEFT EXACTLY AS IT WAS, dangling. Blanking it looks tidier and
636
+ # is a data-loss bug: `queryApi.saved()` refuses a row with an empty `threadId`, so every
637
+ # artefact built in the deleted chat would silently vanish from Query's rail β€” the exact
638
+ # "tidying up destroys work" outcome this door is written not to have
639
+ # [[a-record-can-outlive-its-subject]]. Nothing resolves the id but the chat panel, which
640
+ # only ever looks up the thread it is showing.
641
+ return current
642
+
643
+ session.runtime.update(_namespace_key(session), update, flush="async")
644
+ return {"deleted": tid}
645
+
646
+
647
+ @router.delete("/query/{qid}")
648
+ def delete_query(qid: str, session: Session = Depends(require_session)):
649
+ qid = str(qid)
650
+ state = _state(session)
651
+ if qid not in state["views"]:
652
+ raise err(404, "unknown_query", "that Query artefact does not exist")
653
+
654
+ def update(raw):
655
+ current = _blank_state()
656
+ if isinstance(raw, dict):
657
+ for key in ("threads", "messages", "citations", "views"):
658
+ if isinstance(raw.get(key), dict):
659
+ current[key] = copy.deepcopy(raw[key])
660
+ view = current["views"].pop(qid, None)
661
+ for citation_id in (view or {}).get("citationIds") or []:
662
+ current["citations"].pop(citation_id, None)
663
+ return current
664
+
665
+ session.runtime.update(_namespace_key(session), update, flush="async")
666
+ return {"deleted": qid}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
platform/core/perm_scope.py CHANGED
@@ -87,6 +87,22 @@ def may_access(user, module):
87
  return perms.may_open(user, module) # legacy record: the old grant wall
88
 
89
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
90
  # ── FIELDS ───────────────────────────────────────────────────────────────────────────────────
91
  def hidden_keys(user, module, fields):
92
  """The TRANSITIVE closure of hidden field keys (C-PERM amendment 5).
@@ -143,6 +159,37 @@ def visible_fields(fields, user, module):
143
  if not (isinstance(f, dict) and f.get('key') in hide)]
144
 
145
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
146
  def strip_row(row, hide):
147
  """Drop hidden keys from ONE assembled row. Cheap enough to run per row, and it must run
148
  per row: the field list and the row payload are two different wires, and stripping only the
@@ -170,6 +217,89 @@ def apply_row_scope(rows, user, module, fields, ctx=None):
170
  return [r for r in (rows or ()) if fe.permits(tree, r, fields, ctx)]
171
 
172
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
173
  # ── THE PUSHDOWN ─────────────────────────────────────────────────────────────────────────────
174
  #: `dba` value sets that pin a BU, and the Odoo team they pin. `_dba_attrs` emits exactly
175
  #: {'Fisch','Royal','Both'} (blank for "no brand attributable in 24 months"), so a permission
 
87
  return perms.may_open(user, module) # legacy record: the old grant wall
88
 
89
 
90
+ def assistant_entry(user, module):
91
+ """The explicit database grant an Assistant snapshot may rely on, else ``None``.
92
+
93
+ The interactive application retains an admin break-glass path and a temporary legacy-grant
94
+ compatibility path. Neither is an answer at the Assistant's app-stored data boundary:
95
+ that reader must be able to name the migrated grant that admitted a database. In particular,
96
+ a store-outage admin identity with no ``perms`` document is not an unresolved permission that
97
+ may be widened into data access.
98
+ """
99
+ e = entry(user, module)
100
+ if (not is_migrated(user) or not isinstance(e, dict)
101
+ or not bool(e.get('access', True)) or not may_access(user, module)):
102
+ return None
103
+ return e
104
+
105
+
106
  # ── FIELDS ───────────────────────────────────────────────────────────────────────────────────
107
  def hidden_keys(user, module, fields):
108
  """The TRANSITIVE closure of hidden field keys (C-PERM amendment 5).
 
159
  if not (isinstance(f, dict) and f.get('key') in hide)]
160
 
161
 
162
+ def assistant_visible_fields(fields, user, module):
163
+ """Visible closure under one explicit Assistant grant, including for an admin caller."""
164
+ e = assistant_entry(user, module)
165
+ if e is None:
166
+ return []
167
+ # `hidden_keys` deliberately preserves the interactive admin break-glass behaviour. The
168
+ # Assistant uses its explicit grant instead, but shares the same transitive formula closure.
169
+ hidden = {str(k) for k in (e.get('hiddenFields') or ()) if k}
170
+ if not hidden:
171
+ return list(fields or ())
172
+
173
+ refs = {}
174
+ for field in fields or ():
175
+ if not isinstance(field, dict) or not field.get('key'):
176
+ continue
177
+ expr = field.get('formula')
178
+ if isinstance(expr, str) and expr:
179
+ refs[field['key']] = {match.strip() for match in _FORMULA_REF.findall(expr)
180
+ if match.strip()}
181
+ for _ in range(12):
182
+ grew = False
183
+ for key, deps in refs.items():
184
+ if key not in hidden and deps & hidden:
185
+ hidden.add(key)
186
+ grew = True
187
+ if not grew:
188
+ break
189
+ return [field for field in (fields or ())
190
+ if not (isinstance(field, dict) and field.get('key') in hidden)]
191
+
192
+
193
  def strip_row(row, hide):
194
  """Drop hidden keys from ONE assembled row. Cheap enough to run per row, and it must run
195
  per row: the field list and the row payload are two different wires, and stripping only the
 
217
  return [r for r in (rows or ()) if fe.permits(tree, r, fields, ctx)]
218
 
219
 
220
+ def assistant_apply_row_scope(rows, user, module, fields, ctx=None):
221
+ """Apply an explicit Assistant grant's permanent filter without an admin bypass."""
222
+ e = assistant_entry(user, module)
223
+ if e is None:
224
+ return []
225
+ tree = e.get('filter')
226
+ if not tree:
227
+ return list(rows or ())
228
+ from harness import filter_eval as fe
229
+ return [row for row in (rows or ()) if fe.permits(tree, row, fields, ctx)]
230
+
231
+
232
+ def validate_assistant_filter(tree, fields):
233
+ """Return a strict, detached Assistant filter tree or raise ``ValueError``.
234
+
235
+ The display filter cleaner is intentionally permissive: it drops a stale column or leaves an
236
+ inactive condition alone so an old saved view can still open. An Assistant data reader cannot
237
+ inherit that behaviour β€” dropping a predicate turns a request for a subset into a wider read.
238
+ This validator therefore admits only visible field operands that the existing evaluator can
239
+ answer from one stored row. Cohort, measure and rank conditions need separate materialised
240
+ set resolvers and are refused here rather than guessed.
241
+ """
242
+ if tree is None:
243
+ return None
244
+ from harness import filter_eval as fe
245
+
246
+ by_key = {str(field.get('key')): field for field in (fields or ())
247
+ if isinstance(field, dict) and field.get('key')}
248
+ if not by_key:
249
+ raise ValueError('assistant filter has no visible field contract')
250
+
251
+ def _leaf(raw):
252
+ if not isinstance(raw, dict):
253
+ raise ValueError('assistant filter leaf must be an object')
254
+ allowed = {'id', 'colId', 'op', 'value', 'value2', 'rhs'}
255
+ if set(raw) - allowed:
256
+ raise ValueError('assistant filter carries an unsupported operand')
257
+ col = raw.get('colId')
258
+ op = raw.get('op')
259
+ if not isinstance(col, str) or col not in by_key:
260
+ raise ValueError('assistant filter names an unknown or hidden field')
261
+ if (not isinstance(op, str) or op not in fe.FILTER_OPS or op in fe.RANK_OPS
262
+ or op in {'between', 'within'} or col == fe.COHORT_FIELD):
263
+ raise ValueError('assistant filter uses an unsupported operator')
264
+ rhs = raw.get('rhs')
265
+ if rhs is not None:
266
+ if (not isinstance(rhs, dict) or rhs.get('kind') != 'field'
267
+ or set(rhs) - {'kind', 'colId'}
268
+ or not isinstance(rhs.get('colId'), str)
269
+ or rhs['colId'] not in by_key):
270
+ raise ValueError('assistant filter names an unknown or hidden right-hand field')
271
+ out = {name: raw[name] for name in ('id', 'colId', 'op', 'value', 'value2')
272
+ if name in raw}
273
+ if rhs is not None:
274
+ out['rhs'] = {'kind': rhs.get('kind'), 'colId': rhs['colId']}
275
+ if not fe.is_rule_active(out, by_key):
276
+ raise ValueError('assistant filter is inactive or unanswerable')
277
+ return out
278
+
279
+ def _node(raw):
280
+ if not isinstance(raw, dict):
281
+ raise ValueError('assistant filter node must be an object')
282
+ if 'children' not in raw:
283
+ return _leaf(raw)
284
+ if set(raw) - {'conj', 'children'}:
285
+ raise ValueError('assistant filter group carries an unsupported operand')
286
+ children = raw.get('children')
287
+ if raw.get('conj') not in ('and', 'or') or not isinstance(children, list) or not children:
288
+ raise ValueError('assistant filter group must be a non-empty and/or group')
289
+ return {'conj': raw['conj'], 'children': [_node(child) for child in children]}
290
+
291
+ if isinstance(tree, list):
292
+ if not tree:
293
+ raise ValueError('assistant filter list must not be empty')
294
+ return {'conj': 'and', 'nodes': [_node(node) for node in tree]}
295
+ if not isinstance(tree, dict) or set(tree) - {'conj', 'nodes'}:
296
+ raise ValueError('assistant filters must be a tree with nodes')
297
+ nodes = tree.get('nodes')
298
+ if tree.get('conj') not in ('and', 'or') or not isinstance(nodes, list) or not nodes:
299
+ raise ValueError('assistant filter tree must be a non-empty and/or tree')
300
+ return {'conj': tree['conj'], 'nodes': [_node(node) for node in nodes]}
301
+
302
+
303
  # ── THE PUSHDOWN ─────────────────────────────────────────────────────────────────────────────
304
  #: `dba` value sets that pin a BU, and the Odoo team they pin. `_dba_attrs` emits exactly
305
  #: {'Fisch','Royal','Both'} (blank for "no brand attributable in 24 months"), so a permission
web/src/assistant/AssistantPage.tsx CHANGED
@@ -1,394 +1,409 @@
1
- // ---------------------------------------------------------------------------
2
- // assistant/AssistantPage.tsx β€” THE AI ASSISTANT (owner item 2, 2026-08-14).
3
- //
4
- // Owner, verbatim: *"the query module should exactly BE looking like the database
5
- // module, COMPLETELY, with all the views etc. The only difference is that user isn't
6
- // the one creating the view, it's the AI that they prompt in the AI assistant module.
7
- // So now yes, you can unblock user from accessing the AI assistant, in staging only so
8
- // that we can see if the prompt to query data works."*
9
- //
10
- // That sentence splits one workflow across two routes, and this is the FIRST half:
11
- // you ask here, you read the answer back here, you keep it here β€” and then you LOOK at
12
- // it in Query, which is the database frame. The ask box used to live on `QueryPage`,
13
- // where it sat above a list of rows; a page with a question field on top of it is not a
14
- // page that "exactly looks like the database module", so the box moved to the surface
15
- // that is actually about asking.
16
- //
17
- // β›” THIS IS ALSO THE `ai-agent` MODULE'S REPLACEMENT, and the two are one row now.
18
- // Owner: *"AI assistant module IS AI agent module. So no need to separate it like
19
- // that. Just remove that AI agent module completely."* Wave 33 shipped an `#/ai-agent`
20
- // route beside an "AI assistant" row whose click opened an under-construction note for
21
- // an Analyst deleted at EXIT-6 β€” two AI doors, one of them apologising. There is one.
22
- //
23
- // β›” THE SENTENCE UNDER THE ANSWER IS THE LOAD-BEARING PART OF THIS PAGE, and it came
24
- // here from `QueryPage` intact. Measured in the prototype (W32-T50 section 4d): a free
25
- // model produced a spec whose every column was real, every op legal, kind in the
26
- // vocabulary and target granted β€” and which answered a different question ("Revenue by
27
- // Creator" against a database holding no revenue). No server-side validation can see
28
- // that class. So the server DERIVES a read-back from the spec it accepted, this page
29
- // shows it, and nothing is saved until a person says so. Never shorten `explain` away
30
- // to make the page tidier.
31
- //
32
- // β›” CHROME, NOT A REGISTRY MODULE, and `granted` is why that is allowed. `nav.ts`'s law
33
- // for this set is one sentence β€” "A CHROME ROUTE RENDERS NOTHING THE SERVER DID NOT
34
- // ALREADY GRANT" β€” so the database picker is the nav's OWN entries, handed down by the
35
- // Shell, never a directory this page fetches. It matters more here than on any other
36
- // chrome route, because this is the surface that NAMES a database to a model. (The
37
- // build door re-checks with the same wall regardless: `routes_query._target` β†’
38
- // `routes_templates._target_or_refuse`. This is what stops the picker offering what the
39
- // wall would then refuse.)
40
- //
41
- // ⚠ NO CLIENT UNION OVER `kind` β€” the server's vocabulary arrives as a string (the
42
- // wave-9 law), so a kind we do not have a label for renders as itself rather than
43
- // dropping the row.
44
- // ---------------------------------------------------------------------------
45
-
46
- import { useCallback, useEffect, useRef, useState } from "react";
47
 
48
  import { QUERY_OPEN_EVENT, signal } from "../apiContract";
 
 
49
  import { retryEmit } from "../inbox/inboxModel";
50
- import { buildQuery, deleteQuery, fetchQueries, saveQuery } from "../query/queryApi";
51
- import type { BuildResult, SavedQuery } from "../query/queryApi";
52
- import { QUERY_ROUTE, databaseEntries } from "../shell/nav";
53
  import type { NavEntry } from "../shell/nav";
 
 
 
 
54
  import "./assistant.css";
55
 
56
- export interface AssistantPageProps {
57
- /**
58
- * β›” REQUIRED, and it is the nav's own entries. An optional prop here would degrade to
59
- * "the feature does not exist" the first time a mount forgot it, which is
60
- * indistinguishable from never having been built. The Shell already holds these β€”
61
- * passing them is also what keeps the chrome law true by construction rather than by
62
- * promise.
63
- */
64
- granted: NavEntry[];
65
- }
66
 
67
- /** Human words for a kind. A kind absent from here renders as itself, never dropped. */
68
  const KIND_LABEL: Record<string, string> = {
69
- grid: "Table",
70
- list: "List",
71
- chart: "Chart",
72
- kanban: "Board",
73
- calendar: "Calendar",
74
- timeseries: "Time series",
75
- map: "Map",
76
  };
77
 
78
- /** The sparkle, co-located rather than imported: `Shell.tsx`'s copy is private to it, and
79
- * importing the shell from a lazily-imported route surface is a cycle for one glyph. */
80
- function SparkMark() {
81
- return (
82
- <svg className="shell-nav-icon is-spark" viewBox="0 0 16 16" aria-hidden="true">
83
- <path d="M6.2 2.2 7.5 5.5l3.3 1.3-3.3 1.3-1.3 3.3-1.3-3.3-3.3-1.3 3.3-1.3z" />
84
- <path d="M12.2 9.4l.8 2 2 .8-2 .8-.8 2-.8-2-2-.8 2-.8z" />
85
- </svg>
86
- );
87
- }
88
-
89
  /**
90
- * Route to the Query module and ask it to show this saved query.
 
91
  *
92
- * β›” THE LADDER IS NOT DEFENSIVE PROGRAMMING, IT IS THE ONLY CORRECT SHAPE. `QueryPage`
93
- * mounts on the hash change and then fetches `GET /query`; until that answers it cannot
94
- * know the qid exists, so it DROPS the event β€” and there is no acknowledgement to wait
95
- * on. `retryEmit` is the shell's own bounded ladder (5 attempts over 0–2,600 ms), reused
96
- * rather than re-written, and selecting a query twice is a no-op.
 
97
  */
 
 
 
 
 
 
 
98
  export function openBuiltView(qid: string): () => void {
99
  if (typeof window === "undefined") return () => {};
100
- if (window.location.hash.replace(/^#\/?/, "") !== QUERY_ROUTE) {
101
- window.location.hash = `#/${QUERY_ROUTE}`;
102
- }
103
- return retryEmit(() => {
104
- signal(QUERY_OPEN_EVENT, { qid });
105
- });
106
  }
107
 
108
  /**
109
- * The answer block: a refusal, or the read-back plus the two ways out.
110
- *
111
- * ⚠ A NAMED EXPORT ON PURPOSE, so `verify_query`'s render leg can paint the three states
112
- * directly. `renderToString` does not run effects, so the states this page reaches only
113
- * AFTER a fetch would otherwise be unprovable without a browser β€” and the read-back and
114
- * the refusal sentence are precisely the parts that must not quietly stop rendering
115
- * ([[ui-invisible-to-assertions]]).
116
  */
117
- export function QueryAnswer({ result, saving, onKeep, onDiscard }: {
118
- result: BuildResult;
119
- saving?: boolean;
120
- onKeep: () => void;
121
- onDiscard: () => void;
 
122
  }) {
123
- if (result.refused) {
 
 
124
  return (
125
- <section className="as-answer is-refused" role="status">
126
- <p className="as-answer-head">That is not a view this database can draw</p>
127
- <p className="as-answer-body">{result.refused}</p>
128
- </section>
129
  );
130
  }
131
- if (!result.spec) return null;
132
  return (
133
- <section className="as-answer">
134
- <p className="as-answer-head">Here is what that view would do</p>
135
- {/* β›” THE READ-BACK. Derived by the SERVER from the spec it accepted, so it
136
- describes what will be built rather than what the model meant. */}
137
- {result.explain ? <p className="as-answer-body">{result.explain}</p> : null}
138
- {/* β›” THE FALLBACK NOTICE. Not decoration: the top rung of the server's ladder is the only
139
- one measured to REFUSE an unanswerable question honestly, so a spec built by a lower
140
- rung can be well-formed, plausible and about a different question entirely β€” and the
141
- read-back above describes what the spec DOES, which is exactly as convincing either
142
- way. This is the one signal that tells the reader to look harder before keeping it.
143
- ⚠ It never blocks the save: the person is the check, and taking the control away
144
- because the model was second-choice would be a worse product than saying so. */}
145
- {result.fallback ? (
146
- <p className="as-answer-note" role="status">
147
- Built by a backup model β€” the usual one was busy. Read this back carefully before
148
- keeping it.
149
  </p>
150
  ) : null}
151
- <div className="as-answer-acts">
152
- <button type="button" className="as-btn as-btn--primary" disabled={saving}
153
- onClick={onKeep}>
154
- {saving ? "Saving…" : "Keep this view"}
 
 
 
155
  </button>
156
- <button type="button" className="as-btn" onClick={onDiscard}>
157
- Discard
158
- </button>
159
- </div>
160
- </section>
 
 
 
 
 
 
 
 
 
 
161
  );
162
  }
163
 
164
- /** One view the assistant has built. Named for the same reason as {@link QueryAnswer}. */
165
- export function SavedQueryRow({ view, dbLabel, onOpen, onDelete }: {
166
- view: SavedQuery;
167
- dbLabel: string;
168
- onOpen: () => void;
169
- onDelete: () => void;
170
- }) {
171
- return (
172
- <li className="as-row">
173
- <div className="as-row-main">
174
- <button type="button" className="as-row-open" onClick={onOpen}>
175
- {view.name}
176
- </button>
177
- <span className="as-row-meta">
178
- {KIND_LABEL[view.kind] ?? view.kind} Β· {dbLabel}
179
- </span>
180
- </div>
181
- {view.question ? <p className="as-row-q">β€œ{view.question}”</p> : null}
182
- {view.explain ? <p className="as-row-explain">{view.explain}</p> : null}
183
- <div className="as-row-acts">
184
- <button type="button" className="as-btn" onClick={onOpen}>
185
- Open in Query
186
- </button>
187
- <button type="button" className="as-btn as-btn--quiet" onClick={onDelete}>
188
- Delete
189
- </button>
190
- </div>
191
- </li>
192
- );
193
  }
194
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
195
  export default function AssistantPage({ granted }: AssistantPageProps) {
196
- const [scope, setScope] = useState<string>("");
 
 
 
 
197
  const [question, setQuestion] = useState("");
198
  const [busy, setBusy] = useState(false);
199
- const [result, setResult] = useState<BuildResult | null>(null);
200
  const [problem, setProblem] = useState("");
201
- const [saved, setSaved] = useState<SavedQuery[]>([]);
202
- const [builtins, setBuiltins] = useState<string[]>([]);
203
- const [saving, setSaving] = useState(false);
204
  const [loaded, setLoaded] = useState(false);
 
205
  const openCancel = useRef<(() => void) | null>(null);
 
206
 
 
 
207
  /**
208
- * The databases this page may ASK ABOUT.
209
- *
210
- * `databaseEntries` drops SURFACE rows (that fact is decided where rows are built, not
211
- * re-derived here) and a group head has no destination. β›” BUT "granted and not a surface" is
212
- * WIDER THAN THE BUILD DOOR ACCEPTS: it takes any `ut_*` key plus the built-in topics it
213
- * publishes as `builtins`. Offering anything else would be a control that lies β€” the picker
214
- * would list a database and the build call would 404 on it. So the server's own accepted set is
215
- * mirrored, never guessed, and until it arrives only `ut_*` keys are offered (fail NARROW).
216
  */
217
- const databases = databaseEntries(granted).filter(
218
- (e) => e.kind !== "group" && (e.key.startsWith("ut_") || builtins.includes(e.key))
219
- );
220
-
221
- // Keep the picker on a database that still exists: a grant can be withdrawn while
222
- // this page is open, and the server prunes on read, so the client must not pin a key
223
- // the payload no longer carries.
224
- useEffect(() => {
225
- if (databases.length && !databases.some((d) => d.key === scope)) {
226
- setScope(databases[0].key);
227
- }
228
- }, [databases, scope]);
229
-
230
- // Cancel any open-ladder still running when this page unmounts β€” a timer firing into a
231
- // surface nobody is looking at is the cheap half of a leak, and cancelling is one line.
232
- useEffect(() => () => openCancel.current?.(), []);
233
 
234
  const reload = useCallback(async () => {
235
- const res = await fetchQueries();
236
  setLoaded(true);
237
- if (res.ok) {
238
- setSaved(res.value.views);
239
- setBuiltins(res.value.builtins);
240
  }
241
  }, []);
242
 
 
 
243
  useEffect(() => {
244
- void reload();
245
- }, [reload]);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
 
247
  const ask = useCallback(async () => {
248
- const q = question.trim();
249
- if (!q || !scope || busy) return;
250
- setBusy(true);
251
- setProblem("");
252
- setResult(null);
253
- const res = await buildQuery(q, scope);
254
  setBusy(false);
255
- if (!res.ok) {
256
- setProblem(res.message);
257
- return;
258
- }
259
- setResult(res.value);
260
- }, [busy, question, scope]);
261
-
262
- const keep = useCallback(async () => {
263
- if (!result?.spec || saving) return;
264
- setSaving(true);
265
- const res = await saveQuery(question.trim(), scope, result.spec);
266
- setSaving(false);
267
- if (!res.ok) {
268
- setProblem(res.message);
269
- return;
270
- }
271
- setResult(null);
272
  setQuestion("");
273
- await reload();
274
- }, [question, reload, result, saving, scope]);
 
 
 
 
 
 
 
275
 
276
- const forget = useCallback(async (id: string) => {
277
- const res = await deleteQuery(id);
278
- if (!res.ok) {
279
- setProblem(res.message);
280
- return;
281
- }
282
- await reload();
283
- }, [reload]);
 
 
 
 
284
 
285
- const open = useCallback((qid: string) => {
286
  openCancel.current?.();
287
  openCancel.current = openBuiltView(qid);
288
  }, []);
289
 
290
- const labelOf = (key: string) =>
291
- databases.find((d) => d.key === key)?.label ?? key;
292
-
293
- return (
294
- <div className="as-page">
295
- <h1 className="as-title">
296
- <SparkMark /> AI assistant
297
- </h1>
298
- <p className="as-lede">
299
- Ask about one of your databases. You get a view you can read before you keep it β€” and a
300
- plain answer when the question is not one this product can draw. Everything you keep
301
- opens in Query.
302
- </p>
303
-
304
- {databases.length === 0 ? (
305
- // ⚠ TWO DIFFERENT FACTS, TWO DIFFERENT SENTENCES. Before the index arrives the offerable
306
- // set is deliberately narrow (see `databases`), so "you have no database" would be a claim
307
- // this page cannot yet make.
308
- <p className="as-note">
309
- {loaded
310
- ? "You do not have a database to ask about yet. Create one from the Database menu, "
311
- + "then come back and ask about it."
312
- : "Loading your databases…"}
313
- </p>
314
- ) : (
315
- <section className="as-ask">
316
- <label className="as-field">
317
- <span className="as-label">Database</span>
318
- <select
319
- className="as-select"
320
- value={scope}
321
- onChange={(e) => setScope(e.currentTarget.value)}
322
- >
323
- {databases.map((d) => (
324
- <option key={d.key} value={d.key}>
325
- {d.label}
326
- </option>
327
  ))}
328
  </select>
329
  </label>
330
- <label className="as-field as-field--grow">
331
- <span className="as-label">Question</span>
332
- <input
333
- className="as-input"
334
- value={question}
335
- placeholder="Everyone with more than 50,000 followers, biggest first"
336
- onChange={(e) => setQuestion(e.currentTarget.value)}
337
- onKeyDown={(e) => {
338
- if (e.key === "Enter") void ask();
339
- }}
340
- />
341
- </label>
342
- <button
343
- type="button"
344
- className="as-btn as-btn--primary"
345
- disabled={busy || !question.trim()}
346
- onClick={() => void ask()}
347
- >
348
- {busy ? "Working…" : "Build a view"}
349
  </button>
350
- </section>
351
- )}
352
-
353
- {problem ? (
354
- <p className="as-note is-warn" role="status">
355
- {problem}
356
- </p>
357
- ) : null}
358
-
359
- {/* A REFUSAL IS AN ANSWER, and it gets the same weight as a success β€” a sentence,
360
- never an error page (R1's own words). Both states live in `QueryAnswer`. */}
361
- {result ? (
362
- <QueryAnswer
363
- result={result}
364
- saving={saving}
365
- onKeep={() => void keep()}
366
- onDiscard={() => setResult(null)}
367
- />
368
- ) : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
369
 
370
- <h2 className="as-sub">Views I have built for you</h2>
371
- {!loaded ? (
372
- <p className="as-note">
373
- <span className="lp-spin" role="status" aria-label="Loading" />
374
- </p>
375
- ) : saved.length === 0 ? (
376
- <p className="as-note">
377
- Nothing yet. A view you keep lands in Query, and in the database it was built from.
378
- </p>
379
- ) : (
380
- <ul className="as-list">
381
- {saved.map((v) => (
382
- <SavedQueryRow
383
- key={v.id}
384
- view={v}
385
- dbLabel={labelOf(v.scope)}
386
- onOpen={() => open(v.id)}
387
- onDelete={() => void forget(v.id)}
388
- />
 
 
 
 
 
 
 
 
 
389
  ))}
390
- </ul>
391
- )}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
392
  </div>
393
  );
394
  }
 
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",
 
 
 
 
 
21
  };
22
 
 
 
 
 
 
 
 
 
 
 
 
23
  /**
24
+ * ⭐⭐ THE MODEL NAMES THE OWNER PICKS FROM (owner item 2, 2026-08-15). Verbatim: *"the user can
25
+ * choose which model to use since we have a selections of them."*
26
  *
27
+ * β›” THE WIRE VALUE IS THE PROVIDER KEY AND MUST STAY THE PROVIDER KEY. `routes_query.model_choices`
28
+ * derives the list from `QUERY_PROVIDER_ORDER`, and `_call_model` looks the choice up in
29
+ * `harness.analyst.PROVIDERS` by that exact name. This map is a DISPLAY name only, applied to
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
 
47
  /**
48
+ * R9's provenance, as a DISCLOSURE. Every clause the ruling requires is present β€” sources,
49
+ * fields, filters, aggregation, contributing record count, retrieval time, each a link to the
50
+ * exact artefact β€” but shut by default, because a chat that prints its own audit trail under
51
+ * every sentence is not the "plain look" the owner asked for and is not readable as an answer.
 
 
 
52
  */
53
+ 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 (
64
+ <article className="as-message as-message--user">
65
+ <p className="as-bubble">{message.content}</p>
66
+ </article>
 
67
  );
68
  }
69
+ const numeric = message.numeric;
70
  return (
71
+ <article className={"as-message as-message--assistant" + (message.reason ? " is-refusal" : "")}>
72
+ <p className="as-message-text">{message.content}</p>
73
+ {numeric && numeric.value !== null && numeric.value !== undefined ? (
74
+ <p className="as-numeric">
75
+ <span className="as-numeric-label">{numeric.label}</span>
76
+ <strong className="as-numeric-value">{numeric.value.toLocaleString()}</strong>
 
 
 
 
 
 
 
 
 
 
77
  </p>
78
  ) : null}
79
+ {view ? (
80
+ <button type="button" className="as-preview" onClick={() => onPreview(view.id)}>
81
+ <span className="as-preview-name">{view.name}</span>
82
+ {/* ⚠ ONE expression, not three text nodes. `Open the {x} in Query` renders under SSR as
83
+ `Open the<!-- -->table<!-- --> in Query`, so a check reading the sentence back sees
84
+ a string nobody wrote [[ui-invisible-to-assertions]]. */}
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}
91
+ onClick={() => setOpenSources(!openSources)}>
92
+ {openSources ? "Hide sources" : "Sources"}
93
+ </button>
94
+ {openSources ? (
95
+ <div className="as-sources-list">
96
+ {matching.map((citation) => <CitationLine key={citation.id} citation={citation} />)}
97
+ </div>
98
+ ) : null}
99
+ </div>
100
+ ) : null}
101
+ </article>
102
  );
103
  }
104
 
105
+ function initialIndex(): QueryIndex {
106
+ return { threads: [], messages: [], views: [], citations: [], models: ["auto"], sources: [] };
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  }
108
 
109
+ /**
110
+ * ⭐⭐ OWNER ITEM 2 (2026-08-15) β€” A PLAIN CHATBOT, IN THREE BORROWED SHAPES.
111
+ *
112
+ * Verbatim: *"AI assistant module should be a plain chatbot interface where the user can choose
113
+ * which model to use… I really like the plain look for Glean… Notice how the database you can
114
+ * select is at the bottom there with all the logos, that's how we should allow user to select
115
+ * databases too… The interface should exactly be like ChatGPT… ALSO I want a navigation for the
116
+ * chat EXACTLY like how Airtable does it."*
117
+ *
118
+ * So the surface is assembled from the three references the owner supplied, each for the part it
119
+ * is actually about:
120
+ * Β· `reference/ChatGPT 1.png` β€” the CONVERSATION: one centred column, the question as a bubble
121
+ * on the right, the answer as plain flowing text with no label and no bubble, the composer
122
+ * pinned to the bottom with its controls INSIDE the field.
123
+ * Β· `reference/Glean 1.png` β€” the SOURCES: a row of chips carrying each source's own mark,
124
+ * directly under the composer. Not a dropdown, not a settings panel.
125
+ * Β· `reference/Airtable AI 1.png` β€” the NAVIGATION: "New chat" at the top of a left panel, the
126
+ * thread list under it, and a centred "How can I help?" when the thread is empty.
127
+ *
128
+ * β›” WHAT IS DELIBERATELY NOT COPIED: ChatGPT's dark canvas and Glean's vendor logos. The palette
129
+ * is Loopable's own (`DESIGN.md`, and the font-token rule is gated app-wide over every CSS file
130
+ * in this tree), and the chips wear `FolderMark` β€” the SAME mark the nav rail draws for that
131
+ * database β€” because a brand logo for someone else's product is what those references have and
132
+ * this product's databases are not brands. "Exactly like" is about the interaction shape.
133
+ *
134
+ * ⚠ AND THE TOOL BOUNDARY IS NARROWER THAN THE SENTENCE "the chatbot can basically call the tools
135
+ * in our App". Under ruling R1 the assistant operates the caller's permitted database/view tools
136
+ * and nothing else: server-side there is exactly ONE tool, `build_view`. It cannot run an
137
+ * automation, touch a connector, or change a setting, and no part of this page implies it can.
138
+ */
139
  export default function AssistantPage({ granted }: AssistantPageProps) {
140
+ const [index, setIndex] = useState<QueryIndex>(initialIndex);
141
+ const [activeThread, setActiveThread] = useState("");
142
+ const [selected, setSelected] = useState<string[]>([]);
143
+ const [target, setTarget] = useState("");
144
+ const [model, setModel] = useState("auto");
145
  const [question, setQuestion] = useState("");
146
  const [busy, setBusy] = useState(false);
 
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]);
155
  /**
156
+ * ⭐⭐ WHICH CHIPS CAN ACTUALLY BE ASKED, and why the ones that cannot say so.
157
+ * The nav grants twelve databases on tenant #0 and the read boundary can answer for two: the
158
+ * ten `ut_odoo_*` grids are served through the connector mirror, which the Assistant refuses by
159
+ * design. Offering ten doors that cannot open β€” and letting the reader discover it one spent
160
+ * prompt at a time β€” is the same defect shape as a rail that paints two rows differently from
161
+ * five. ⚠ Absent from the map β‡’ TREATED AS ANSWERABLE: the server is the authority and its
162
+ * refusal is still the wall; a client that greyed out everything it had not heard about would
163
+ * hide a working source the moment this field failed to arrive.
164
  */
165
+ const sourceStatus = useMemo(
166
+ () => new Map(index.sources.map((row) => [row.database, row])), [index.sources]);
167
+ const blockedReason = useCallback(
168
+ (key: string) => (sourceStatus.get(key)?.answerable === false
169
+ ? sourceStatus.get(key)?.reason || "this database cannot be asked yet" : ""),
170
+ [sourceStatus]);
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
 
183
+ useEffect(() => { void reload(); }, [reload]);
184
+ useEffect(() => () => openCancel.current?.(), []);
185
  useEffect(() => {
186
+ const permitted = databases.map((entry) => entry.key);
187
+ setSelected((current) => current.filter((key) => permitted.includes(key)));
188
+ setTarget((current) => permitted.includes(current) ? current : "");
189
+ }, [databases]);
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
+ }, []);
196
+
197
+ /** Opening a past chat restores the sources and the model it ran with (R6). */
198
+ const openThread = useCallback((id: string) => {
199
+ setActiveThread(id); setProblem("");
200
+ const row = index.threads.find((thread) => thread.id === id);
201
+ if (!row) return;
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;
210
+ setSelected((current) => {
211
+ const next = current.includes(key) ? current.filter((item) => item !== key) : [...current, key];
212
+ setTarget((currentTarget) => next.includes(currentTarget) ? currentTarget : next[0] || "");
213
+ return next;
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
+ }));
234
+ }, [activeThread, busy, model, question, selected, target]);
235
 
236
+ /** β›” The chat goes; the views it built STAY (they live in Query). Two-click, in the row. */
237
+ const removeThread = useCallback(async (id: string) => {
238
+ const result = await deleteThread(id);
239
+ if (!result.ok) { setProblem(result.message); return; }
240
+ setIndex((current) => ({
241
+ ...current,
242
+ threads: current.threads.filter((row) => row.id !== id),
243
+ messages: current.messages.filter((row) => row.threadId !== id),
244
+ }));
245
+ setActiveThread((current) => (current === id ? "" : current));
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">
257
+ <div className="as-field">
258
+ <textarea
259
+ className="as-prompt"
260
+ value={question}
261
+ rows={1}
262
+ placeholder={target ? `Ask about ${labels.get(target) || target}…` : "Pick a database below, then ask…"}
263
+ aria-label="Ask the assistant"
264
+ onChange={(event) => setQuestion(event.currentTarget.value)}
265
+ onKeyDown={(event) => {
266
+ if (event.key === "Enter" && !event.shiftKey) { event.preventDefault(); void ask(); }
267
+ }}
268
+ />
269
+ <div className="as-field-foot">
270
+ {/* ChatGPT puts its model control inside the field, as text rather than a boxed
271
+ form control. A native `select` keeps the keyboard and screen-reader behaviour
272
+ a hand-rolled popover would have to re-earn. */}
273
+ <label className="as-pick">
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);
283
+ with a single source selected that database is not a question, so asking it would
284
+ be chrome for its own sake. */}
285
+ {selected.length > 1 ? (
286
+ <label className="as-pick">
287
+ <span className="as-pick-label">Build on</span>
288
+ <select className="as-pick-select" value={target} aria-label="Target database"
289
+ onChange={(event) => setTarget(event.currentTarget.value)}>
290
+ {selected.map((key) => <option key={key} value={key}>{labels.get(key) || key}</option>)}
291
+ </select>
292
+ </label>
293
+ ) : null}
294
+ <button type="button" className="as-send" aria-label="Send"
295
+ disabled={busy || !question.trim() || !target} onClick={() => void ask()}>
296
+ <svg viewBox="0 0 16 16" aria-hidden="true" width="15" height="15">
297
+ <path d="M8 13V3.6M4 7.4 8 3.4l4 4" />
298
+ </svg>
 
 
299
  </button>
300
+ </div>
301
+ </div>
302
+ {/* Glean's row: the sources, with their marks, under the field. */}
303
+ <div className="as-chips" role="group" aria-label="Databases the assistant may read">
304
+ {databases.map((database) => {
305
+ const on = selected.includes(database.key);
306
+ const blocked = blockedReason(database.key);
307
+ return (
308
+ <button type="button" key={database.key}
309
+ className={"as-chip" + (on ? " is-selected" : "")
310
+ + (database.key === target ? " is-target" : "") + (blocked ? " is-blocked" : "")}
311
+ aria-pressed={on}
312
+ aria-disabled={blocked ? true : undefined}
313
+ title={blocked || undefined}
314
+ onClick={() => toggleSource(database.key)}>
315
+ <span className="as-chip-mark" aria-hidden="true">
316
+ {database.icon ? <FolderMark icon={database.icon} size={14} /> : <DbIcon />}
317
+ </span>
318
+ <span className="as-chip-name">{database.label}</span>
319
+ </button>
320
+ );
321
+ })}
322
+ {/* ⚠ ONE sentence for the whole row, not one per chip: with ten blocked sources the
323
+ per-chip copy would be the loudest thing on a surface whose whole instruction was
324
+ "plain". The chip carries the exact cause in its `title`. */}
325
+ {databases.some((database) => blockedReason(database.key)) ? (
326
+ <p className="as-chips-note">
327
+ Greyed databases are served live from a connector and cannot be asked yet.
328
+ </p>
329
+ ) : null}
330
+ {databases.length === 0 && loaded ? (
331
+ <p className="as-chips-empty">No databases are shared with your account yet.</p>
332
+ ) : null}
333
+ </div>
334
+ </div>
335
+ );
336
 
337
+ return (
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" />
344
+ </svg>
345
+ New chat
346
+ </button>
347
+ <nav className="as-thread-list" aria-label="Chat history">
348
+ {index.threads.map((row) => (
349
+ <div className={"as-thread-row" + (row.id === activeThread ? " is-active" : "")} key={row.id}>
350
+ <button type="button" className="as-thread"
351
+ onClick={() => { setConfirmThread(""); openThread(row.id); }}
352
+ title={row.title}>{row.title}</button>
353
+ {confirmThread === row.id ? (
354
+ <button type="button" className="as-thread-confirm"
355
+ onClick={() => void removeThread(row.id)}>Delete</button>
356
+ ) : (
357
+ <button type="button" className="as-thread-del" aria-label={`Delete chat: ${row.title}`}
358
+ onClick={() => setConfirmThread(row.id)}>
359
+ <svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13">
360
+ <path d="M3.5 4.5h9M6.5 4.5V3.2h3v1.3M5 4.5l.6 8h4.8l.6-8" />
361
+ </svg>
362
+ </button>
363
+ )}
364
+ </div>
365
  ))}
366
+ {loaded && index.threads.length === 0 ? (
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">
376
+ Ask about a database you can already open. The answer arrives as a view in Query.
377
+ </p>
378
+ {composer}
379
+ {problem ? <p className="as-problem" role="status">{problem}</p> : null}
380
+ </div>
381
+ ) : (
382
+ <>
383
+ <div className="as-stream">
384
+ <div className="as-column">
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}
395
+ <div ref={foot} />
396
+ </div>
397
+ </div>
398
+ <div className="as-dock">
399
+ <div className="as-column">
400
+ {problem ? <p className="as-problem" role="status">{problem}</p> : null}
401
+ {composer}
402
+ </div>
403
+ </div>
404
+ </>
405
+ )}
406
+ </main>
407
  </div>
408
  );
409
  }
web/src/assistant/assistant.css CHANGED
@@ -1,280 +1,486 @@
1
  /* ---------------------------------------------------------------------------
2
- assistant/assistant.css β€” the AI assistant's own stylesheet (owner item 2,
3
- 2026-08-14).
4
 
5
- ⭐ THESE RULES ARE `query.css`'s ASK/ANSWER/LIST RULES, MOVED β€” not copied and
6
- not re-invented. The ask box left the Query module when Query became the
7
- database frame the owner asked for, and the styling went with the markup it
8
- styles. `query.css` keeps only what the new Query page still draws. A copy in
9
- both files would be two places for one look to drift.
10
 
11
- Shipped BESIDE its component (the `FormInterface.css` / `query.css`
12
- precedent) rather than added to `index.css`. Vite carries the import;
13
- `verify_ui`'s node shim already stubs the CSS require for exactly this shape.
14
-
15
- β›” EVERY FONT SIZE IS A `--lp-fs-*` TOKEN AND EVERY COLOUR A `--lp-*` TOKEN.
16
  Wave-21 R5 is gated app-wide over every CSS file under src, and a literal
17
  here reds `web_ui` for the whole tree. No emojis, no ALL-CAPS chrome
18
  (DESIGN.md R6).
19
 
20
- β›”β›” AND DO NOT WRITE A GLOB IN THIS COMMENT. A star-star followed by a slash
21
- spells the CSS COMMENT TERMINATOR, so the comment would end mid-sentence,
22
- every line after it would become stray tokens, and `lightningcss` dies on the
23
- first backtick it then meets. `tsc` passes and `vite` transforms every module
24
- β€” the build fails in the MINIFIER, nowhere near the sentence that caused it.
25
- That cost wave 32 a deploy.
26
  --------------------------------------------------------------------------- */
27
 
28
- .as-page {
 
 
29
  height: 100%;
30
- overflow-y: auto;
31
- padding: 34px 44px 56px;
32
- box-sizing: border-box;
33
- background: var(--lp-wash);
34
  }
35
 
36
- .as-title {
37
- display: flex;
38
- align-items: center;
39
- gap: 9px;
40
- margin: 0 0 6px;
41
- font-size: var(--lp-fs-lg);
42
- font-weight: 650;
43
- color: var(--lp-ink);
44
- }
45
 
46
- .as-lede {
47
- margin: 0 0 22px;
48
- max-width: 62ch;
49
- font-size: var(--lp-fs-sm);
50
- line-height: var(--lp-lh);
51
- color: var(--lp-muted);
 
 
 
 
 
 
52
  }
53
 
54
- .as-sub {
55
- margin: 30px 0 12px;
56
- font-size: var(--lp-fs-md);
57
- font-weight: 650;
 
 
 
 
 
 
58
  color: var(--lp-ink);
 
 
 
 
 
59
  }
60
 
61
- /* ── the ask row ─────────────────────────────────────────────────────────── */
62
 
63
- .as-ask {
64
- display: flex;
65
- flex-wrap: wrap;
66
- align-items: flex-end;
67
- gap: 12px;
68
- max-width: 940px;
69
  }
70
 
71
- .as-field {
 
 
 
 
72
  display: flex;
73
  flex-direction: column;
74
- gap: 5px;
75
- min-width: 180px;
76
- }
77
-
78
- .as-field--grow {
79
- flex: 1 1 380px;
80
- }
81
-
82
- .as-label {
83
- font-size: var(--lp-fs-2xs);
84
- font-weight: 600;
85
- color: var(--lp-muted);
86
  }
87
 
88
- .as-select,
89
- .as-input {
90
- box-sizing: border-box;
91
- width: 100%;
92
- padding: 8px 11px;
93
- border: 1px solid var(--lp-line);
94
  border-radius: var(--lp-r-md);
95
- background: var(--lp-surface);
96
- color: var(--lp-ink);
97
- font-size: var(--lp-fs-sm);
98
- font-family: inherit;
99
  }
100
 
101
- .as-select:focus-visible,
102
- .as-input:focus-visible {
103
- outline: 2px solid var(--lp-blue-solid);
104
- outline-offset: 1px;
105
- }
106
 
107
- /* ── buttons ─────────────────────────────────────────────────────────────── */
108
-
109
- .as-btn {
110
- padding: 8px 14px;
111
- border: 1px solid var(--lp-line);
 
112
  border-radius: var(--lp-r-md);
113
- background: var(--lp-surface);
114
  color: var(--lp-ink);
115
- font-size: var(--lp-fs-sm);
116
- font-family: inherit;
117
- font-weight: 600;
 
 
 
118
  cursor: pointer;
119
  }
120
 
121
- .as-btn:hover:not(:disabled) {
122
- background: var(--lp-surface-2);
 
 
 
 
 
 
 
 
 
 
 
 
123
  }
124
 
125
- .as-btn:disabled {
126
- opacity: 0.55;
127
- cursor: default;
 
 
 
128
  }
129
 
130
- .as-btn--primary {
131
- border-color: transparent;
132
- background: var(--lp-blue-solid);
 
 
 
 
 
 
 
 
133
  color: var(--lp-surface);
 
 
 
 
134
  }
135
 
136
- .as-btn--primary:hover:not(:disabled) {
137
- background: color-mix(in srgb, var(--lp-blue-solid) 82%, #000);
 
 
138
  }
139
 
140
- .as-btn--quiet {
141
- border-color: transparent;
142
- background: transparent;
143
- color: var(--lp-muted);
144
- font-weight: 500;
 
 
 
 
145
  }
146
 
147
- .as-btn--quiet:hover {
148
- color: var(--lp-red-deep);
149
- background: transparent;
 
 
 
 
 
150
  }
151
 
152
- /* ── the answer, and the read-back that is the point of it ───────────────── */
 
 
 
 
 
153
 
154
- .as-answer {
155
- max-width: 940px;
156
- margin: 20px 0 0;
157
- padding: 16px 18px;
158
- border: 1px solid var(--lp-line);
159
- border-left: 3px solid var(--lp-blue-solid);
160
- border-radius: var(--lp-r-md);
161
  background: var(--lp-surface);
162
  }
163
 
164
- .as-answer.is-refused {
165
- border-left-color: var(--lp-yellow-deep);
 
 
 
 
 
 
 
 
166
  }
167
 
168
- .as-answer-head {
169
  margin: 0 0 6px;
170
- font-size: var(--lp-fs-2xs);
 
171
  font-weight: 650;
172
- color: var(--lp-muted);
173
  }
174
 
175
- .as-answer-body {
176
- margin: 0;
177
- max-width: 78ch;
 
178
  font-size: var(--lp-fs-sm);
179
  line-height: var(--lp-lh);
180
- color: var(--lp-ink);
181
  }
182
 
183
- /* The backup-model notice (W33-T50). ⚠ Deliberately a CAUTION and not an alarm: the answer is
184
- usable, it just deserves a closer read, and `is-refused` one rule up already owns the stronger
185
- voice with the same yellow. Sized down and muted so it reads as a footnote to the read-back
186
- rather than competing with it β€” DESIGN.md's "never over-explain" applied to a warning. */
187
- .as-answer-note {
188
- margin: 8px 0 0;
189
- max-width: 78ch;
190
- font-size: var(--lp-fs-2xs);
191
- line-height: var(--lp-lh);
192
- color: var(--lp-yellow-deep);
193
  }
194
 
195
- .as-answer-acts {
 
 
 
 
196
  display: flex;
197
- gap: 10px;
198
- margin-top: 14px;
199
  }
200
 
201
- /* ── the list of what the assistant has built ────────────────────────────── */
 
 
 
 
 
 
 
 
 
 
 
202
 
203
- .as-note {
204
- max-width: 62ch;
205
- margin: 14px 0 0;
 
 
 
206
  font-size: var(--lp-fs-sm);
207
  line-height: var(--lp-lh);
208
- color: var(--lp-muted);
 
209
  }
210
 
211
- .as-note.is-warn {
212
- color: var(--lp-red-deep);
 
 
 
 
 
213
  }
214
 
215
- .as-list {
 
 
 
216
  display: flex;
217
  flex-direction: column;
218
- gap: 10px;
219
- max-width: 940px;
220
- margin: 0;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  padding: 0;
222
- list-style: none;
 
 
 
 
 
 
223
  }
224
 
225
- .as-row {
226
- padding: 14px 16px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
227
  border: 1px solid var(--lp-line);
228
- border-radius: var(--lp-r-md);
229
  background: var(--lp-surface);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
230
  }
231
 
232
- .as-row-main {
 
 
233
  display: flex;
234
- flex-wrap: wrap;
235
- align-items: baseline;
236
- gap: 10px;
237
  }
238
 
239
- .as-row-open {
240
- padding: 0;
 
 
 
 
 
 
 
 
 
 
241
  border: 0;
242
  background: transparent;
243
- color: var(--lp-blue-solid);
244
- font-size: var(--lp-fs-md);
245
- font-family: inherit;
246
- font-weight: 650;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
247
  cursor: pointer;
248
- text-align: left;
249
  }
250
 
251
- .as-row-open:hover {
252
- text-decoration: underline;
 
 
 
 
 
 
 
 
 
253
  }
254
 
255
- .as-row-meta {
 
 
 
 
 
 
 
 
 
 
256
  font-size: var(--lp-fs-2xs);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
257
  color: var(--lp-muted);
258
  }
259
 
260
- .as-row-q {
261
- margin: 6px 0 0;
262
- max-width: 78ch;
263
- font-size: var(--lp-fs-sm);
264
- font-style: italic;
265
- color: var(--lp-ink);
 
 
266
  }
267
 
268
- .as-row-explain {
269
- margin: 4px 0 0;
270
- max-width: 78ch;
271
- font-size: var(--lp-fs-2xs);
272
- line-height: var(--lp-lh);
 
 
 
 
 
 
 
273
  color: var(--lp-muted);
 
 
 
 
274
  }
275
 
276
- .as-row-acts {
277
- display: flex;
278
- gap: 8px;
279
- margin-top: 12px;
 
 
 
 
 
 
 
 
 
 
280
  }
 
1
  /* ---------------------------------------------------------------------------
2
+ assistant/assistant.css β€” the AI assistant's chat surface (owner item 2).
 
3
 
4
+ The shape is borrowed, reference by reference: ChatGPT's centred column and
5
+ in-field composer, Glean's source chips under the field, Airtable's "New
6
+ chat" panel. The PALETTE is Loopable's own β€” see AssistantPage.tsx's note on
7
+ why the dark canvas and the vendor logos are the parts not copied.
 
8
 
9
+ EVERY FONT SIZE IS A `--lp-fs-*` TOKEN AND EVERY COLOUR A `--lp-*` TOKEN.
 
 
 
 
10
  Wave-21 R5 is gated app-wide over every CSS file under src, and a literal
11
  here reds `web_ui` for the whole tree. No emojis, no ALL-CAPS chrome
12
  (DESIGN.md R6).
13
 
14
+ AND DO NOT WRITE A GLOB IN THIS COMMENT: a star-star followed by a slash is
15
+ the CSS comment terminator, and `lightningcss` then dies far from the cause.
 
 
 
 
16
  --------------------------------------------------------------------------- */
17
 
18
+ .as-chat {
19
+ display: flex;
20
+ width: 100%;
21
  height: 100%;
22
+ min-width: 0;
23
+ overflow: hidden;
24
+ background: var(--lp-surface);
 
25
  }
26
 
27
+ /* ── Airtable's panel: New chat, then the history ─────────────────────────── */
 
 
 
 
 
 
 
 
28
 
29
+ .as-history {
30
+ flex: 0 0 var(--lp-rail-w);
31
+ width: var(--lp-rail-w);
32
+ min-width: var(--lp-rail-w);
33
+ height: 100%;
34
+ box-sizing: border-box;
35
+ display: flex;
36
+ flex-direction: column;
37
+ padding: 10px 8px;
38
+ border-right: 1px solid var(--lp-line);
39
+ background: var(--lp-surface);
40
+ overflow: hidden;
41
  }
42
 
43
+ .as-new {
44
+ flex: 0 0 auto;
45
+ display: flex;
46
+ align-items: center;
47
+ gap: 8px;
48
+ min-height: 34px;
49
+ padding: 7px 10px;
50
+ border: 1px solid var(--lp-line);
51
+ border-radius: var(--lp-r-md);
52
+ background: var(--lp-surface);
53
  color: var(--lp-ink);
54
+ font: inherit;
55
+ font-size: var(--lp-fs-xs);
56
+ font-weight: 600;
57
+ text-align: left;
58
+ cursor: pointer;
59
  }
60
 
61
+ .as-new:hover { background: var(--lp-surface-2); }
62
 
63
+ .as-new svg {
64
+ fill: none;
65
+ stroke: currentColor;
66
+ stroke-width: 1.6;
67
+ stroke-linecap: round;
 
68
  }
69
 
70
+ .as-thread-list {
71
+ flex: 1 1 auto;
72
+ min-height: 0;
73
+ margin-top: 10px;
74
+ overflow-y: auto;
75
  display: flex;
76
  flex-direction: column;
77
+ gap: 1px;
 
 
 
 
 
 
 
 
 
 
 
78
  }
79
 
80
+ .as-thread-row {
81
+ display: flex;
82
+ align-items: center;
83
+ min-width: 0;
 
 
84
  border-radius: var(--lp-r-md);
 
 
 
 
85
  }
86
 
87
+ .as-thread-row:hover { background: var(--lp-surface-2); }
88
+ .as-thread-row.is-active { background: var(--lp-blue-tint); }
89
+ .as-thread-row.is-active .as-thread { font-weight: 600; }
 
 
90
 
91
+ .as-thread {
92
+ flex: 1 1 auto;
93
+ min-width: 0;
94
+ min-height: 30px;
95
+ padding: 6px 10px;
96
+ border: 0;
97
  border-radius: var(--lp-r-md);
98
+ background: transparent;
99
  color: var(--lp-ink);
100
+ font: inherit;
101
+ font-size: var(--lp-fs-xs);
102
+ text-align: left;
103
+ overflow: hidden;
104
+ text-overflow: ellipsis;
105
+ white-space: nowrap;
106
  cursor: pointer;
107
  }
108
 
109
+ .as-thread-del {
110
+ flex: 0 0 auto;
111
+ width: 24px;
112
+ height: 24px;
113
+ margin-right: 4px;
114
+ display: inline-flex;
115
+ align-items: center;
116
+ justify-content: center;
117
+ border: 0;
118
+ border-radius: 6px;
119
+ background: transparent;
120
+ color: var(--lp-muted);
121
+ opacity: 0;
122
+ cursor: pointer;
123
  }
124
 
125
+ .as-thread-del svg {
126
+ fill: none;
127
+ stroke: currentColor;
128
+ stroke-width: 1.3;
129
+ stroke-linecap: round;
130
+ stroke-linejoin: round;
131
  }
132
 
133
+ .as-thread-row:hover .as-thread-del,
134
+ .as-thread-row.is-active .as-thread-del,
135
+ .as-thread-del:focus-visible { opacity: 1; }
136
+
137
+ .as-thread-confirm {
138
+ flex: 0 0 auto;
139
+ margin-right: 4px;
140
+ padding: 3px 8px;
141
+ border: 0;
142
+ border-radius: 6px;
143
+ background: var(--lp-red-deep);
144
  color: var(--lp-surface);
145
+ font: inherit;
146
+ font-size: var(--lp-fs-2xs);
147
+ font-weight: 600;
148
+ cursor: pointer;
149
  }
150
 
151
+ .as-thread-empty {
152
+ margin: 8px 10px 0;
153
+ color: var(--lp-muted);
154
+ font-size: var(--lp-fs-2xs);
155
  }
156
 
157
+ /* ── ChatGPT's column ─────────────────────────────────────────────────────── */
158
+
159
+ .as-conversation {
160
+ flex: 1 1 auto;
161
+ min-width: 0;
162
+ height: 100%;
163
+ display: flex;
164
+ flex-direction: column;
165
+ overflow: hidden;
166
  }
167
 
168
+ /* One measure, used by the stream, the dock and the opening state, so the
169
+ composer does not shift sideways when the first answer arrives. */
170
+ .as-column {
171
+ width: 100%;
172
+ max-width: 760px;
173
+ margin: 0 auto;
174
+ padding: 0 24px;
175
+ box-sizing: border-box;
176
  }
177
 
178
+ .as-stream {
179
+ flex: 1 1 auto;
180
+ min-height: 0;
181
+ overflow-y: auto;
182
+ padding: 26px 0 8px;
183
+ }
184
 
185
+ .as-dock {
186
+ flex: 0 0 auto;
187
+ padding: 6px 0 18px;
 
 
 
 
188
  background: var(--lp-surface);
189
  }
190
 
191
+ .as-opening {
192
+ flex: 1 1 auto;
193
+ min-height: 0;
194
+ display: flex;
195
+ flex-direction: column;
196
+ align-items: center;
197
+ justify-content: center;
198
+ padding: 24px;
199
+ box-sizing: border-box;
200
+ text-align: center;
201
  }
202
 
203
+ .as-opening-h {
204
  margin: 0 0 6px;
205
+ color: var(--lp-ink);
206
+ font-size: var(--lp-fs-lg);
207
  font-weight: 650;
 
208
  }
209
 
210
+ .as-opening-p {
211
+ max-width: 52ch;
212
+ margin: 0 0 20px;
213
+ color: var(--lp-muted);
214
  font-size: var(--lp-fs-sm);
215
  line-height: var(--lp-lh);
 
216
  }
217
 
218
+ .as-opening .as-composer {
219
+ width: 100%;
220
+ max-width: 700px;
221
+ text-align: left;
 
 
 
 
 
 
222
  }
223
 
224
+ /* ── the messages ─────────────────────────────────────────────────────────── */
225
+
226
+ .as-message { margin: 0 0 22px; }
227
+
228
+ .as-message--user {
229
  display: flex;
230
+ justify-content: flex-end;
 
231
  }
232
 
233
+ .as-bubble {
234
+ max-width: 78%;
235
+ margin: 0;
236
+ padding: 9px 14px;
237
+ border-radius: 18px;
238
+ background: var(--lp-surface-2);
239
+ color: var(--lp-ink);
240
+ font-size: var(--lp-fs-sm);
241
+ line-height: var(--lp-lh);
242
+ white-space: pre-wrap;
243
+ overflow-wrap: anywhere;
244
+ }
245
 
246
+ /* No bubble and no "Assistant" label: the reference prints the answer as the
247
+ page's own prose, and a role label on every turn is the thing that makes a
248
+ chat read like a transcript instead of a reply. */
249
+ .as-message-text {
250
+ margin: 0;
251
+ color: var(--lp-ink);
252
  font-size: var(--lp-fs-sm);
253
  line-height: var(--lp-lh);
254
+ white-space: pre-wrap;
255
+ overflow-wrap: anywhere;
256
  }
257
 
258
+ .as-message--assistant.is-refusal .as-message-text { color: var(--lp-muted); }
259
+
260
+ .as-numeric {
261
+ display: flex;
262
+ align-items: baseline;
263
+ gap: 8px;
264
+ margin: 10px 0 0;
265
  }
266
 
267
+ .as-numeric-label { color: var(--lp-muted); font-size: var(--lp-fs-2xs); }
268
+ .as-numeric-value { color: var(--lp-ink); font-size: var(--lp-fs-md); font-weight: 650; }
269
+
270
+ .as-preview {
271
  display: flex;
272
  flex-direction: column;
273
+ gap: 2px;
274
+ margin-top: 12px;
275
+ padding: 9px 13px;
276
+ border: 1px solid var(--lp-line);
277
+ border-radius: var(--lp-r-md);
278
+ background: var(--lp-surface);
279
+ font: inherit;
280
+ text-align: left;
281
+ cursor: pointer;
282
+ }
283
+
284
+ .as-preview:hover { background: var(--lp-surface-2); }
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 {
291
  padding: 0;
292
+ border: 0;
293
+ background: transparent;
294
+ color: var(--lp-muted);
295
+ font: inherit;
296
+ font-size: var(--lp-fs-2xs);
297
+ font-weight: 600;
298
+ cursor: pointer;
299
  }
300
 
301
+ .as-sources-btn:hover { color: var(--lp-ink); }
302
+
303
+ .as-sources-list {
304
+ display: flex;
305
+ flex-direction: column;
306
+ gap: 4px;
307
+ margin-top: 6px;
308
+ }
309
+
310
+ .as-citation {
311
+ color: var(--lp-muted);
312
+ font-size: var(--lp-fs-2xs);
313
+ line-height: var(--lp-lh);
314
+ text-decoration: none;
315
+ overflow-wrap: anywhere;
316
+ }
317
+
318
+ .as-citation:hover { color: var(--lp-blue-solid); text-decoration: underline; }
319
+
320
+ .as-working { margin: 0 0 22px; }
321
+
322
+ .as-problem {
323
+ margin: 0 0 10px;
324
+ color: var(--lp-red-deep);
325
+ font-size: var(--lp-fs-2xs);
326
+ }
327
+
328
+ /* ── the composer ─────────────────────────────────────────────────────────── */
329
+
330
+ .as-field {
331
  border: 1px solid var(--lp-line);
332
+ border-radius: 22px;
333
  background: var(--lp-surface);
334
+ padding: 10px 8px 8px 16px;
335
+ box-shadow: 0 1px 2px rgba(20, 24, 40, 0.05);
336
+ }
337
+
338
+ .as-field:focus-within { border-color: var(--lp-blue-solid); }
339
+
340
+ .as-prompt {
341
+ display: block;
342
+ width: 100%;
343
+ max-height: 180px;
344
+ border: 0;
345
+ outline: none;
346
+ padding: 0 8px 0 0;
347
+ background: transparent;
348
+ color: var(--lp-ink);
349
+ font: inherit;
350
+ font-size: var(--lp-fs-sm);
351
+ line-height: var(--lp-lh);
352
+ resize: none;
353
+ box-sizing: border-box;
354
  }
355
 
356
+ .as-prompt::placeholder { color: var(--lp-muted); }
357
+
358
+ .as-field-foot {
359
  display: flex;
360
+ align-items: center;
361
+ gap: 12px;
362
+ margin-top: 8px;
363
  }
364
 
365
+ .as-pick {
366
+ display: inline-flex;
367
+ align-items: center;
368
+ gap: 5px;
369
+ min-width: 0;
370
+ }
371
+
372
+ .as-pick-label { color: var(--lp-muted); font-size: var(--lp-fs-2xs); }
373
+
374
+ /* Text, not a boxed control β€” ChatGPT's "Instant" reads as part of the field. */
375
+ .as-pick-select {
376
+ max-width: 150px;
377
  border: 0;
378
  background: transparent;
379
+ color: var(--lp-ink);
380
+ font: inherit;
381
+ font-size: var(--lp-fs-2xs);
382
+ font-weight: 600;
383
+ cursor: pointer;
384
+ }
385
+
386
+ .as-send {
387
+ flex: 0 0 auto;
388
+ margin-left: auto;
389
+ width: 30px;
390
+ height: 30px;
391
+ display: inline-flex;
392
+ align-items: center;
393
+ justify-content: center;
394
+ border: 0;
395
+ border-radius: 999px;
396
+ background: var(--lp-ink);
397
+ color: var(--lp-surface);
398
  cursor: pointer;
 
399
  }
400
 
401
+ .as-send svg { fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
402
+ .as-send:disabled { background: var(--lp-surface-2); color: var(--lp-muted); cursor: default; }
403
+
404
+ /* ── Glean's chips ────────────────────────────────────────────────────────── */
405
+
406
+ .as-chips {
407
+ display: flex;
408
+ flex-wrap: wrap;
409
+ gap: 6px;
410
+ margin-top: 10px;
411
+ padding: 0 6px;
412
  }
413
 
414
+ .as-chip {
415
+ display: inline-flex;
416
+ align-items: center;
417
+ gap: 6px;
418
+ max-width: 220px;
419
+ padding: 5px 11px;
420
+ border: 1px solid transparent;
421
+ border-radius: 999px;
422
+ background: var(--lp-surface-2);
423
+ color: var(--lp-ink);
424
+ font: inherit;
425
  font-size: var(--lp-fs-2xs);
426
+ font-weight: 500;
427
+ cursor: pointer;
428
+ }
429
+
430
+ .as-chip:hover { border-color: var(--lp-line); }
431
+ .as-chip.is-selected { background: var(--lp-blue-tint); border-color: var(--lp-blue-tint); font-weight: 600; }
432
+ /* The one the answer will be built on, when more than one is picked (R3). */
433
+ .as-chip.is-target { border-color: var(--lp-blue-solid); }
434
+
435
+ .as-chip-mark {
436
+ flex: 0 0 auto;
437
+ display: inline-flex;
438
+ width: 14px;
439
+ height: 14px;
440
  color: var(--lp-muted);
441
  }
442
 
443
+ .as-chip-mark svg {
444
+ width: 14px;
445
+ height: 14px;
446
+ fill: none;
447
+ stroke: currentColor;
448
+ stroke-width: 1.4;
449
+ stroke-linecap: round;
450
+ stroke-linejoin: round;
451
  }
452
 
453
+ .as-chip.is-selected .as-chip-mark { color: var(--lp-blue-solid); }
454
+
455
+ .as-chip-name {
456
+ min-width: 0;
457
+ overflow: hidden;
458
+ text-overflow: ellipsis;
459
+ white-space: nowrap;
460
+ }
461
+
462
+ /* A source this caller holds and cannot ask (see AssistantPage's note): still visible, because
463
+ an artefact already built on it stays listed β€” but plainly not a door. */
464
+ .as-chip.is-blocked {
465
  color: var(--lp-muted);
466
+ background: transparent;
467
+ border-color: var(--lp-line);
468
+ border-style: dashed;
469
+ cursor: default;
470
  }
471
 
472
+ .as-chip.is-blocked .as-chip-mark { opacity: 0.5; }
473
+ .as-chip.is-blocked:hover { border-color: var(--lp-line); }
474
+
475
+ .as-chips-note {
476
+ flex: 1 0 100%;
477
+ margin: 2px 0 0;
478
+ color: var(--lp-muted);
479
+ font-size: var(--lp-fs-2xs);
480
+ }
481
+
482
+ .as-chips-empty {
483
+ margin: 2px 0 0;
484
+ color: var(--lp-muted);
485
+ font-size: var(--lp-fs-2xs);
486
  }
web/src/customer-grid/CustomerGrid.tsx CHANGED
@@ -25,6 +25,9 @@ import "@glideapps/glide-data-grid/dist/index.css";
25
 
26
  import { useCustomerData } from "./useCustomerData";
27
  import type { SurfaceScope } from "./apiBridge";
 
 
 
28
  // ⭐ WAVE 30 Β· W30-T42 (contract C2) β€” the WINDOWED grid's arithmetic, pure and node-run in
29
  // `verify_grid_ux.py`, because every one of these decisions is taken inside a callback where
30
  // only its own source text could otherwise be checked.
@@ -353,6 +356,37 @@ function allRecordsView(fields: Field[], scope: string): SavedView {
353
  };
354
  }
355
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
356
  function readLocal(storageKey: string): LocalWorkspace | null {
357
  try {
358
  const raw = localStorage.getItem(`aios-grid:${storageKey}`);
@@ -559,6 +593,8 @@ function buildOverlayField(
559
  * re-renders for its own state (edits, resize) and remounts on route change via `key`. */
560
  interface CustomerGridProps {
561
  scope?: SurfaceScope;
 
 
562
  /** Read-only Grid view embedded in a linked-record modal. It keeps the standard
563
  * filter/sort/search toolbar while withdrawing schema and row mutations. */
564
  embedded?: boolean;
@@ -656,14 +692,62 @@ function LinkGridModal({ label, table, recordIds, editable = false, single = fal
656
  );
657
  }
658
 
659
- function CustomerGrid({
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
660
  scope = "customer",
 
661
  embedded = false,
662
  embeddedRecordIds,
663
  embeddedSelectable = false,
664
  embeddedSelectedIds = [],
665
  onEmbeddedSelectionChange,
666
  }: CustomerGridProps = {}) {
 
 
667
  // Wave 16 C-TOPIC: which TABLE this tree is drawing, derived from the one scope prop.
668
  const topic = topicForScope(scope);
669
  const {
@@ -676,9 +760,9 @@ function CustomerGrid({
676
  patchOverlay,
677
  requestWindow,
678
  } = useCustomerData(scope, {
679
- bindSurface: !embedded,
680
- includeWorkspace: !embedded,
681
- writable: !embedded,
682
  });
683
  const embeddedIdsKey = embeddedRecordIds?.join(",") ?? "";
684
  /**
@@ -780,7 +864,7 @@ function CustomerGrid({
780
  */
781
  const [alerted, setAlerted] = useState<string[]>([]);
782
  useEffect(() => {
783
- if (embedded) return;
784
  let live = true;
785
  const pull = () => {
786
  void fetchAlerts().then((r) => {
@@ -794,7 +878,7 @@ function CustomerGrid({
794
  live = false;
795
  window.removeEventListener("focus", pull);
796
  };
797
- }, [embedded, scope]);
798
 
799
  const [fields, setFields] = useState<Field[]>([]);
800
  const [views, setViews] = useState<SavedView[]>([]);
@@ -963,12 +1047,24 @@ function CustomerGrid({
963
  * ⚠ Existing browsers lose the contents of the old shared bucket. That is the point: every
964
  * byte in it is a workspace some other surface persisted, and there is no way to tell whose.
965
  */
966
- const storageKey = payload?.workspace?.storageKey ?? `local:${scope}`;
 
 
967
 
968
  // Initialize once per permission/data scope. Host state wins; local state
969
  // fills only missing objects and keeps the standalone path useful.
970
  useEffect(() => {
971
  if (!payload || payloadFields.length === 0 || initializedKey.current === storageKey) return;
 
 
 
 
 
 
 
 
 
 
972
  const local = embedded ? null : readLocal(storageKey);
973
  // Item 3c: the def half of the no-blip layer. A RECENT local stamp beats a
974
  // lagged host echo (rename survives, retype holds, a delete stays deleted);
@@ -1062,7 +1158,7 @@ function CustomerGrid({
1062
  * the config the client is actually filtering with, and sending anything else would ask the
1063
  * host to resolve a question nobody on screen is asking.
1064
  */
1065
- if (!embedded && reemit.size > 0) {
1066
  const stamped = { ...(local?.reemitted ?? {}) };
1067
  for (const view of initialViews) {
1068
  const key = reemit.get(view.id);
@@ -1077,10 +1173,10 @@ function CustomerGrid({
1077
  } else {
1078
  reemittedRef.current = { ...(local?.reemitted ?? {}) };
1079
  }
1080
- }, [payload, payloadFields, storageKey, embedded, scope]);
1081
 
1082
  useEffect(() => {
1083
- if (!workspaceReady || embedded) return;
1084
  writeLocal(storageKey, {
1085
  fields,
1086
  views,
@@ -1097,7 +1193,7 @@ function CustomerGrid({
1097
  // drop a view this browser created seconds ago.
1098
  viewWrites: pruneTombstones(viewWritesRef.current, Date.now()),
1099
  });
1100
- }, [workspaceReady, storageKey, fields, views, activeViewId, embedded]);
1101
 
1102
  /**
1103
  * ⭐ THE LIVE WORKSPACE (owner report, 2026-08-04) β€” what has APPEARED since we mounted.
@@ -1122,7 +1218,7 @@ function CustomerGrid({
1122
  */
1123
  const hostWorkspaceViews = payload?.workspace?.views;
1124
  useEffect(() => {
1125
- if (!workspaceReady || embedded) return;
1126
  const now = Date.now();
1127
  const nextFields = adoptNewFields(
1128
  fields, payloadFields, fieldStampsRef.current.deleted, now
@@ -1132,7 +1228,7 @@ function CustomerGrid({
1132
  adoptNewViews(current, hostWorkspaceViews, viewTombstonesRef.current, now,
1133
  (config) => normalizeConfig(config, nextFields))
1134
  );
1135
- }, [workspaceReady, hostWorkspaceViews, payloadFields, fields, embedded]);
1136
 
1137
  /** Item 3c β€” stamp a def write / a delete. Pruned at every touch so the persisted blob
1138
  * stays a recent window, never an archive. */
@@ -1155,10 +1251,14 @@ function CustomerGrid({
1155
 
1156
  const updateConfig = useCallback(
1157
  (next: ViewConfig) => {
 
 
 
 
1158
  if (next.groupBy !== config.groupBy) setCollapsed(new Set());
1159
  setConfig(next);
1160
  },
1161
- [config.groupBy]
1162
  );
1163
 
1164
  const {
@@ -1193,7 +1293,7 @@ function CustomerGrid({
1193
  * this resolves, the appended row does not exist yet, so "bottom" would land on the last OLD
1194
  * row.
1195
  */
1196
- const isUserTable = !embedded && scope.startsWith("ut_");
1197
  const recordsMutable = payload?.recordsMutable !== false;
1198
  const canMutateRecords = isUserTable && recordsMutable;
1199
  /**
@@ -1351,7 +1451,7 @@ function CustomerGrid({
1351
 
1352
  // Airtable behavior: configuration changes to the active view autosave.
1353
  useEffect(() => {
1354
- if (!workspaceReady || embedded) return;
1355
  const active = views.find((view) => view.id === activeViewId);
1356
  if (!active || sameConfig(active.config, config)) {
1357
  setSaveState("saved");
@@ -1373,7 +1473,7 @@ function CustomerGrid({
1373
  return () => {
1374
  if (saveTimer.current !== null) window.clearTimeout(saveTimer.current);
1375
  };
1376
- }, [workspaceReady, activeViewId, config, views, embedded]);
1377
 
1378
  // Numeric dimensions force a glide relayout when either the component frame
1379
  // or Streamlit's main column changes width (notably sidebar collapse).
@@ -1404,7 +1504,7 @@ function CustomerGrid({
1404
  const mode = tableMode(payload?.counts);
1405
  const serverWindowed = mode === "server-windowed";
1406
  // Owner items 4+6 β€” the Cohort page's grid renders without the Views sidebar.
1407
- const hideViews = embedded || payload?.workspace?.hideViews === true;
1408
  // Wave-6 item 10 β€” how this view displays. A WINDOWED table is always the grid: list/
1409
  // calendar/kanban compute over the whole matched set, and one page is not it (CG-3's rule,
1410
  // the same reason grouping is off there).
@@ -1542,8 +1642,8 @@ function CustomerGrid({
1542
  // permissions vs the viewer, fail-closed on restricted fields when the viewer is unknown.
1543
  const viewer = payload?.viewer;
1544
  const canEditField = useCallback(
1545
- (f: Field): boolean => !embedded && recordsMutable && mayEditField(f, viewer),
1546
- [embedded, recordsMutable, viewer]
1547
  );
1548
  const unresolvedCount = useMemo(
1549
  () => unresolvedConditions(config.filters, { cohortSets, today }),
@@ -3073,14 +3173,26 @@ function CustomerGrid({
3073
  );
3074
 
3075
  const persistView = useCallback((view: SavedView) => {
 
 
 
 
 
 
 
 
 
 
3076
  setViews((current) => {
3077
  const found = current.some((item) => item.id === view.id);
3078
  return found
3079
  ? current.map((item) => (item.id === view.id ? view : item))
3080
  : [...current, view];
3081
  });
3082
- emitHostEvent({ id: eventId("view"), type: "view_upsert", view });
3083
- }, []);
 
 
3084
 
3085
  /**
3086
  * Item 12 (C-LOCK) β€” set or clear a view's cohort lock, from the rail's menu.
@@ -3115,6 +3227,7 @@ function CustomerGrid({
3115
 
3116
  const selectView = useCallback(
3117
  (id: string) => {
 
3118
  const current = views.find((view) => view.id === activeViewId);
3119
  if (current && !sameConfig(current.config, config))
3120
  persistView({ ...current, config });
@@ -3133,7 +3246,7 @@ function CustomerGrid({
3133
  // Owner item 10: opening a view is "I am working now" β€” the frame folds its nav rail.
3134
  signal(NAV_MINIMIZE_EVENT);
3135
  },
3136
- [views, activeViewId, config, persistView, fields]
3137
  );
3138
 
3139
  /**
@@ -3157,6 +3270,14 @@ function CustomerGrid({
3157
  }, [scope, views, selectView]);
3158
  const createView = useCallback(
3159
  (name: string, mode: DisplayMode, permissions: ViewPermissions) => {
 
 
 
 
 
 
 
 
3160
  const acceptedName = uniqueDisplayName(name, views.map((view) => view.name));
3161
  /**
3162
  * ⭐⭐ WAVE 27 Β· OWNER ITEM 9 / RULING R4 β€” **A NEW VIEW IS BLANK. ALL OF IT.**
@@ -3214,7 +3335,7 @@ function CustomerGrid({
3214
  },
3215
  // `fields`, not `config` (R4): the blank is derived from the COLUMNS, and reading the live
3216
  // config here at all is what item 9 deletes.
3217
- [fields, persistView, views]
3218
  );
3219
  const renameView = useCallback(
3220
  (id: string, name: string) => {
@@ -3333,6 +3454,25 @@ function CustomerGrid({
3333
  );
3334
  const deleteView = useCallback(
3335
  (id: string) => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3336
  const view = views.find((item) => item.id === id);
3337
  // ⚠ This gate used to read `view.locked`, and the menu entry above it did too. C3
3338
  // redefines `locked` as "the DISPLAY MODE is frozen" on ANY view, so leaving the gate
@@ -3348,14 +3488,16 @@ function CustomerGrid({
3348
  // back and (since that merge never removes) stay back. See liveWorkspace.ts.
3349
  viewTombstonesRef.current = stampTombstone(viewTombstonesRef.current, id, Date.now());
3350
  setViews((current) => current.filter((item) => item.id !== id));
3351
- emitHostEvent({ id: eventId("view-delete"), type: "view_delete", viewId: id });
 
 
3352
  if (activeViewId === id) {
3353
  const all = views.find((item) => item.id === ALL_VIEW_ID) ?? allRecordsView(fields, scope);
3354
  setActiveViewId(all.id);
3355
  setConfig(all.config);
3356
  }
3357
  },
3358
- [views, activeViewId, fields, viewer, scope]
3359
  );
3360
 
3361
  /**
@@ -5110,7 +5252,7 @@ function CustomerGrid({
5110
  const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
5111
  const menuPinnedTo = menuVisIndex >= 0 && frozenN > 1 && menuVisIndex + 1 === frozenN;
5112
  // Item 10 β€” the toolbar's mode switcher (grid-only on windowed tables, see displayMode).
5113
- const modeControl = serverWindowed || embedded ? undefined : (
5114
  <ModeSwitch
5115
  mode={displayMode}
5116
  onMode={setDisplayMode}
@@ -5212,6 +5354,9 @@ function CustomerGrid({
5212
  data-today={today ?? ""}
5213
  data-measure-rules={measureRuleKeys}
5214
  data-measure-sets={measureSetKeys}
 
 
 
5215
  >
5216
  {/* ⭐ wave17 R1 / C-LOCKV β€” the COHORT SIDEBAR is gone, and with it the last surface that
5217
  treated a cohort as its own kind of object. `CohortSidebar` was the retired `#/cohort`
@@ -5220,6 +5365,14 @@ function CustomerGrid({
5220
  below renders every one of them and the second rail has nothing to switch between.
5221
  ⚠ `lists` is NOT gone with it β€” it stays the membership channel that feeds `cohortSets`
5222
  (C-LOCKV point 2), which is what the lock resolves against. */}
 
 
 
 
 
 
 
 
5223
  {!hideViews && (
5224
  <ViewSidebar
5225
  // Item 12 (C-LOCK) β€” the rail's menu is the only emitter of a SAVED `cohortLock`.
@@ -5576,7 +5729,7 @@ function CustomerGrid({
5576
  height={gridSize.height}
5577
  customRenderers={[ratingCellRenderer, userCellRenderer, imageCellRenderer]}
5578
  headerIcons={HEADER_ICONS}
5579
- rightElement={embedded ? undefined : (
5580
  // Wave-6 item 4 β€” the "+" of the header row: the create-field form,
5581
  // insert-at-end.
5582
  //
@@ -5770,7 +5923,7 @@ function CustomerGrid({
5770
  docs={payload?.docs}
5771
  docPayload={payload?.docPayload}
5772
  onDocAdd={
5773
- payload?.docs
5774
  ? (pid, file) =>
5775
  emitHostEvent({
5776
  id: eventId("docadd"),
@@ -5782,13 +5935,13 @@ function CustomerGrid({
5782
  : undefined
5783
  }
5784
  onDocFetch={
5785
- payload?.docs
5786
  ? (pid, docId) =>
5787
  emitHostEvent({ id: eventId("docget"), type: "doc_fetch", pid, docId })
5788
  : undefined
5789
  }
5790
  onDocDelete={
5791
- payload?.docs
5792
  ? (pid, docId) =>
5793
  emitHostEvent({ id: eventId("docdel"), type: "doc_delete", pid, docId })
5794
  : undefined
@@ -6855,7 +7008,7 @@ function CustomerGrid({
6855
  // on the NEXT render via `payload.docPayload`; Documents.tsx matches it
6856
  // to its own pending request before touching it.
6857
  onDocAdd={
6858
- payload?.docs
6859
  ? (file) =>
6860
  emitHostEvent({
6861
  id: eventId("docadd"),
@@ -6867,7 +7020,7 @@ function CustomerGrid({
6867
  : undefined
6868
  }
6869
  onDocFetch={
6870
- payload?.docs
6871
  ? (docId) =>
6872
  emitHostEvent({
6873
  id: eventId("docget"),
@@ -6878,7 +7031,7 @@ function CustomerGrid({
6878
  : undefined
6879
  }
6880
  onDocDelete={
6881
- payload?.docs
6882
  ? (docId) =>
6883
  emitHostEvent({
6884
  id: eventId("docdel"),
 
25
 
26
  import { useCustomerData } from "./useCustomerData";
27
  import type { SurfaceScope } from "./apiBridge";
28
+ import { mutateQueryWorkspace, QUERY_BINDING_EVENT, refuseQueryMutation } from "../query/queryApi";
29
+ import type { QueryVirtualBinding } from "../query/queryApi";
30
+ import { acceptsQueryPreview, routeQueryViewMutation } from "./queryPreview";
31
  // ⭐ WAVE 30 Β· W30-T42 (contract C2) β€” the WINDOWED grid's arithmetic, pure and node-run in
32
  // `verify_grid_ux.py`, because every one of these decisions is taken inside a callback where
33
  // only its own source text could otherwise be checked.
 
356
  };
357
  }
358
 
359
+ /** Adapt E's already-validated Query view to the existing renderer without creating a native view. */
360
+ function queryPreviewView(binding: QueryVirtualBinding, fields: Field[]): SavedView {
361
+ const raw = binding.view;
362
+ const keys = new Set(fields.map((field) => field.key));
363
+ const config = defaultViewConfig(fields);
364
+ const visible = Array.isArray(raw.visible)
365
+ ? raw.visible.filter((key): key is string => typeof key === "string" && keys.has(key))
366
+ : [];
367
+ if (visible.length) {
368
+ config.visible = visible;
369
+ config.order = [...visible, ...config.order.filter((key) => !visible.includes(key))];
370
+ }
371
+ config.filters = Array.isArray(raw.filters) ? raw.filters as FilterNode[] : [];
372
+ config.filterConj = raw.filterConj === "or" ? "or" : "and";
373
+ config.sorts = Array.isArray(raw.sorts)
374
+ ? raw.sorts.filter((item): item is { colId: string; dir?: unknown } =>
375
+ !!item && typeof item === "object" && typeof (item as { colId?: unknown }).colId === "string"
376
+ && keys.has((item as { colId: string }).colId)
377
+ ).map((item) => ({ colId: item.colId, dir: item.dir === "desc" ? "desc" : "asc" }))
378
+ : [];
379
+ config.groupBy = typeof raw.groupBy === "string" && keys.has(raw.groupBy) ? raw.groupBy : null;
380
+ if (raw.display && typeof raw.display === "object") config.display = raw.display as DisplaySpec;
381
+ return {
382
+ id: binding.artifactId,
383
+ name: typeof raw.name === "string" && raw.name ? raw.name : binding.source.label,
384
+ kind: "custom",
385
+ locked: true,
386
+ config,
387
+ };
388
+ }
389
+
390
  function readLocal(storageKey: string): LocalWorkspace | null {
391
  try {
392
  const raw = localStorage.getItem(`aios-grid:${storageKey}`);
 
593
  * re-renders for its own state (edits, resize) and remounts on route change via `key`. */
594
  interface CustomerGridProps {
595
  scope?: SurfaceScope;
596
+ /** A Query-owned immutable artefact. `scope` remains the renderer's mapped data scope. */
597
+ queryBinding?: QueryVirtualBinding;
598
  /** Read-only Grid view embedded in a linked-record modal. It keeps the standard
599
  * filter/sort/search toolbar while withdrawing schema and row mutations. */
600
  embedded?: boolean;
 
692
  );
693
  }
694
 
695
+ function isQueryPreviewRoute(): boolean {
696
+ return typeof window !== "undefined" && window.location.hash.startsWith("#/query");
697
+ }
698
+
699
+ function CustomerGrid(props: CustomerGridProps = {}) {
700
+ const scope = props.scope ?? "customer";
701
+ const queryRoute = isQueryPreviewRoute();
702
+ const [eventBinding, setEventBinding] = useState<QueryVirtualBinding>();
703
+ const [eventRejected, setEventRejected] = useState(false);
704
+ useEffect(() => {
705
+ if (!queryRoute || props.queryBinding) return;
706
+ const receive = (event: Event) => {
707
+ const candidate = (event as CustomEvent<unknown>).detail;
708
+ if (!acceptsQueryPreview(candidate, scope)) {
709
+ setEventBinding(undefined);
710
+ setEventRejected(true);
711
+ return;
712
+ }
713
+ setEventBinding(candidate);
714
+ setEventRejected(false);
715
+ };
716
+ window.addEventListener(QUERY_BINDING_EVENT, receive);
717
+ return () => window.removeEventListener(QUERY_BINDING_EVENT, receive);
718
+ }, [queryRoute, props.queryBinding, scope]);
719
+
720
+ const queryBinding = props.queryBinding ?? eventBinding;
721
+ if (queryBinding && !acceptsQueryPreview(queryBinding, scope)) {
722
+ return (
723
+ <main className="cg-main" role="alert">
724
+ This Query preview is unavailable because its source binding is invalid.
725
+ </main>
726
+ );
727
+ }
728
+ if (queryRoute && !props.queryBinding && !queryBinding) {
729
+ return (
730
+ <main className="cg-main" role={eventRejected ? "alert" : "status"} aria-live="polite">
731
+ {eventRejected
732
+ ? "This Query preview is unavailable because its source binding was refused."
733
+ : "Opening Query preview…"}
734
+ </main>
735
+ );
736
+ }
737
+ return <CustomerGridSurface {...props} queryBinding={queryBinding} />;
738
+ }
739
+
740
+ function CustomerGridSurface({
741
  scope = "customer",
742
+ queryBinding,
743
  embedded = false,
744
  embeddedRecordIds,
745
  embeddedSelectable = false,
746
  embeddedSelectedIds = [],
747
  onEmbeddedSelectionChange,
748
  }: CustomerGridProps = {}) {
749
+ const isQueryPreview = queryBinding !== undefined;
750
+ const previewReadOnly = embedded || isQueryPreview;
751
  // Wave 16 C-TOPIC: which TABLE this tree is drawing, derived from the one scope prop.
752
  const topic = topicForScope(scope);
753
  const {
 
760
  patchOverlay,
761
  requestWindow,
762
  } = useCustomerData(scope, {
763
+ bindSurface: !previewReadOnly,
764
+ includeWorkspace: !previewReadOnly,
765
+ writable: !previewReadOnly,
766
  });
767
  const embeddedIdsKey = embeddedRecordIds?.join(",") ?? "";
768
  /**
 
864
  */
865
  const [alerted, setAlerted] = useState<string[]>([]);
866
  useEffect(() => {
867
+ if (previewReadOnly) return;
868
  let live = true;
869
  const pull = () => {
870
  void fetchAlerts().then((r) => {
 
878
  live = false;
879
  window.removeEventListener("focus", pull);
880
  };
881
+ }, [previewReadOnly, scope]);
882
 
883
  const [fields, setFields] = useState<Field[]>([]);
884
  const [views, setViews] = useState<SavedView[]>([]);
 
1047
  * ⚠ Existing browsers lose the contents of the old shared bucket. That is the point: every
1048
  * byte in it is a workspace some other surface persisted, and there is no way to tell whose.
1049
  */
1050
+ const storageKey = isQueryPreview && queryBinding
1051
+ ? `query:${queryBinding.workspaceBinding.key}`
1052
+ : payload?.workspace?.storageKey ?? `local:${scope}`;
1053
 
1054
  // Initialize once per permission/data scope. Host state wins; local state
1055
  // fills only missing objects and keeps the standalone path useful.
1056
  useEffect(() => {
1057
  if (!payload || payloadFields.length === 0 || initializedKey.current === storageKey) return;
1058
+ if (isQueryPreview && queryBinding) {
1059
+ const virtual = queryPreviewView(queryBinding, payloadFields);
1060
+ setFields(payloadFields);
1061
+ setViews([virtual]);
1062
+ setActiveViewId(virtual.id);
1063
+ setConfig(virtual.config);
1064
+ setWorkspaceReady(true);
1065
+ initializedKey.current = storageKey;
1066
+ return;
1067
+ }
1068
  const local = embedded ? null : readLocal(storageKey);
1069
  // Item 3c: the def half of the no-blip layer. A RECENT local stamp beats a
1070
  // lagged host echo (rename survives, retype holds, a delete stays deleted);
 
1158
  * the config the client is actually filtering with, and sending anything else would ask the
1159
  * host to resolve a question nobody on screen is asking.
1160
  */
1161
+ if (!previewReadOnly && reemit.size > 0) {
1162
  const stamped = { ...(local?.reemitted ?? {}) };
1163
  for (const view of initialViews) {
1164
  const key = reemit.get(view.id);
 
1173
  } else {
1174
  reemittedRef.current = { ...(local?.reemitted ?? {}) };
1175
  }
1176
+ }, [payload, payloadFields, storageKey, previewReadOnly, scope, isQueryPreview, queryBinding]);
1177
 
1178
  useEffect(() => {
1179
+ if (!workspaceReady || previewReadOnly) return;
1180
  writeLocal(storageKey, {
1181
  fields,
1182
  views,
 
1193
  // drop a view this browser created seconds ago.
1194
  viewWrites: pruneTombstones(viewWritesRef.current, Date.now()),
1195
  });
1196
+ }, [workspaceReady, storageKey, fields, views, activeViewId, previewReadOnly]);
1197
 
1198
  /**
1199
  * ⭐ THE LIVE WORKSPACE (owner report, 2026-08-04) β€” what has APPEARED since we mounted.
 
1218
  */
1219
  const hostWorkspaceViews = payload?.workspace?.views;
1220
  useEffect(() => {
1221
+ if (!workspaceReady || previewReadOnly) return;
1222
  const now = Date.now();
1223
  const nextFields = adoptNewFields(
1224
  fields, payloadFields, fieldStampsRef.current.deleted, now
 
1228
  adoptNewViews(current, hostWorkspaceViews, viewTombstonesRef.current, now,
1229
  (config) => normalizeConfig(config, nextFields))
1230
  );
1231
+ }, [workspaceReady, hostWorkspaceViews, payloadFields, fields, previewReadOnly]);
1232
 
1233
  /** Item 3c β€” stamp a def write / a delete. Pruned at every touch so the persisted blob
1234
  * stays a recent window, never an archive. */
 
1251
 
1252
  const updateConfig = useCallback(
1253
  (next: ViewConfig) => {
1254
+ if (isQueryPreview && queryBinding) {
1255
+ signal(TOAST_EVENT, refuseQueryMutation(queryBinding, "update").message);
1256
+ return;
1257
+ }
1258
  if (next.groupBy !== config.groupBy) setCollapsed(new Set());
1259
  setConfig(next);
1260
  },
1261
+ [config.groupBy, isQueryPreview, queryBinding]
1262
  );
1263
 
1264
  const {
 
1293
  * this resolves, the appended row does not exist yet, so "bottom" would land on the last OLD
1294
  * row.
1295
  */
1296
+ const isUserTable = !previewReadOnly && scope.startsWith("ut_");
1297
  const recordsMutable = payload?.recordsMutable !== false;
1298
  const canMutateRecords = isUserTable && recordsMutable;
1299
  /**
 
1451
 
1452
  // Airtable behavior: configuration changes to the active view autosave.
1453
  useEffect(() => {
1454
+ if (!workspaceReady || previewReadOnly) return;
1455
  const active = views.find((view) => view.id === activeViewId);
1456
  if (!active || sameConfig(active.config, config)) {
1457
  setSaveState("saved");
 
1473
  return () => {
1474
  if (saveTimer.current !== null) window.clearTimeout(saveTimer.current);
1475
  };
1476
+ }, [workspaceReady, activeViewId, config, views, previewReadOnly]);
1477
 
1478
  // Numeric dimensions force a glide relayout when either the component frame
1479
  // or Streamlit's main column changes width (notably sidebar collapse).
 
1504
  const mode = tableMode(payload?.counts);
1505
  const serverWindowed = mode === "server-windowed";
1506
  // Owner items 4+6 β€” the Cohort page's grid renders without the Views sidebar.
1507
+ const hideViews = previewReadOnly || payload?.workspace?.hideViews === true;
1508
  // Wave-6 item 10 β€” how this view displays. A WINDOWED table is always the grid: list/
1509
  // calendar/kanban compute over the whole matched set, and one page is not it (CG-3's rule,
1510
  // the same reason grouping is off there).
 
1642
  // permissions vs the viewer, fail-closed on restricted fields when the viewer is unknown.
1643
  const viewer = payload?.viewer;
1644
  const canEditField = useCallback(
1645
+ (f: Field): boolean => !previewReadOnly && recordsMutable && mayEditField(f, viewer),
1646
+ [previewReadOnly, recordsMutable, viewer]
1647
  );
1648
  const unresolvedCount = useMemo(
1649
  () => unresolvedConditions(config.filters, { cohortSets, today }),
 
3173
  );
3174
 
3175
  const persistView = useCallback((view: SavedView) => {
3176
+ if (queryBinding) {
3177
+ const routed = routeQueryViewMutation(queryBinding, scope, {
3178
+ id: eventId("query-view-update"),
3179
+ type: "view_upsert",
3180
+ view: view as unknown as Record<string, unknown>,
3181
+ }, { query: mutateQueryWorkspace, native: emitHostEvent });
3182
+ if (routed.channel === "refused")
3183
+ signal(TOAST_EVENT, routed.refusal?.message ?? "This Query binding cannot write a source view.");
3184
+ return;
3185
+ }
3186
  setViews((current) => {
3187
  const found = current.some((item) => item.id === view.id);
3188
  return found
3189
  ? current.map((item) => (item.id === view.id ? view : item))
3190
  : [...current, view];
3191
  });
3192
+ routeQueryViewMutation(undefined, scope, {
3193
+ id: eventId("view"), type: "view_upsert", view: view as unknown as Record<string, unknown>,
3194
+ }, { query: mutateQueryWorkspace, native: emitHostEvent });
3195
+ }, [queryBinding, scope]);
3196
 
3197
  /**
3198
  * Item 12 (C-LOCK) β€” set or clear a view's cohort lock, from the rail's menu.
 
3227
 
3228
  const selectView = useCallback(
3229
  (id: string) => {
3230
+ if (isQueryPreview) return;
3231
  const current = views.find((view) => view.id === activeViewId);
3232
  if (current && !sameConfig(current.config, config))
3233
  persistView({ ...current, config });
 
3246
  // Owner item 10: opening a view is "I am working now" β€” the frame folds its nav rail.
3247
  signal(NAV_MINIMIZE_EVENT);
3248
  },
3249
+ [views, activeViewId, config, persistView, fields, isQueryPreview]
3250
  );
3251
 
3252
  /**
 
3270
  }, [scope, views, selectView]);
3271
  const createView = useCallback(
3272
  (name: string, mode: DisplayMode, permissions: ViewPermissions) => {
3273
+ if (queryBinding) {
3274
+ const routed = routeQueryViewMutation(queryBinding, scope, {
3275
+ id: eventId("query-view-create"), type: "view_create",
3276
+ }, { query: mutateQueryWorkspace, native: emitHostEvent });
3277
+ if (routed.channel === "refused")
3278
+ signal(TOAST_EVENT, routed.refusal?.message ?? "This Query binding cannot create a source view.");
3279
+ return;
3280
+ }
3281
  const acceptedName = uniqueDisplayName(name, views.map((view) => view.name));
3282
  /**
3283
  * ⭐⭐ WAVE 27 Β· OWNER ITEM 9 / RULING R4 β€” **A NEW VIEW IS BLANK. ALL OF IT.**
 
3335
  },
3336
  // `fields`, not `config` (R4): the blank is derived from the COLUMNS, and reading the live
3337
  // config here at all is what item 9 deletes.
3338
+ [fields, persistView, views, queryBinding, scope]
3339
  );
3340
  const renameView = useCallback(
3341
  (id: string, name: string) => {
 
3454
  );
3455
  const deleteView = useCallback(
3456
  (id: string) => {
3457
+ if (queryBinding) {
3458
+ const routed = routeQueryViewMutation(queryBinding, scope, {
3459
+ id: eventId("query-view-delete"), type: "view_delete", viewId: id,
3460
+ }, { query: mutateQueryWorkspace, native: emitHostEvent });
3461
+ if (routed.channel === "refused") {
3462
+ signal(TOAST_EVENT, routed.refusal?.message ?? "This Query binding cannot delete a source view.");
3463
+ return;
3464
+ }
3465
+ if (routed.channel !== "query") return;
3466
+ void routed.result.then((result) => {
3467
+ if (!result.ok) {
3468
+ signal(TOAST_EVENT, result.message);
3469
+ return;
3470
+ }
3471
+ setViews([]);
3472
+ setActiveViewId("");
3473
+ });
3474
+ return;
3475
+ }
3476
  const view = views.find((item) => item.id === id);
3477
  // ⚠ This gate used to read `view.locked`, and the menu entry above it did too. C3
3478
  // redefines `locked` as "the DISPLAY MODE is frozen" on ANY view, so leaving the gate
 
3488
  // back and (since that merge never removes) stay back. See liveWorkspace.ts.
3489
  viewTombstonesRef.current = stampTombstone(viewTombstonesRef.current, id, Date.now());
3490
  setViews((current) => current.filter((item) => item.id !== id));
3491
+ routeQueryViewMutation(undefined, scope, {
3492
+ id: eventId("view-delete"), type: "view_delete", viewId: id,
3493
+ }, { query: mutateQueryWorkspace, native: emitHostEvent });
3494
  if (activeViewId === id) {
3495
  const all = views.find((item) => item.id === ALL_VIEW_ID) ?? allRecordsView(fields, scope);
3496
  setActiveViewId(all.id);
3497
  setConfig(all.config);
3498
  }
3499
  },
3500
+ [views, activeViewId, fields, viewer, scope, queryBinding]
3501
  );
3502
 
3503
  /**
 
5252
  const menuVisIndex = menuField ? visibleKeys.indexOf(menuField.key) : -1;
5253
  const menuPinnedTo = menuVisIndex >= 0 && frozenN > 1 && menuVisIndex + 1 === frozenN;
5254
  // Item 10 β€” the toolbar's mode switcher (grid-only on windowed tables, see displayMode).
5255
+ const modeControl = serverWindowed || previewReadOnly ? undefined : (
5256
  <ModeSwitch
5257
  mode={displayMode}
5258
  onMode={setDisplayMode}
 
5354
  data-today={today ?? ""}
5355
  data-measure-rules={measureRuleKeys}
5356
  data-measure-sets={measureSetKeys}
5357
+ data-query-artifact={queryBinding?.artifactId ?? ""}
5358
+ data-query-workspace={queryBinding?.workspaceBinding.key ?? ""}
5359
+ data-query-source={queryBinding?.dataScope ?? ""}
5360
  >
5361
  {/* ⭐ wave17 R1 / C-LOCKV β€” the COHORT SIDEBAR is gone, and with it the last surface that
5362
  treated a cohort as its own kind of object. `CohortSidebar` was the retired `#/cohort`
 
5365
  below renders every one of them and the second rail has nothing to switch between.
5366
  ⚠ `lists` is NOT gone with it β€” it stays the membership channel that feeds `cohortSets`
5367
  (C-LOCKV point 2), which is what the lock resolves against. */}
5368
+ {/* β›” THE QUERY PROVENANCE BLOCK WAS HERE AND MOVED TO `QueryPage` (owner item 3, 2026-08-15).
5369
+ It was rendered as the FIRST CHILD of `.cg-shell`, which is a horizontal flex row
5370
+ (index.css:233) holding the views rail and `.cg-main` β€” and it had no stylesheet rule of
5371
+ its own anywhere in the tree. So R9's citations painted as an unstyled column of raw
5372
+ `JSON.stringify` beside the grid, shoving the table sideways: the provenance requirement
5373
+ met, and the surface it was on made unreadable. Chrome belongs to the page that owns the
5374
+ artefact; the grid draws the artefact. `queryCitationLabel` still formats it, from
5375
+ `QueryPage`. */}
5376
  {!hideViews && (
5377
  <ViewSidebar
5378
  // Item 12 (C-LOCK) β€” the rail's menu is the only emitter of a SAVED `cohortLock`.
 
5729
  height={gridSize.height}
5730
  customRenderers={[ratingCellRenderer, userCellRenderer, imageCellRenderer]}
5731
  headerIcons={HEADER_ICONS}
5732
+ rightElement={previewReadOnly ? undefined : (
5733
  // Wave-6 item 4 β€” the "+" of the header row: the create-field form,
5734
  // insert-at-end.
5735
  //
 
5923
  docs={payload?.docs}
5924
  docPayload={payload?.docPayload}
5925
  onDocAdd={
5926
+ !previewReadOnly && payload?.docs
5927
  ? (pid, file) =>
5928
  emitHostEvent({
5929
  id: eventId("docadd"),
 
5935
  : undefined
5936
  }
5937
  onDocFetch={
5938
+ !previewReadOnly && payload?.docs
5939
  ? (pid, docId) =>
5940
  emitHostEvent({ id: eventId("docget"), type: "doc_fetch", pid, docId })
5941
  : undefined
5942
  }
5943
  onDocDelete={
5944
+ !previewReadOnly && payload?.docs
5945
  ? (pid, docId) =>
5946
  emitHostEvent({ id: eventId("docdel"), type: "doc_delete", pid, docId })
5947
  : undefined
 
7008
  // on the NEXT render via `payload.docPayload`; Documents.tsx matches it
7009
  // to its own pending request before touching it.
7010
  onDocAdd={
7011
+ !previewReadOnly && payload?.docs
7012
  ? (file) =>
7013
  emitHostEvent({
7014
  id: eventId("docadd"),
 
7020
  : undefined
7021
  }
7022
  onDocFetch={
7023
+ !previewReadOnly && payload?.docs
7024
  ? (docId) =>
7025
  emitHostEvent({
7026
  id: eventId("docget"),
 
7031
  : undefined
7032
  }
7033
  onDocDelete={
7034
+ !previewReadOnly && payload?.docs
7035
  ? (docId) =>
7036
  emitHostEvent({
7037
  id: eventId("docdel"),
web/src/customer-grid/queryPreview.ts ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { refuseQueryMutation } from "../query/queryApi";
2
+ import type { QueryCitation, QueryMutationResult, QueryVirtualBinding,
3
+ QueryWorkspaceEvent } from "../query/queryApi";
4
+ import { topicForScope } from "./types";
5
+ import type { HostEvent } from "./types";
6
+
7
+ type NativeViewUpsert = Extract<HostEvent, { type: "view_upsert" }>;
8
+ type NativeViewDelete = Extract<HostEvent, { type: "view_delete" }>;
9
+
10
+ /** The native route accepts a SavedView, never an unvalidated query-shaped record. */
11
+ function isNativeView(value: unknown): value is NativeViewUpsert["view"] {
12
+ if (!value || typeof value !== "object") return false;
13
+ const view = value as Record<string, unknown>;
14
+ return typeof view.id === "string"
15
+ && typeof view.name === "string"
16
+ && (view.kind === "system" || view.kind === "list" || view.kind === "custom" || view.kind === "locked")
17
+ && !!view.config && typeof view.config === "object";
18
+ }
19
+
20
+ /**
21
+ * Query owns the virtual artefact; the grid receives only the one source it may
22
+ * read. The source key is checked here instead of reconstructing a second
23
+ * database-name mapping in the client.
24
+ */
25
+ export function acceptsQueryPreview(
26
+ binding: unknown,
27
+ scope: string,
28
+ ): binding is QueryVirtualBinding {
29
+ const candidate = binding as Partial<QueryVirtualBinding> | null;
30
+ const source = candidate?.source;
31
+ const workspace = candidate?.workspaceBinding;
32
+ return !!candidate
33
+ && typeof candidate.artifactId === "string" && candidate.artifactId.length > 0
34
+ && workspace?.kind === "query"
35
+ && workspace.key === candidate.artifactId
36
+ && source?.permission_scope_applied === true
37
+ && source.database === candidate.dataScope
38
+ && topicForScope(scope).key === candidate.dataScope
39
+ && Array.isArray(candidate.citations)
40
+ && candidate.citations.every((citation) =>
41
+ citation.database === candidate.dataScope && citation.permission_scope_applied === true
42
+ );
43
+ }
44
+
45
+ export type QueryViewMutationRoute<T> =
46
+ | { channel: "query"; result: T }
47
+ | { channel: "refused"; refusal: QueryMutationResult | null }
48
+ | { channel: "native" };
49
+
50
+ /**
51
+ * The sole bridge between existing view callbacks and a virtual Query artefact. Query owns
52
+ * the mutation policy: creates and updates are refused locally, while a matching delete travels
53
+ * only to E's Query transport. A denied/mismatched binding cannot fall through to `native`.
54
+ */
55
+ export function routeQueryViewMutation<T>(
56
+ binding: QueryVirtualBinding | undefined,
57
+ scope: string,
58
+ event: QueryWorkspaceEvent,
59
+ sinks: {
60
+ query: (binding: QueryVirtualBinding, event: QueryWorkspaceEvent) => T;
61
+ native: (event: NativeViewUpsert | NativeViewDelete) => void;
62
+ },
63
+ ): QueryViewMutationRoute<T> {
64
+ if (binding) {
65
+ if (!acceptsQueryPreview(binding, scope)) return { channel: "refused", refusal: null };
66
+ if (event.type === "view_delete" && event.viewId === binding.artifactId)
67
+ return { channel: "query", result: sinks.query(binding, event) };
68
+ return {
69
+ channel: "refused",
70
+ refusal: refuseQueryMutation(binding, event.type === "view_create" ? "create" : "update"),
71
+ };
72
+ }
73
+ if (event.type === "view_upsert" && isNativeView(event.view)) {
74
+ sinks.native({
75
+ id: event.id ?? "",
76
+ type: "view_upsert",
77
+ view: event.view,
78
+ });
79
+ return { channel: "native" };
80
+ }
81
+ if (event.type === "view_delete" && event.viewId) {
82
+ sinks.native({ id: event.id ?? "", type: "view_delete", viewId: event.viewId });
83
+ return { channel: "native" };
84
+ }
85
+ return { channel: "refused", refusal: null };
86
+ }
87
+
88
+ /**
89
+ * The predicates a citation was computed under, in words.
90
+ *
91
+ * β›” IT WAS `JSON.stringify(citation.filters)`, ON SCREEN. A view with no predicates rendered
92
+ * `filters: {"source":null,"view":{"conj":"and","nodes":[]}}` β€” thirteen tokens of wire shape to
93
+ * say "none" β€” and a view WITH predicates rendered its internals rather than its meaning. R9 asks
94
+ * for provenance a reader can audit; a serialised object is only auditable by whoever wrote the
95
+ * serialiser. Every clause the ruling names is still here, and the empty case now says so.
96
+ */
97
+ function citationFilterText(raw: unknown): string {
98
+ const box = (raw ?? {}) as { source?: unknown; view?: { conj?: unknown; nodes?: unknown } };
99
+ const node = (item: unknown): string => {
100
+ const row = (item ?? {}) as { colId?: unknown; op?: unknown; value?: unknown };
101
+ if (typeof row.colId !== "string") return "";
102
+ const value = row.value === undefined || row.value === null || row.value === "" ? "" : ` ${String(row.value)}`;
103
+ return `${row.colId} ${String(row.op ?? "")}${value}`.trim();
104
+ };
105
+ const list = (items: unknown): string[] =>
106
+ (Array.isArray(items) ? items : []).map(node).filter(Boolean);
107
+ const source = list(box.source);
108
+ const view = list(box.view?.nodes);
109
+ const conj = box.view?.conj === "or" ? " or " : " and ";
110
+ const parts: string[] = [];
111
+ if (source.length) parts.push(`source ${source.join(" and ")}`);
112
+ if (view.length) parts.push(view.join(conj));
113
+ return parts.length ? `filters: ${parts.join("; ")}` : "no filters";
114
+ }
115
+
116
+ /** Keep complete provenance visible without inventing a record from an opaque id. */
117
+ export function queryCitationLabel(citation: QueryCitation): string {
118
+ const aggregate = citation.aggregation.field
119
+ ? `${citation.aggregation.op} of ${citation.aggregation.field}`
120
+ : citation.aggregation.op;
121
+ const filters = citationFilterText(citation.filters);
122
+ return [
123
+ citation.database,
124
+ `${citation.snapshot.kind} ${String(citation.snapshot.version)}`,
125
+ citation.fields.join(", ") || "no fields",
126
+ filters,
127
+ aggregate,
128
+ `${citation.contributing_record_count.toLocaleString()} records`,
129
+ citation.retrieved_at,
130
+ ].join(" Β· ");
131
+ }
web/src/query/QueryPage.tsx CHANGED
@@ -1,222 +1,138 @@
1
- // ---------------------------------------------------------------------------
2
- // query/QueryPage.tsx β€” THE QUERY MODULE, which is the DATABASE MODULE (owner item 2,
3
- // 2026-08-14).
4
- //
5
- // Owner, verbatim: *"the query module should exactly BE looking like the database module,
6
- // COMPLETELY, with all the views etc. The only difference is that user isn't the one
7
- // creating the view, it's the AI that they prompt in the AI assistant module."*
8
- //
9
- // β›” "EXACTLY" AND "COMPLETELY" ARE ONLY SATISFIABLE ONE WAY: by mounting THE SAME frame
10
- // and THE SAME grid the database route mounts β€” `shell-db-frame` + `DbHead` +
11
- // `OverlayProvider` + `CustomerGrid` β€” so every view kind, the view sidebar, filters,
12
- // grouping, the row detail, the export, the undo stack and everything else arrive by
13
- // being the same component rather than by being rebuilt. A hand-rolled list of AI views
14
- // beside a read-only preview would satisfy the sentence's words and none of its meaning.
15
- // `DbHead` and `gridScopeFor` moved out of `Shell.tsx` into `shell/dbFrame.tsx` for this;
16
- // nothing about them changed.
17
- //
18
- // β›” WHAT THIS PAGE IS *NOT* ANY MORE: the ask box. It used to carry the question field,
19
- // the read-back and the Keep/Discard pair, above a list of rows β€” which is a form, not a
20
- // database. All of that moved to `assistant/AssistantPage.tsx`, the surface the owner
21
- // names as the one difference between this module and a database ("the AI that they
22
- // prompt in the AI assistant module"). This page reads `GET /query` only to know WHICH
23
- // views the assistant built and which database each belongs to.
24
- //
25
- // β›” THE VIEW IS SELECTED THROUGH `VIEW_OPEN_EVENT`, NOT A PROP, and that is a re-use
26
- // rather than a shortcut. That channel's contract: *"the SHELL routes to the table and
27
- // the GRID owns view selection, so neither has to learn the other's state."* Two traps
28
- // ride with it and both are handled here:
29
- // Β· the listener guards on `detail.topic !== scope` β€” the GRID SCOPE spelling, not the
30
- // registry key β€” and drops anything else SILENTLY. `gridScopeFor` is what makes the
31
- // two agree; emitting the saved row's `scope` verbatim would select nothing on
32
- // `customer_data` / `product_data` and look exactly like a click that "worked".
33
- // Β· the listener also drops a view it has not loaded yet, and there is no ack. So the
34
- // emit rides `retryEmit` on THIS module's own ladder (`VIEW_SELECT_RETRY_MS`), which is
35
- // longer than the inbox's for a measured reason β€” see that constant.
36
- //
37
- // ⚠ NO CLIENT UNION OVER `kind` β€” the server's vocabulary arrives as a string (the
38
- // wave-9 law), so a kind we have no label for renders as itself rather than dropping the
39
- // row from the picker.
40
- // ---------------------------------------------------------------------------
41
-
42
- import { useCallback, useEffect, useRef, useState } from "react";
43
-
44
- import { VIEW_OPEN_EVENT, signal } from "../apiContract";
45
- import type { QueryOpenDetail } from "../apiContract";
46
- import { QUERY_OPEN_EVENT } from "../apiContract";
47
- import CustomerGrid from "../customer-grid/CustomerGrid";
48
- import { OverlayProvider } from "../customer-grid/OverlaySurface";
49
- import { retryEmit } from "../inbox/inboxModel";
50
- import { DbHead, gridScopeFor } from "../shell/dbFrame";
51
- import { databaseEntries } from "../shell/nav";
52
- import type { NavEntry } from "../shell/nav";
53
- import { fetchQueries } from "./queryApi";
54
- import type { SavedQuery } from "./queryApi";
55
- // β›” THE TWO PIECES THIS MODULE ADDS LIVE IN A LEAF, not here β€” see `queryParts.tsx`'s header.
56
- // This file imports `CustomerGrid`, so anything defined beside it can only be render-tested by
57
- // dragging the whole spreadsheet engine into plain node, which does not survive the trip.
58
- import { QueryEmpty, QueryPicker } from "./queryParts";
59
- import "./query.css";
60
-
61
- export interface QueryPageProps {
62
- /**
63
- * β›” REQUIRED, and it is the nav's own entries. An optional prop here would degrade to
64
- * "the feature does not exist" the first time a mount forgot it, which is
65
- * indistinguishable from never having been built. It is also what makes this a legal
66
- * CHROME route: the labels and icons this page draws are the ones the SERVER already
67
- * sent, so it renders nothing that was not already granted.
68
- */
69
- granted: NavEntry[];
70
- }
71
-
72
- /**
73
- * ⭐⭐ THIS MODULE'S OWN RE-EMIT LADDER (ms), and it is LONGER than the inbox's on purpose.
74
- *
75
- * β›” THE NUMBERS COME FROM A MEASUREMENT, NOT FROM CAUTION. `OPEN_RETRY_MS` ends at 2,600 ms and
76
- * is right for its case: a notification click into a grid that is usually already warm. This
77
- * module's case is the opposite β€” it keys `CustomerGrid` FRESH on every view switch, so the
78
- * workspace read is cold, and CLAUDE.md records `ut_assembly` at **799 ms warm / 20.6 s cold**
79
- * with live database switches at 1.8–7.3 s. A 2.6 s ladder against a 20.6 s read runs out before
80
- * the grid has views to select from, the listener's `views.some(...)` guard drops every attempt,
81
- * and there is NO ACK to notice it with.
82
- *
83
- * β›” THE FAILURE THAT BUYS IS WORSE THAN A SLOW PAINT, which is why the ladder is the fix rather
84
- * than a spinner: the header would say *"View: Big accounts"* while the grid painted its own
85
- * default view. A wrong claim, silently, with the picker asserting it. (Worse than the case this
86
- * ladder was borrowed from β€” there the header made no competing claim.)
87
- *
88
- * ⚠ RE-EMITTING IS FREE: `selectView` early-returns once the view is active and the listener
89
- * drops a duplicate, so the tail rungs cost nothing on the ordinary warm path β€” they are only
90
- * ever reached by a load that is genuinely still in flight.
91
- */
92
- export const VIEW_SELECT_RETRY_MS = [0, 250, 700, 1500, 2600, 5000, 9000, 14000, 21000] as const;
93
-
94
- export default function QueryPage({ granted }: QueryPageProps) {
95
- const [views, setViews] = useState<SavedQuery[]>([]);
96
- const [activeId, setActiveId] = useState("");
97
- const [loaded, setLoaded] = useState(false);
98
- const emitCancel = useRef<(() => void) | null>(null);
99
-
100
- const entries = databaseEntries(granted);
101
- const entryOf = (key: string) => entries.find((e) => e.key === key);
102
-
103
- /**
104
- * β›” ONLY THE VIEWS WHOSE DATABASE THE NAV NAMED. Two things depend on that entry β€” the
105
- * header's label and the grid's icon β€” and without it `dbLabelOf` would fall back to the raw
106
- * store key, painting `ut_leads` as the name of a database. The picker's own render test
107
- * asserts against exactly that leak (*"never the raw store key"*); the header is one file over
108
- * and would have been unguarded.
109
- *
110
- * ⚠ IT IS ALSO THE CHROME LAW, applied on this end too: *"a chrome route renders nothing the
111
- * server did not already grant."* `GET /query` prunes against the wall, so this normally drops
112
- * nothing at all; it bites when `/nav` came back `degraded: ["databases"]` β€” a 200 whose
113
- * database list is missing β€” and rendering a grid we cannot even name from that state would be
114
- * guessing. `unnamed` carries the count so the empty face can say WHICH silence this is
115
- * instead of claiming the workspace has no views.
116
- */
117
- const known = views.filter((v) => entryOf(v.scope));
118
- const unnamed = views.length - known.length;
119
-
120
- // β›” `?? known[0]` β€” NOT just the find. `activeId` is seeded by the effect below, so the FIRST
121
- // render after a non-empty fetch has `activeId === ""` and `loaded === true`, which painted
122
- // "No views yet" for one frame at somebody who has views. That is this file's own two-facts-
123
- // two-sentences rule broken by render ordering. The effect stays as the correctness guard for
124
- // an id that was pruned away; this is what stops a wrong sentence reaching a screen at all.
125
- const active = known.find((v) => v.id === activeId) ?? known[0] ?? null;
126
- const dbLabelOf = (key: string) => entryOf(key)?.label ?? key;
127
-
128
- useEffect(() => {
129
- let alive = true;
130
- void (async () => {
131
- const res = await fetchQueries();
132
- if (!alive) return;
133
- setLoaded(true);
134
- if (res.ok) setViews(res.value.views);
135
- })();
136
- return () => {
137
- alive = false;
138
- };
139
- }, []);
140
-
141
- // The first view is the landing one. ⚠ Pinned against the CURRENT list rather than set
142
- // once: the index prunes on read (a grant withdrawn, a database deleted), so an id that
143
- // was valid a moment ago can leave β€” and a header naming a view the grid is not showing
144
- // is worse than no selection at all.
145
- useEffect(() => {
146
- if (known.length && !known.some((v) => v.id === activeId)) setActiveId(known[0].id);
147
- }, [known, activeId]);
148
-
149
- /**
150
- * β›” THE EMIT, AND ITS TOPIC IS THE **GRID SCOPE**. See the header: the listener compares
151
- * against its own `scope` prop, so a registry key here selects nothing and reports
152
- * nothing. The ladder covers the other half β€” the grid drops a view it has not loaded.
153
- */
154
- useEffect(() => {
155
- if (!active) return;
156
- emitCancel.current?.();
157
- const topic = gridScopeFor(active.scope);
158
- emitCancel.current = retryEmit(
159
- () => {
160
- signal(VIEW_OPEN_EVENT, { topic, viewId: active.viewId });
161
- },
162
- undefined,
163
- VIEW_SELECT_RETRY_MS
164
- );
165
- return () => emitCancel.current?.();
166
- }, [active]);
167
-
168
- /** The assistant's "Open in Query" click-through: route, then ASK. An event naming a
169
- * query this page does not have is DROPPED β€” the list prunes on read, and a stale
170
- * click must do nothing rather than select something else. */
171
- useEffect(() => {
172
- const onOpen = (e: Event) => {
173
- const detail = (e as CustomEvent<QueryOpenDetail>).detail;
174
- const qid = detail?.qid;
175
- if (!qid || !views.some((v) => v.id === qid)) return;
176
- setActiveId(qid);
177
- };
178
- window.addEventListener(QUERY_OPEN_EVENT, onOpen);
179
- return () => window.removeEventListener(QUERY_OPEN_EVENT, onOpen);
180
- }, [views]);
181
-
182
- const pick = useCallback((qid: string) => setActiveId(qid), []);
183
-
184
- if (!active) return <QueryEmpty loaded={loaded} unnamed={unnamed} />;
185
-
186
- const entry = entryOf(active.scope);
187
- return (
188
- // β›” THE SAME THREE CLASSES AS THE DATABASE ROUTE (`Shell.tsx`'s native branch), in the
189
- // same nesting. `.shell-db-frame > .shell-grid-host` is what gives glide its height;
190
- // renaming them for tidiness would silently collapse the grid to nothing.
191
- <div className="shell-db-frame">
192
- <DbHead
193
- label={dbLabelOf(active.scope)}
194
- {...(entry?.icon ? { icon: entry.icon } : {})}
195
- aside={
196
- <QueryPicker
197
- views={known}
198
- /* ⚠ `active.id`, NOT `activeId`. They differ on exactly one render β€” the first after
199
- a non-empty fetch, when the seeding effect has not run and `activeId` is still "".
200
- A `<select>` whose value matches no option renders BLANK, so the header would show
201
- an empty picker over a grid that is loading a real view. Same one-frame ordering
202
- bug as the `?? known[0]` above, one control over. */
203
- activeId={active.id}
204
- dbLabelOf={dbLabelOf}
205
- onPick={pick}
206
- />
207
- }
208
- />
209
- <div className="shell-grid-host">
210
- <OverlayProvider>
211
- {/* ⚠ `key` ON THE SCOPE, so switching to a view on a DIFFERENT database remounts
212
- rather than re-pointing a grid that still holds the old table's rows, fields and
213
- undo stack β€” the same reason the shell keys this component on the route. */}
214
- <CustomerGrid
215
- key={gridScopeFor(active.scope)}
216
- scope={gridScopeFor(active.scope)}
217
- />
218
- </OverlayProvider>
219
- </div>
220
- </div>
221
- );
222
- }
 
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
25
+ * `QUERY_BINDING_EVENT`: `dataScope` reads the one source database and `workspaceBinding` names
26
+ * the opaque Query artefact. The two must never be collapsed into a source-native workspace.
27
+ *
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();
71
+ setLoaded(true);
72
+ if (result.ok) {
73
+ setViews(result.value.views);
74
+ setCitations(result.value.citations);
75
+ }
76
+ }, []);
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) => {
95
+ const qid = (event as CustomEvent<QueryOpenDetail>).detail?.qid;
96
+ if (!qid) return;
97
+ // β›” The assistant can hand over an artefact this page has not fetched yet β€” it was created
98
+ // seconds ago, in the OTHER surface. Selecting it optimistically and reloading is what makes
99
+ // "ask, then click the preview" work on a first visit; without the reload the id is unknown,
100
+ // the guard drops it, and the click silently does nothing [[wrong-parent-not-broken-control]].
101
+ setActiveId(qid);
102
+ if (!views.some((view) => view.id === qid)) void reload();
103
+ };
104
+ window.addEventListener(QUERY_OPEN_EVENT, onOpen);
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;
111
+ setViews((current) => current.filter((view) => view.id !== qid));
112
+ setActiveId((current) => (current === qid ? "" : current));
113
+ }, []);
114
+
115
+ if (!active) return <QueryEmpty loaded={loaded} unnamed={views.length - known.length} />;
116
+ const entry = entryOf(active.source.database);
117
+ const cited = citations.filter((citation) => active.citationIds.includes(citation.id));
118
+ const provenance = cited.map(queryCitationLabel).join(" Β· ")
119
+ || `${active.source.label} Β· snapshot ${String(active.source.source_version)} Β· retrieved ${active.source.retrieved_at}`;
120
+ return (
121
+ <div className="shell-db-frame">
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>
134
+ </div>
135
+ </div>
136
+ </div>
137
+ );
138
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
web/src/query/query.css CHANGED
@@ -1,101 +1,284 @@
1
  /* ---------------------------------------------------------------------------
2
  query/query.css β€” the Query module's own stylesheet.
3
 
4
- ⭐ IT IS SHORT NOW, AND THAT IS THE POINT (owner item 2, 2026-08-14). The Query
5
- module IS the database module β€” `shell-db-frame` + `DbHead` + `CustomerGrid` β€”
6
- so the frame, the header, the grid and every view kind are styled by rules that
7
- already existed. What is left here is the only two things this surface adds: the
8
- VIEW PICKER inside the database header, and the empty state for a workspace whose
9
- assistant has not built anything yet.
10
-
11
- The ask/answer/list rules that used to live here MOVED to
12
- `assistant/assistant.css` with the markup they style (the `as-` prefix). They were
13
- not copied: one look, one place.
14
-
15
- β›” EVERY FONT SIZE IS A `--lp-fs-*` TOKEN AND EVERY COLOUR A `--lp-*` TOKEN.
16
- Wave-21 R5 is gated app-wide over every CSS file under src, not just index.css,
17
- and a literal here reds `web_ui` for the whole tree. No emojis, no ALL-CAPS
18
- chrome (DESIGN.md R6).
19
-
20
- β›”β›” AND DO NOT WRITE A GLOB IN THIS COMMENT. A star-star followed by a slash
21
- spells the CSS COMMENT TERMINATOR β€” the comment would end in the middle of its own
22
- explanation, every line after it would become stray tokens, and `lightningcss`
23
- would die on the first backtick it then met. `tsc` passes, `vite` transforms all
24
- 1,014 modules, and the build fails in the MINIFIER, nowhere near the sentence that
25
- caused it. Found by `web_build` in wave 32's QA; it blocked the deploy outright.
26
  --------------------------------------------------------------------------- */
27
 
28
- /* ── the view picker, inside the database header ─────────────────────────── */
29
 
30
- .qy-pick {
31
  display: flex;
32
- align-items: center;
33
- gap: 8px;
34
- margin-left: auto;
35
  min-width: 0;
 
 
36
  }
37
 
38
- .qy-pick-label {
39
- font-size: var(--lp-fs-2xs);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
40
  font-weight: 600;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
41
  color: var(--lp-muted);
 
 
42
  }
43
 
44
- .qy-pick-select {
45
- box-sizing: border-box;
46
- max-width: 420px;
47
- padding: 5px 9px;
48
- border: 1px solid var(--lp-line);
49
- border-radius: var(--lp-r-md);
50
- background: var(--lp-surface);
51
- color: var(--lp-ink);
52
- font-size: var(--lp-fs-sm);
53
- font-family: inherit;
54
  }
55
 
56
- .qy-pick-select:focus-visible {
57
- outline: 2px solid var(--lp-blue-solid);
58
- outline-offset: 1px;
 
 
 
 
 
59
  }
60
 
61
- /* ── nothing built yet ───────────────────────────────────────────────────── */
 
 
 
 
 
62
 
63
- .qy-empty {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
64
  display: flex;
65
  flex-direction: column;
66
- align-items: flex-start;
67
  justify-content: center;
68
- height: 100%;
69
- padding: 34px 44px;
70
- box-sizing: border-box;
71
- background: var(--lp-wash);
 
 
 
 
72
  }
73
 
74
- .qy-empty-h {
75
- margin: 0 0 8px;
76
- font-size: var(--lp-fs-lg);
77
- font-weight: 650;
78
- color: var(--lp-ink);
 
79
  }
80
 
81
- .qy-empty-p {
82
- margin: 0 0 18px;
83
- max-width: 58ch;
84
- font-size: var(--lp-fs-sm);
85
- line-height: var(--lp-lh);
86
  color: var(--lp-muted);
 
87
  }
88
 
89
- .qy-empty-go {
90
- padding: 8px 14px;
91
- border-radius: var(--lp-r-md);
92
- background: var(--lp-blue-solid);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
93
  color: var(--lp-surface);
94
- font-size: var(--lp-fs-sm);
 
 
 
 
 
 
 
 
 
 
 
 
95
  font-weight: 600;
96
  text-decoration: none;
97
  }
98
 
99
- .qy-empty-go:hover {
100
- background: color-mix(in srgb, var(--lp-blue-solid) 82%, #000);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
101
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  /* ---------------------------------------------------------------------------
2
  query/query.css β€” the Query module's own stylesheet.
3
 
4
+ The Query module IS the database module β€” `shell-db-frame` + `DbHead` +
5
+ `CustomerGrid` β€” so the frame, the header, the grid and every view kind are
6
+ styled by rules that already exist. What is left here is what this surface
7
+ adds: the VIEW RAIL (owner item 3: the AI's views, grouped by the database
8
+ they read), a one-line provenance disclosure, and the empty state.
9
+
10
+ The chat rules live in `assistant/assistant.css` with the markup they style.
11
+
12
+ EVERY FONT SIZE IS A `--lp-fs-*` TOKEN AND EVERY COLOUR A `--lp-*` TOKEN.
13
+ Wave-21 R5 is gated app-wide over every CSS file under src, and a literal
14
+ here reds `web_ui` for the whole tree. No emojis, no ALL-CAPS chrome
15
+ (DESIGN.md R6).
16
+
17
+ AND DO NOT WRITE A GLOB IN THIS COMMENT. A star-star followed by a slash
18
+ spells the CSS COMMENT TERMINATOR β€” the comment would end in the middle of
19
+ its own explanation and `lightningcss` would die far from the cause. Found
20
+ by `web_build` in wave 32's QA; it blocked the deploy outright.
 
 
 
 
 
21
  --------------------------------------------------------------------------- */
22
 
23
+ /* ── the workspace: rail + surface, in the database module's own geometry ─── */
24
 
25
+ .qy-workspace {
26
  display: flex;
27
+ width: 100%;
28
+ height: 100%;
 
29
  min-width: 0;
30
+ overflow: hidden;
31
+ background: var(--lp-surface);
32
  }
33
 
34
+ /* The same width and hairline `CustomerGrid`'s own views rail uses
35
+ (`.cg-views`, index.css) β€” this rail stands in that slot, so it has to
36
+ measure the same or Query stops looking like a database. */
37
+ .qy-rail {
38
+ flex: 0 0 var(--lp-rail-w);
39
+ width: var(--lp-rail-w);
40
+ min-width: var(--lp-rail-w);
41
+ height: 100%;
42
+ box-sizing: border-box;
43
+ display: flex;
44
+ flex-direction: column;
45
+ padding: 10px 8px;
46
+ border-right: 1px solid var(--lp-line);
47
+ background: var(--lp-surface);
48
+ overflow: hidden;
49
+ }
50
+
51
+ .qy-rail-head {
52
+ margin: 0 0 6px;
53
+ padding: 0 6px;
54
+ color: var(--lp-muted);
55
+ font-size: var(--lp-fs-xs);
56
  font-weight: 600;
57
+ }
58
+
59
+ .qy-rail-scroll {
60
+ flex: 1 1 auto;
61
+ min-height: 0;
62
+ overflow-y: auto;
63
+ }
64
+
65
+ .qy-rail-group { margin-bottom: 10px; }
66
+
67
+ .qy-rail-db {
68
+ display: flex;
69
+ align-items: center;
70
+ gap: 6px;
71
+ margin: 0 0 2px;
72
+ padding: 4px 6px;
73
  color: var(--lp-muted);
74
+ font-size: var(--lp-fs-xs);
75
+ font-weight: 600;
76
  }
77
 
78
+ .qy-rail-db-mark {
79
+ flex: 0 0 auto;
80
+ display: inline-flex;
81
+ width: 14px;
82
+ height: 14px;
83
+ color: var(--lp-muted);
 
 
 
 
84
  }
85
 
86
+ .qy-rail-db-mark svg {
87
+ width: 14px;
88
+ height: 14px;
89
+ fill: none;
90
+ stroke: currentColor;
91
+ stroke-width: 1.4;
92
+ stroke-linecap: round;
93
+ stroke-linejoin: round;
94
  }
95
 
96
+ .qy-rail-db-name {
97
+ min-width: 0;
98
+ overflow: hidden;
99
+ text-overflow: ellipsis;
100
+ white-space: nowrap;
101
+ }
102
 
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);
109
+ }
110
+
111
+ .qy-rail-row:hover { background: var(--lp-surface-2); }
112
+ .qy-rail-row.is-active { background: var(--lp-blue-tint); }
113
+
114
+ .qy-rail-view {
115
+ flex: 1 1 auto;
116
+ min-width: 0;
117
+ min-height: 32px;
118
  display: flex;
119
  flex-direction: column;
 
120
  justify-content: center;
121
+ gap: 1px;
122
+ padding: 5px 8px;
123
+ border: 0;
124
+ background: transparent;
125
+ font: inherit;
126
+ text-align: left;
127
+ color: var(--lp-ink);
128
+ cursor: pointer;
129
  }
130
 
131
+ .qy-rail-name {
132
+ overflow: hidden;
133
+ text-overflow: ellipsis;
134
+ white-space: nowrap;
135
+ font-size: var(--lp-fs-xs);
136
+ font-weight: 500;
137
  }
138
 
139
+ .qy-rail-row.is-active .qy-rail-name { font-weight: 600; }
140
+
141
+ .qy-rail-kind {
 
 
142
  color: var(--lp-muted);
143
+ font-size: var(--lp-fs-2xs);
144
  }
145
 
146
+ /* Hover-revealed, like the grid rail's own row menu β€” visible on focus so it is
147
+ reachable from the keyboard rather than only from a pointer. */
148
+ .qy-rail-del {
149
+ flex: 0 0 auto;
150
+ width: 24px;
151
+ height: 24px;
152
+ margin-right: 4px;
153
+ display: inline-flex;
154
+ align-items: center;
155
+ justify-content: center;
156
+ border: 0;
157
+ border-radius: 6px;
158
+ background: transparent;
159
+ color: var(--lp-muted);
160
+ opacity: 0;
161
+ cursor: pointer;
162
+ }
163
+
164
+ .qy-rail-del svg {
165
+ fill: none;
166
+ stroke: currentColor;
167
+ stroke-width: 1.3;
168
+ stroke-linecap: round;
169
+ stroke-linejoin: round;
170
+ }
171
+
172
+ .qy-rail-row:hover .qy-rail-del,
173
+ .qy-rail-row.is-active .qy-rail-del,
174
+ .qy-rail-del:focus-visible { opacity: 1; }
175
+
176
+ .qy-rail-confirm {
177
+ flex: 0 0 auto;
178
+ margin-right: 4px;
179
+ padding: 3px 8px;
180
+ border: 0;
181
+ border-radius: 6px;
182
+ background: var(--lp-red-deep);
183
  color: var(--lp-surface);
184
+ font: inherit;
185
+ font-size: var(--lp-fs-2xs);
186
+ font-weight: 600;
187
+ cursor: pointer;
188
+ }
189
+
190
+ .qy-rail-ask {
191
+ flex: 0 0 auto;
192
+ margin-top: 8px;
193
+ padding: 7px 8px;
194
+ border-radius: var(--lp-r-md);
195
+ color: var(--lp-blue-solid);
196
+ font-size: var(--lp-fs-xs);
197
  font-weight: 600;
198
  text-decoration: none;
199
  }
200
 
201
+ .qy-rail-ask:hover { background: var(--lp-surface-2); }
202
+
203
+ /* ── the surface: provenance line, then the grid ──────────────────────────── */
204
+
205
+ .qy-surface {
206
+ flex: 1 1 auto;
207
+ min-width: 0;
208
+ height: 100%;
209
+ display: flex;
210
+ flex-direction: column;
211
+ overflow: hidden;
212
+ }
213
+
214
+ .qy-grid-host {
215
+ flex: 1 1 auto;
216
+ min-height: 0;
217
+ overflow: hidden;
218
  }
219
+
220
+ .qy-prov {
221
+ flex: 0 0 auto;
222
+ border-bottom: 1px solid var(--lp-line);
223
+ background: var(--lp-surface);
224
+ }
225
+
226
+ .qy-prov-btn {
227
+ display: flex;
228
+ align-items: baseline;
229
+ gap: 10px;
230
+ width: 100%;
231
+ padding: 7px 14px;
232
+ border: 0;
233
+ background: transparent;
234
+ font: inherit;
235
+ text-align: left;
236
+ cursor: pointer;
237
+ }
238
+
239
+ .qy-prov-q {
240
+ flex: 1 1 auto;
241
+ min-width: 0;
242
+ overflow: hidden;
243
+ text-overflow: ellipsis;
244
+ white-space: nowrap;
245
+ color: var(--lp-muted);
246
+ font-size: var(--lp-fs-xs);
247
+ }
248
+
249
+ .qy-prov-toggle {
250
+ flex: 0 0 auto;
251
+ color: var(--lp-blue-solid);
252
+ font-size: var(--lp-fs-2xs);
253
+ font-weight: 600;
254
+ }
255
+
256
+ .qy-prov-detail {
257
+ margin: 0;
258
+ padding: 0 14px 9px;
259
+ color: var(--lp-muted);
260
+ font-size: var(--lp-fs-2xs);
261
+ line-height: var(--lp-lh);
262
+ word-break: break-word;
263
+ }
264
+
265
+ /* ── the empty state ──────────────────────────────────────────────────────── */
266
+
267
+ .qy-empty {
268
+ display: flex;
269
+ flex-direction: column;
270
+ align-items: center;
271
+ justify-content: center;
272
+ width: 100%;
273
+ height: 100%;
274
+ min-height: 260px;
275
+ padding: 34px 44px;
276
+ box-sizing: border-box;
277
+ background: var(--lp-wash);
278
+ text-align: center;
279
+ }
280
+
281
+ .qy-empty-h { margin: 0 0 8px; color: var(--lp-ink); font-size: var(--lp-fs-lg); font-weight: 650; }
282
+ .qy-empty-p { max-width: 58ch; margin: 0 0 18px; color: var(--lp-muted); font-size: var(--lp-fs-sm); line-height: var(--lp-lh); }
283
+ .qy-empty-go { padding: 8px 14px; border-radius: var(--lp-r-md); background: var(--lp-blue-solid); color: var(--lp-surface); font-size: var(--lp-fs-sm); font-weight: 650; text-decoration: none; }
284
+ .qy-empty-go:hover { background: var(--lp-blue-solid); }
web/src/query/queryApi.ts CHANGED
@@ -1,177 +1,361 @@
1
- // ---------------------------------------------------------------------------
2
- // query/queryApi.ts β€” WAVE 32 item 7 (ruling R1, contract C5): the four calls,
3
- // and nothing else.
4
- //
5
- // Shaped like `alerts/alertsApi.ts` β€” the same `Result<T>`, the same 4xx/5xx
6
- // message split β€” because the failure that matters is identical: a wrong
7
- // `credentials` word produces no client-side symptom at all, just a 401 from a
8
- // server that never saw a session.
9
- //
10
- // β›” A REFUSAL IS A 200 HERE, AND THAT IS THE CONTRACT, NOT A LOOSE END. C5's
11
- // promise is that a question the backend cannot express gets "a plain sentence,
12
- // not a broken view", so `build` answers `{spec: null, refused: "<sentence>"}`
13
- // with an OK status. Mapping it onto an error would paint the error page the
14
- // ruling exists to prevent. The 4xx cases are the ones that really are the
15
- // caller's fault: no database named, no question, a database this session may
16
- // not open.
17
- //
18
- // ⚠ NO CLIENT UNION OVER `kind`. It is the server's vocabulary and arrives as a
19
- // string (the wave-9 law): a client `type Kind = "grid" | …` turns "the server
20
- // added a kind" into "the client drops the view".
21
- // ---------------------------------------------------------------------------
22
-
23
- import { API_V1, CREDENTIALS } from "../apiContract";
24
-
25
- export type Result<T> =
26
- | { ok: true; value: T }
27
- | { ok: false; status: number; message: string };
28
-
29
- /** One saved AI-built view, as `GET /query` sends it. */
30
- export interface SavedQuery {
31
- id: string;
32
- scope: string;
33
- viewId: string;
34
- name: string;
35
- /** A STRING, never a union β€” see the header. */
36
- kind: string;
37
- question: string;
38
- explain: string;
39
- createdAt?: string;
40
- }
41
-
42
- /** What `POST /query/build` answers. Exactly one of `spec` / `refused` is set. */
43
- export interface BuildResult {
44
- spec: unknown | null;
45
- explain: string | null;
46
- refused: string | null;
47
- /** The machine-readable cause behind `refused`, when the server names one. */
48
- reason?: string;
49
- id?: string;
50
- /**
51
- * β›” TRUE when the answer came from a rung BELOW the top of the server's provider ladder.
52
- *
53
- * The server has always known which model answered and has always sent it; nothing read it.
54
- * It matters because the rungs are not interchangeable: `_call_model`'s own docstring records
55
- * that only the FIRST provider is measured to refuse honestly, and *"the ones below it answer
56
- * unanswerable questions with plausible specs"*. So when the top rung is rate-limited the
57
- * ladder falls through β€” correctly, a Query module that dies whenever one free tier 429s is
58
- * worse β€” and the person is then reading a confident, well-formed view built by a model that
59
- * does not know how to say "this database cannot answer that". This flag is the only thing
60
- * that distinguishes those two answers on screen.
61
- */
62
- fallback?: boolean;
63
- }
64
-
65
- /** 4xx text is POLICY the reader needs ("this database does not have …"); a 5xx's
66
- * text is the server's internals and is never shown. Same split as alertsApi. */
67
- export function errorMessage(status: number, message?: string): string {
68
- if (status >= 500 || !message) {
69
- return status >= 500
70
- ? "Something went wrong on our side. Try again in a moment."
71
- : `The server answered ${status}.`;
72
- }
73
- return message;
74
- }
75
-
76
- async function call<T>(
77
- path: string,
78
- init: RequestInit,
79
- read: (body: unknown) => T
80
- ): Promise<Result<T>> {
81
- let res: Response;
82
- try {
83
- res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
84
- } catch {
85
- return { ok: false, status: 0, message: "Cannot reach the server." };
86
- }
87
- const body = (await res.json().catch(() => null)) as unknown;
88
- if (!res.ok) {
89
- const detail = (body as { error?: { message?: string } } | null)?.error?.message;
90
- return { ok: false, status: res.status, message: errorMessage(res.status, detail) };
91
- }
92
- return { ok: true, value: read(body) };
93
- }
94
-
95
- const json = (data: unknown): RequestInit => ({
96
- method: "POST",
97
- headers: { "Content-Type": "application/json" },
98
- body: JSON.stringify(data),
99
- });
100
-
101
- const str = (v: unknown): string => (typeof v === "string" ? v : "");
102
-
103
- /** Fails CLOSED: an unreadable answer is an empty list, never a half-parsed one. */
104
- export function parseSaved(body: unknown): SavedQuery[] {
105
- const rows = (body as { views?: unknown[] } | null)?.views;
106
- if (!Array.isArray(rows)) return [];
107
- return rows
108
- .map((r) => r as Record<string, unknown>)
109
- .filter((r) => r && str(r.id) && str(r.scope))
110
- .map((r) => ({
111
- id: str(r.id),
112
- scope: str(r.scope),
113
- viewId: str(r.viewId) || str(r.id),
114
- name: str(r.name) || "Query",
115
- kind: str(r.kind) || "grid",
116
- question: str(r.question),
117
- explain: str(r.explain),
118
- createdAt: str(r.createdAt) || undefined,
119
- }));
120
- }
121
-
122
- /** `GET /query`: the saved views AND the built-in scopes the build door accepts. */
123
- export interface QueryIndex {
124
- views: SavedQuery[];
125
- /**
126
- * The non-`ut_` scopes `POST /query/build` will accept, published by the server.
127
- *
128
- * β›” THE PICKER MIRRORS THIS INSTEAD OF GUESSING. The nav hands this page every granted entry,
129
- * which is a WIDER set than the build door accepts β€” a picker filtered on "not a surface" alone
130
- * offers databases that 404. `view_templates`' rule: a picker that offers what the door would
131
- * refuse is a control that lies.
132
- */
133
- builtins: string[];
134
- }
135
-
136
- export function fetchQueries(): Promise<Result<QueryIndex>> {
137
- return call("/query", {}, (b) => ({
138
- views: parseSaved(b),
139
- builtins: (((b as { builtins?: unknown } | null)?.builtins as unknown[]) ?? [])
140
- .filter((k): k is string => typeof k === "string"),
141
- }));
142
- }
143
-
144
- /**
145
- * Ask for a view. **This writes nothing** β€” the spec comes back for a person to
146
- * read before `saveQuery` stores it, which is the only check on the one failure
147
- * class no validator can see (a well-formed spec that answers a different
148
- * question). See `routes_query.py`'s header.
149
- */
150
- export function buildQuery(question: string, scope: string): Promise<Result<BuildResult>> {
151
- return call("/query/build", json({ question, scope }), (b) => {
152
- const r = (b || {}) as Record<string, unknown>;
153
- return {
154
- spec: r.spec ?? null,
155
- explain: str(r.explain) || null,
156
- refused: str(r.refused) || null,
157
- reason: str(r.reason) || undefined,
158
- id: str(r.id) || undefined,
159
- // ⚠ `=== true`, not truthy: an absent key must read as "not a fallback", and a server that
160
- // has not been redeployed yet sends nothing at all. Fail-quiet is right here β€” the wrong
161
- // direction would be warning about every answer on an older build.
162
- fallback: r.fallback === true,
163
- };
164
- });
165
- }
166
-
167
- export function saveQuery(
168
- question: string,
169
- scope: string,
170
- spec: unknown
171
- ): Promise<Result<SavedQuery>> {
172
- return call("/query/save", json({ question, scope, spec }), (b) => parseSaved({ views: [b] })[0]);
173
- }
174
-
175
- export function deleteQuery(id: string): Promise<Result<true>> {
176
- return call(`/query/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const);
177
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Query's public contract. Assistant owns the conversation; Query owns only the
2
+ // immutable, per-user artefacts the conversation creates.
3
+ import { API_V1, CREDENTIALS } from "../apiContract";
4
+
5
+ export type Result<T> =
6
+ | { ok: true; value: T }
7
+ | { ok: false; status: number; message: string };
8
+
9
+ export interface QuerySource {
10
+ database: string;
11
+ label: string;
12
+ source_kind: string;
13
+ source_version: unknown;
14
+ retrieved_at: string;
15
+ fields: Array<{ key: string; label?: string; type?: string }>;
16
+ filters: unknown;
17
+ permission_scope_applied: boolean;
18
+ }
19
+
20
+ export interface QueryCitation {
21
+ id: string;
22
+ href: string;
23
+ database: string;
24
+ snapshot: { kind: string; version: unknown };
25
+ fields: string[];
26
+ filters: unknown;
27
+ permission_scope_applied: boolean;
28
+ aggregation: { op: string; field?: string | null };
29
+ contributing_record_count: number;
30
+ retrieved_at: string;
31
+ }
32
+
33
+ export interface SavedQuery {
34
+ id: string;
35
+ viewId: string;
36
+ scope: string;
37
+ name: string;
38
+ kind: string;
39
+ question: string;
40
+ explain: string;
41
+ threadId: string;
42
+ createdAt: string;
43
+ virtual: true;
44
+ source: QuerySource;
45
+ view: Record<string, unknown>;
46
+ citationIds: string[];
47
+ numeric: { label: string; value: number | null; contributing_record_count: number };
48
+ }
49
+
50
+ export interface QueryThread {
51
+ id: string;
52
+ title: string;
53
+ createdAt: string;
54
+ updatedAt: string;
55
+ sources: string[];
56
+ model: string;
57
+ activeViewId?: string;
58
+ }
59
+
60
+ export interface QueryMessage {
61
+ id: string;
62
+ threadId: string;
63
+ role: "user" | "assistant";
64
+ content: string;
65
+ createdAt: string;
66
+ targetDatabase?: string;
67
+ sources?: string[];
68
+ requestedModel?: string;
69
+ model?: string | null;
70
+ reason?: string | null;
71
+ viewId?: string | null;
72
+ citationIds?: string[];
73
+ numeric?: SavedQuery["numeric"] | null;
74
+ }
75
+
76
+ /**
77
+ * A source this caller HOLDS, and whether it can actually be asked.
78
+ *
79
+ * ⭐ `permitted` and `answerable` are different questions and the picker needs the second.
80
+ * Measured on tenant #0: twelve chips offered, two answerable β€” the rest are the `ut_odoo_*`
81
+ * grids, whose rows live uncapped in the connector mirror and are read THROUGH it, which the
82
+ * Assistant's read boundary refuses by design. `reason` is what the chip says instead of making
83
+ * the reader spend a prompt to find out.
84
+ */
85
+ export interface QuerySourceStatus {
86
+ database: string;
87
+ answerable: boolean;
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
+
100
+ export interface ChatResult {
101
+ thread: QueryThread;
102
+ userMessage: QueryMessage;
103
+ message: QueryMessage;
104
+ view: SavedQuery | null;
105
+ citations: QueryCitation[];
106
+ }
107
+
108
+ /** The cross-fence B contract: source reads and virtual workspace writes are separate. */
109
+ export interface QueryVirtualBinding {
110
+ artifactId: string;
111
+ dataScope: string;
112
+ workspaceBinding: { kind: "query"; key: string };
113
+ mutation: QueryMutationPolicy;
114
+ source: QuerySource;
115
+ view: Record<string, unknown>;
116
+ citationIds: string[];
117
+ /** Complete, clickable provenance for this exact immutable virtual artefact. */
118
+ citations: QueryCitation[];
119
+ }
120
+
121
+ /**
122
+ * Grid previews cannot mutate Query artefacts. A new Assistant prompt creates a new immutable
123
+ * artefact; the existing DELETE endpoint is Query-history removal, never a grid event transport.
124
+ */
125
+ export type QueryMutationOperation = "create" | "update" | "delete";
126
+ export interface QueryMutationRefusal {
127
+ durable: false;
128
+ transport: "none";
129
+ code: "query_view_immutable";
130
+ message: string;
131
+ }
132
+ export interface QueryDeleteMutation {
133
+ durable: true;
134
+ transport: "query_event";
135
+ endpoint: "POST /query/{workspaceBinding.key}/events";
136
+ event: "view_delete";
137
+ }
138
+ export interface QueryMutationPolicy {
139
+ create: QueryMutationRefusal;
140
+ update: QueryMutationRefusal;
141
+ delete: QueryDeleteMutation;
142
+ }
143
+ export interface QueryMutationResult extends QueryMutationRefusal {
144
+ artifactId: string;
145
+ workspaceKey: string;
146
+ operation: "create" | "update";
147
+ }
148
+
149
+ const IMMUTABLE_MUTATION: QueryMutationRefusal = {
150
+ durable: false,
151
+ transport: "none",
152
+ code: "query_view_immutable",
153
+ message: "This Query view is immutable. Ask the AI assistant to create a revised view.",
154
+ };
155
+ export const QUERY_MUTATION_POLICY: QueryMutationPolicy = {
156
+ create: IMMUTABLE_MUTATION,
157
+ update: IMMUTABLE_MUTATION,
158
+ delete: {
159
+ durable: true,
160
+ transport: "query_event",
161
+ endpoint: "POST /query/{workspaceBinding.key}/events",
162
+ event: "view_delete",
163
+ },
164
+ };
165
+
166
+ /** The durable Query-only event payload; no source scope is permitted here. */
167
+ export interface QueryWorkspaceEvent {
168
+ id?: string;
169
+ type: "view_create" | "view_upsert" | "view_delete";
170
+ viewId?: string;
171
+ view?: Record<string, unknown>;
172
+ }
173
+ export interface QueryWorkspaceMutation {
174
+ workspaceBinding: { kind: "query"; key: string };
175
+ event: "view_delete";
176
+ deleted: string;
177
+ }
178
+
179
+ export const QUERY_BINDING_EVENT = "aios:query-virtual-binding";
180
+
181
+ /** The local refusal B must use for every grid create, update, or delete attempt. */
182
+ export function refuseQueryMutation(
183
+ binding: Pick<QueryVirtualBinding, "artifactId" | "workspaceBinding">,
184
+ operation: "create" | "update",
185
+ ): QueryMutationResult {
186
+ return {
187
+ ...IMMUTABLE_MUTATION,
188
+ artifactId: binding.artifactId,
189
+ workspaceKey: binding.workspaceBinding.key,
190
+ operation,
191
+ };
192
+ }
193
+
194
+ export function bindingFor(view: SavedQuery, citations: QueryCitation[]): QueryVirtualBinding {
195
+ const ids = new Set(view.citationIds);
196
+ return {
197
+ artifactId: view.id,
198
+ dataScope: view.source.database,
199
+ workspaceBinding: { kind: "query", key: view.id },
200
+ mutation: QUERY_MUTATION_POLICY,
201
+ source: view.source,
202
+ view: view.view,
203
+ citationIds: view.citationIds,
204
+ citations: citations.filter((citation) => ids.has(citation.id) && citation.database === view.source.database),
205
+ };
206
+ }
207
+
208
+ export function errorMessage(status: number, message?: string): string {
209
+ if (status >= 500 || !message) {
210
+ return status >= 500
211
+ ? "Something went wrong on our side. Try again in a moment."
212
+ : `The server answered ${status}.`;
213
+ }
214
+ return message;
215
+ }
216
+
217
+ async function call<T>(path: string, init: RequestInit, read: (body: unknown) => T): Promise<Result<T>> {
218
+ let res: Response;
219
+ try {
220
+ res = await fetch(`${API_V1}${path}`, { credentials: CREDENTIALS, ...init });
221
+ } catch {
222
+ return { ok: false, status: 0, message: "Cannot reach the server." };
223
+ }
224
+ const body = (await res.json().catch(() => null)) as unknown;
225
+ if (!res.ok) {
226
+ const detail = (body as { error?: { message?: string } } | null)?.error?.message;
227
+ return { ok: false, status: res.status, message: errorMessage(res.status, detail) };
228
+ }
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") : [];
238
+
239
+ function source(value: unknown): QuerySource | null {
240
+ const row = value as Record<string, unknown> | null;
241
+ if (!row || !str(row.database) || !str(row.label) || !str(row.retrieved_at)) return null;
242
+ return {
243
+ database: str(row.database), label: str(row.label), source_kind: str(row.source_kind),
244
+ source_version: row.source_version, retrieved_at: str(row.retrieved_at),
245
+ fields: rows(row.fields).filter((field) => !!str(field.key)).map((field) => ({
246
+ key: str(field.key), label: str(field.label) || undefined, type: str(field.type) || undefined,
247
+ })),
248
+ filters: row.filters, permission_scope_applied: row.permission_scope_applied === true,
249
+ };
250
+ }
251
+
252
+ function saved(value: unknown): SavedQuery | null {
253
+ const row = value as Record<string, unknown> | null;
254
+ const src = source(row?.source);
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>,
262
+ citationIds: (row.citationIds as unknown[] || []).filter((id): id is string => typeof id === "string"),
263
+ numeric: ((row.numeric && typeof row.numeric === "object" ? row.numeric : {}) as SavedQuery["numeric"]),
264
+ };
265
+ }
266
+
267
+ function message(value: unknown): QueryMessage | null {
268
+ const row = value as Record<string, unknown> | null;
269
+ const role = str(row?.role);
270
+ if (!row || !str(row.id) || !str(row.threadId) || !str(row.content) || !str(row.createdAt)
271
+ || (role !== "user" && role !== "assistant")) return null;
272
+ return { id: str(row.id), threadId: str(row.threadId), role, content: str(row.content), createdAt: str(row.createdAt),
273
+ targetDatabase: str(row.targetDatabase) || undefined,
274
+ sources: (row.sources as unknown[] || []).filter((v): v is string => typeof v === "string"),
275
+ requestedModel: str(row.requestedModel) || undefined, model: str(row.model) || 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
+
282
+ function thread(value: unknown): QueryThread | null {
283
+ const row = value as Record<string, unknown> | null;
284
+ if (!row || !str(row.id) || !str(row.createdAt) || !str(row.updatedAt)) return null;
285
+ return { id: str(row.id), title: str(row.title) || "New chat", createdAt: str(row.createdAt),
286
+ updatedAt: str(row.updatedAt), sources: (row.sources as unknown[] || []).filter((v): v is string => typeof v === "string"),
287
+ model: str(row.model) || "auto", activeViewId: str(row.activeViewId) || undefined };
288
+ }
289
+
290
+ function citation(value: unknown): QueryCitation | null {
291
+ const row = value as Record<string, unknown> | null;
292
+ const snap = row?.snapshot as Record<string, unknown> | null;
293
+ if (!row || !snap || !str(row.id) || !str(row.href) || !str(row.database) || !str(row.retrieved_at)) return null;
294
+ return { id: str(row.id), href: str(row.href), database: str(row.database),
295
+ snapshot: { kind: str(snap.kind), version: snap.version },
296
+ fields: (row.fields as unknown[] || []).filter((v): v is string => typeof v === "string"),
297
+ filters: row.filters, permission_scope_applied: row.permission_scope_applied === true,
298
+ aggregation: (row.aggregation && typeof row.aggregation === "object" ? row.aggregation : {}) as QueryCitation["aggregation"],
299
+ contributing_record_count: Number(row.contributing_record_count) || 0, retrieved_at: str(row.retrieved_at) };
300
+ }
301
+
302
+ export function parseIndex(body: unknown): QueryIndex {
303
+ const row = body as Record<string, unknown> | null;
304
+ return {
305
+ threads: rows(row?.threads).map(thread).filter((v): v is QueryThread => !!v),
306
+ messages: rows(row?.messages).map(message).filter((v): v is QueryMessage => !!v),
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
+ })),
313
+ };
314
+ }
315
+
316
+ export function fetchQueries(): Promise<Result<QueryIndex>> {
317
+ return call("/query", {}, parseIndex);
318
+ }
319
+
320
+ export function submitChat(body: {
321
+ question: string; database: string; sources: string[]; threadId?: string; model: string;
322
+ }): Promise<Result<ChatResult>> {
323
+ return call("/query/chat", json(body), (raw) => {
324
+ const row = raw as Record<string, unknown> | null;
325
+ return {
326
+ thread: thread(row?.thread) as QueryThread,
327
+ userMessage: message(row?.userMessage) as QueryMessage,
328
+ message: message(row?.message) as QueryMessage,
329
+ view: saved(row?.view),
330
+ citations: rows(row?.citations).map(citation).filter((v): v is QueryCitation => !!v),
331
+ };
332
+ });
333
+ }
334
+
335
+ export function deleteQuery(id: string): Promise<Result<true>> {
336
+ return call(`/query/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const);
337
+ }
338
+
339
+ /** Remove one chat. The Query views it produced SURVIVE β€” they live in Query, not in the chat. */
340
+ 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.
347
+ * The server accepts delete and explicitly refuses create/update because AI artefacts are immutable.
348
+ */
349
+ export function mutateQueryWorkspace(binding: QueryVirtualBinding, event: QueryWorkspaceEvent): Promise<Result<QueryWorkspaceMutation>> {
350
+ const key = binding.workspaceBinding.key;
351
+ return call(`/query/${encodeURIComponent(key)}/events`, json({
352
+ workspaceBinding: binding.workspaceBinding, event,
353
+ }), (raw) => {
354
+ const row = raw as Record<string, unknown> | null;
355
+ return {
356
+ workspaceBinding: { kind: "query", key: str((row?.workspaceBinding as Record<string, unknown> | null)?.key) },
357
+ event: str(row?.event) as QueryWorkspaceMutation["event"],
358
+ deleted: str(row?.deleted),
359
+ };
360
+ });
361
+ }
web/src/query/queryParts.tsx CHANGED
@@ -1,116 +1,135 @@
1
- // ---------------------------------------------------------------------------
2
- // query/queryParts.tsx β€” the two pieces the Query module ADDS to the database frame,
3
- // in a file that drags no grid behind it.
4
- //
5
- // β›” WHY THEY ARE NOT IN `QueryPage.tsx`. That file imports `CustomerGrid`, which is
6
- // the whole spreadsheet engine (glide, the formula parser, the view modes). The render
7
- // leg compiles a test file with bare `npx tsc --ignoreConfig` and runs it under plain
8
- // node with CSS stubbed β€” no bundler, no browser, no DOM β€” so a test importing
9
- // `QueryPage` would pull that graph in and die on the first thing that expects a
10
- // window. The parts that a person actually needs to SEE painted (the picker that says
11
- // which view you are looking at, and the empty state that names where views come from)
12
- // therefore live in a leaf with no heavy imports, and BOTH `QueryPage` and the render
13
- // test import them from here.
14
- //
15
- // ⚠ This is the same shape `verify_ui.render_smoke` already relies on elsewhere: the
16
- // testable surface is the leaf, and the mount is proved by the wiring gate instead.
17
- // ---------------------------------------------------------------------------
18
 
 
 
19
  import { ASSISTANT_ROUTE } from "../shell/nav";
 
20
  import type { SavedQuery } from "./queryApi";
21
 
22
- /**
23
- * Human words for a view kind.
24
- *
25
- * ⚠ NO CLIENT UNION OVER `kind` β€” it is the server's vocabulary and arrives as a string
26
- * (the wave-9 law). A kind absent from here renders AS ITSELF; dropping the row instead
27
- * would make "the server added a kind" look like "the assistant lost my view".
28
- */
29
- export const KIND_LABEL: Record<string, string> = {
30
- grid: "Table",
31
- list: "List",
32
- chart: "Chart",
33
- kanban: "Board",
34
- calendar: "Calendar",
35
- timeseries: "Time series",
36
- map: "Map",
 
 
 
 
 
 
 
 
 
37
  };
38
 
 
 
 
 
 
 
 
39
  /**
40
- * The picker that sits INSIDE the database header.
 
 
 
 
 
 
 
 
 
 
41
  *
42
- * β›” INSIDE `DbHead`, NOT IN A STRIP ABOVE IT. The owner's instruction is that this module
43
- * *"exactly BE looking like the database module, COMPLETELY"*; you cannot look at a
44
- * database without saying which one, and a second bar above the header is the difference
45
- * between "the same frame" and "the frame with something bolted on".
 
 
46
  */
47
- export function QueryPicker({ views, activeId, dbLabelOf, onPick }: {
48
- views: SavedQuery[];
49
  activeId: string;
50
- dbLabelOf: (key: string) => string;
51
- onPick: (qid: string) => void;
52
  }) {
 
 
 
53
  return (
54
- <label className="qy-pick">
55
- <span className="qy-pick-label">View</span>
56
- <select
57
- className="qy-pick-select"
58
- value={activeId}
59
- onChange={(e) => onPick(e.currentTarget.value)}
60
- >
61
- {views.map((v) => (
62
- <option key={v.id} value={v.id}>
63
- {v.name} β€” {KIND_LABEL[v.kind] ?? v.kind} Β· {dbLabelOf(v.scope)}
64
- </option>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
65
  ))}
66
- </select>
67
- </label>
 
68
  );
69
  }
70
 
71
  /**
72
- * Nothing to show β€” and WHICH nothing.
73
- *
74
- * β›” IT NAMES THE ONE PLACE VIEWS COME FROM, because that IS this module's whole
75
- * difference from a database (owner: *"the user isn't the one creating the view, it's the
76
- * AI that they prompt in the AI assistant module"*). A bare "no views" would leave a
77
- * person hunting for a create button this surface deliberately does not have.
78
  *
79
- * β›”β›” THREE FACTS, THREE SENTENCES, and the third is the one that is easy to lose. "Still
80
- * asking", "you have none", and "you have some and this page could not name their databases"
81
- * are different states, and collapsing the last into the middle would tell somebody their work
82
- * is gone because `/nav` came back `degraded`. That degraded 200 is a REAL state this shell
83
- * already handles elsewhere; it must not read as deletion here.
84
- *
85
- * ⚠ The spinner is the shared `lp-spin` mark β€” R6 forbids load TEXT, and `verify_icons`
86
- * asserts it across every source file.
87
  */
88
- export function QueryEmpty({ loaded, unnamed = 0 }: { loaded: boolean; unnamed?: number }) {
 
89
  return (
90
- <div className="qy-empty">
91
- {!loaded ? (
92
- <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" />
93
- ) : unnamed > 0 ? (
94
- <>
95
- <h1 className="qy-empty-h">Your views could not be opened</h1>
96
- <p className="qy-empty-p">
97
- The assistant has built {unnamed === 1 ? "a view" : `${unnamed} views`}, but this
98
- workspace’s database list did not load, so there is nothing to open them against.
99
- Nothing has been lost β€” reload to try again.
100
- </p>
101
- </>
102
- ) : (
103
- <>
104
- <h1 className="qy-empty-h">No views yet</h1>
105
- <p className="qy-empty-p">
106
- Views here are built by the assistant, not by hand. Ask it a question about one of
107
- your databases and keep the answer β€” it opens here, as an ordinary database.
108
- </p>
109
- <a className="qy-empty-go" href={`#/${ASSISTANT_ROUTE}`}>
110
- Open the AI assistant
111
- </a>
112
- </>
113
- )}
114
  </div>
115
  );
116
  }
 
1
+ import { useState } from "react";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
 
3
+ 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 (
12
+ <div className="qy-empty">
13
+ {!loaded ? <span className="lp-spin lp-spin--lg" role="status" aria-label="Loading" /> : unnamed > 0 ? (
14
+ <>
15
+ <h1 className="qy-empty-h">Your views could not be opened</h1>
16
+ <p className="qy-empty-p">The source database is no longer in your current permission scope. Nothing was changed.</p>
17
+ </>
18
+ ) : (
19
+ <>
20
+ <h1 className="qy-empty-h">No Query views yet</h1>
21
+ <p className="qy-empty-p">Ask the assistant for a table or chart. It creates the Query view immediately.</p>
22
+ <a className="qy-empty-go" href={`#/${ASSISTANT_ROUTE}`}>Open the AI assistant</a>
23
+ </>
24
+ )}
25
+ </div>
26
+ );
27
+ }
28
+
29
+ /** The kind glyph a Query row wears β€” the same vocabulary the grid's own view rail uses. */
30
+ const KIND_MARK: Record<string, string> = {
31
+ grid: "Table", list: "List", chart: "Chart", kanban: "Board",
32
+ calendar: "Calendar", timeseries: "Time series", map: "Map",
33
  };
34
 
35
+ export interface QueryGroup {
36
+ database: string;
37
+ label: string;
38
+ icon?: NavEntry["icon"];
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
45
+ * correspond to the relevant database. So we need to remove that dumb dropdown that suddenly
46
+ * appears at the top right of the Database when the AI does query. That's not how we should do
47
+ * the navigation."*
48
+ *
49
+ * β›” THE DROPDOWN WAS NOT THE DEFECT β€” THE ABSENCE OF NAVIGATION WAS. Deleting it (which the
50
+ * previous pass did) left Query with no way to move between artefacts at all: the page picked
51
+ * `known[0]` and every other view the assistant had ever built was unreachable. A surface with
52
+ * one implicit selection is not "no dropdown", it is no navigation, and the owner asked for the
53
+ * navigation to be done differently rather than removed.
54
  *
55
+ * ⚠ AND THIS IS WHERE A DATABASE PUTS ITS VIEWS. `dbFrame.tsx` carries the owner's earlier
56
+ * sentence β€” *"the query module should exactly BE looking like the database module, COMPLETELY,
57
+ * with all the views etc."* β€” so the rail sits in the slot `CustomerGrid`'s own `ViewSidebar`
58
+ * occupies on every other database, at the same `--lp-rail-w`. It is a separate component only
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}>
77
+ <p className="qy-rail-db">
78
+ <span className="qy-rail-db-mark" aria-hidden="true">
79
+ {group.icon ? <FolderMark icon={group.icon} size={14} /> : <DbIcon />}
80
+ </span>
81
+ <span className="qy-rail-db-name">{group.label}</span>
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>
111
  ))}
112
+ </div>
113
+ <a className="qy-rail-ask" href={`#/${ASSISTANT_ROUTE}`}>Ask the assistant</a>
114
+ </aside>
115
  );
116
  }
117
 
118
  /**
119
+ * The provenance R9 requires, in one readable line rather than a JSON dump.
 
 
 
 
 
120
  *
121
+ * ⚠ It is a DISCLOSURE, not a banner: shut by default, so a surface whose job is to look like a
122
+ * database looks like one, and one click away for the reader who wants to audit the number.
 
 
 
 
 
 
123
  */
124
+ export function QueryProvenance({ view, detail }: { view: SavedQuery; detail: string }) {
125
+ const [open, setOpen] = useState(false);
126
  return (
127
+ <div className={"qy-prov" + (open ? " is-open" : "")}>
128
+ <button type="button" className="qy-prov-btn" aria-expanded={open} onClick={() => setOpen(!open)}>
129
+ <span className="qy-prov-q">{view.question}</span>
130
+ <span className="qy-prov-toggle">{open ? "Hide sources" : "Sources"}</span>
131
+ </button>
132
+ {open ? <p className="qy-prov-detail">{detail}</p> : null}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
133
  </div>
134
  );
135
  }
web/src/shell/Shell.tsx CHANGED
@@ -327,31 +327,55 @@ const findAutomation = (entries: NavEntry[]) =>
327
  * original defect. Only a remembered, explicit `false` suppresses the placeholder. Same on a
328
  * blocked/parse-failed storage: fall back to reserving.
329
  */
330
- const RAIL_MEMORY_KEY = "aios-rail-slots";
331
-
332
- function railMemory(who: string | null): Record<string, boolean> {
333
- if (!who) return {};
334
- try {
335
- const all = JSON.parse(localStorage.getItem(RAIL_MEMORY_KEY) || "{}");
336
- const mine = all && typeof all === "object" ? all[who] : null;
337
- return mine && typeof mine === "object" ? (mine as Record<string, boolean>) : {};
338
- } catch {
339
- return {};
340
- }
341
- }
342
 
343
- function rememberRail(who: string | null, slots: Record<string, boolean>) {
344
- if (!who) return;
345
- try {
346
- const raw = localStorage.getItem(RAIL_MEMORY_KEY);
347
- const parsed = raw ? JSON.parse(raw) : {};
348
- const all = parsed && typeof parsed === "object" ? parsed : {};
349
- all[who] = slots;
350
- localStorage.setItem(RAIL_MEMORY_KEY, JSON.stringify(all));
351
- } catch {
352
- // Storage can be blocked. The rail then falls back to reserving the slot, which is the
353
- // safe direction: a placeholder that turns into a row beats a row that appears from nowhere.
354
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
355
  }
356
 
357
  function useHashRoute(): string {
@@ -1001,14 +1025,6 @@ function ShellFrame() {
1001
  * recording "absent" from a failed read would suppress the placeholder on the next load for a
1002
  * row the user actually has [[empty-answer-vs-unfinished-answer]].
1003
  */
1004
- useEffect(() => {
1005
- if (nav.phase !== "ready") return;
1006
- rememberRail(who, {
1007
- analyst: !!findAnalyst(nav.utility),
1008
- automation: !!findAutomation(nav.entries),
1009
- });
1010
- }, [nav, who]);
1011
-
1012
  // NO_ENTRIES, not a fresh `[]`: a new array identity on every render would
1013
  // re-run the hash effect below on every render for no reason.
1014
  const entries = nav.phase === "ready" ? nav.entries : NO_ENTRIES;
@@ -1654,31 +1670,28 @@ function ShellFrame() {
1654
  * on one row; a second copy is how the two rows start disagreeing about what `degraded` means
1655
  * [[one-question-two-normalizers]]. `present` is the ONLY per-row input.
1656
  *
1657
- * β›” WHAT THIS DOES NOT CLAIM. `silent` still collapses the slot, and it must: reserving space
1658
- * for a surface the server did not grant is a hard-coded row this frame refuses to have
1659
- * (see the file header). So an account that lacks Automation or the Analyst grant still sees
1660
- * ONE reflow when `/nav` lands β€” down from every account seeing one, every load.
 
 
 
 
 
1661
  */
1662
- type RailSlot = "row" | "pending" | "unavailable" | "silent";
1663
- const railWas = railMemory(who);
1664
- const railSlot = (present: boolean, remembered: string): RailSlot =>
1665
  present
1666
  ? "row"
1667
- : nav.phase === "loading" || nav.phase === "idle"
1668
- ? // ⭐ THE MEMORY, and it only ever SUPPRESSES. `false` means "this account did not have
1669
- // this row last time", which is the one case where reserving a slot creates the flash
1670
- // -then-collapse a reviewer caught in the first version of this fix. Unknown, or any
1671
- // other value, reserves β€” see `railMemory`'s note on why unknown is not false.
1672
- railWas[remembered] === false
1673
- ? "silent"
1674
- : "pending"
1675
- : nav.phase === "error"
1676
  ? "unavailable"
1677
- : nav.phase === "ready" && (nav.degraded?.length ?? 0) > 0
1678
- ? "unavailable"
1679
- : "silent";
1680
- const automationSlot: RailSlot = railSlot(!!automation, "automation");
1681
- const analystSlot: RailSlot = railSlot(!!analyst, "analyst");
1682
  const dbEntries = databaseEntries(entries);
1683
  // WAVE 23 C10 β€” the flyout's search. A plain label substring, case-folded: this list is at
1684
  // most a few dozen rows, so anything cleverer (fuzzy, ranked) would be a scoring function
@@ -1735,7 +1748,13 @@ function ShellFrame() {
1735
  </button>
1736
  </div>
1737
 
1738
- <nav className="shell-nav">
 
 
 
 
 
 
1739
  {/* ⭐ WAVE 23 item 9 (R7, contract C10) β€” HOME, the new landing, at the top of the rail.
1740
  An `<a>` to a CHROME route: it needs no grant because it renders nothing the server
1741
  did not already send (nav.ts' `CHROME_ROUTES` note carries the full argument, and
@@ -1784,25 +1803,6 @@ function ShellFrame() {
1784
  <SparkIcon />
1785
  <span className="shell-nav-label">AI assistant</span>
1786
  </button>
1787
- ) : analystSlot === "pending" ? (
1788
- /* ⭐⭐ W33-T22 (owner item 1) β€” THE ASSISTANT'S OWN SLOT, held from first paint.
1789
- β›” THIS IS THE ROW THE OWNER WAS ACTUALLY REPORTING. Without it, `/nav` landing
1790
- INSERTED ~34 px here and pushed Query / Inbox / Automation / Database / Connectors
1791
- down β€” so the row a reader sees "load separately" is the one with NO gate at all
1792
- (Connectors, `:1765`), which cannot arrive late and therefore must have MOVED.
1793
- ⚠ Same classes as Automation's pending arm on purpose: `.shell-nav-item.is-pending`
1794
- and `.shell-nav-item.is-pending .lp-spin{16px}` are GENERIC rules in index.css, and
1795
- `.shell-nav-assist` applies by class, so this placeholder is the same 34 px box the
1796
- real row will be. A new modifier class would need an index.css rule (B's file, C4)
1797
- and would render at a different height until it got one β€” which is the jump again. */
1798
- <div
1799
- className="shell-nav-item shell-nav-assist is-pending"
1800
- aria-busy="true"
1801
- aria-label="AI assistant is loading"
1802
- >
1803
- <span className="lp-spin" aria-hidden="true" />
1804
- <span className="shell-nav-label shell-nav-label--muted">AI assistant</span>
1805
- </div>
1806
  ) : analystSlot === "unavailable" ? (
1807
  /* β›” The payload never came, or came back `degraded`. Silence here is a claim that this
1808
  workspace has no assistant, and we do not know that β€” same argument as W31-T11's
@@ -1819,6 +1819,8 @@ function ShellFrame() {
1819
  <span className="shell-nav-label shell-nav-label--muted">AI assistant</span>
1820
  <span className="shell-nav-note-dot" aria-hidden="true" />
1821
  </div>
 
 
1822
  ) : null}
1823
 
1824
  {/* β›” THE "AI agent" ROW WAS HERE AND IS DELETED (owner, 2026-08-14): *"AI assistant
@@ -1924,19 +1926,6 @@ function ShellFrame() {
1924
  )}
1925
  <span className="shell-nav-label">{automation.label}</span>
1926
  </a>
1927
- ) : automationSlot === "pending" ? (
1928
- /* ⭐ W31-T11 β€” THE PENDING STATE, IN AUTOMATION'S OWN SLOT. The shared spinner mark
1929
- (R6: the mark, never the word "Loading…", which `verify_icons` enforces), on a row
1930
- that occupies the space the real one will. `aria-busy` is what makes it a pending
1931
- ROW to a screen reader rather than a decorative glyph. */
1932
- <div
1933
- className="shell-nav-item shell-nav-auto is-pending"
1934
- aria-busy="true"
1935
- aria-label="Automation is loading"
1936
- >
1937
- <span className="lp-spin" aria-hidden="true" />
1938
- <span className="shell-nav-label shell-nav-label--muted">Automation</span>
1939
- </div>
1940
  ) : automationSlot === "unavailable" ? (
1941
  /* β›” W31-T11 β€” NOT LOADING, AND NOT ABSENT-ON-PURPOSE: the server said this payload
1942
  is incomplete (`degraded`), or it never answered. The row states that rather than
@@ -1953,6 +1942,8 @@ function ShellFrame() {
1953
  <span className="shell-nav-label shell-nav-label--muted">Automation</span>
1954
  <span className="shell-nav-note-dot" aria-hidden="true" />
1955
  </div>
 
 
1956
  ) : null}
1957
 
1958
  {/* ⭐ WAVE 23 item 9 (R7) β€” THE DATABASE BUTTON, and the end of the always-on band.
@@ -2345,14 +2336,8 @@ function ShellFrame() {
2345
  document.body
2346
  )
2347
  : null}
2348
- {/* Wave 17 item 3 (R6) β€” the mark, never the word. "Loading…" under an
2349
- empty rail told the reader what they could already see, and it read
2350
- as a nav ITEM for the beat before it vanished. */}
2351
- {nav.phase === "loading" ? (
2352
- <div className="shell-nav-note is-spin">
2353
- <span className="lp-spin" role="status" aria-label="Loading" />
2354
- </div>
2355
- ) : null}
2356
  {/* Honest, and it names the fix. Inventing a nav here would show
2357
  surfaces the server never granted. */}
2358
  {nav.phase === "error" ? (
 
327
  * original defect. Only a remembered, explicit `false` suppresses the placeholder. Same on a
328
  * blocked/parse-failed storage: fall back to reserving.
329
  */
330
+ // W33-T75 supersedes the T22 account-memory approach above. A fresh account has no remembered
331
+ // row set, so optional destinations retain an inert final skeleton rather than collapsing after
332
+ // `/nav` resolves. The skeleton has no label, route, focus target, or source of permission.
 
 
 
 
 
 
 
 
 
333
 
334
+ /**
335
+ * ⭐⭐ W33-T75 (owner item 1, `reference/ERROR 6.png`) β€” THE RAIL PAINTS ONCE.
336
+ *
337
+ * β›” THE ACCEPTANCE TEST IS NOT "THE SPINNER IS GONE", IT IS "NO ROW IS EVER IN A DIFFERENT
338
+ * STATE FROM ITS SIBLINGS." The owner has reported this four times. Every previous answer kept
339
+ * the same shape β€” five finished rows plus one or two rows wearing a *distinguishable* loading
340
+ * state β€” and shrank the difference: `/nav` went 11.0 s β†’ 26 ms (still two paints, because
341
+ * `:911` makes `entries` `NO_ENTRIES` until `nav.phase === "ready"`, so first paint happens
342
+ * while "does this workspace have Automation / an Analyst?" is unknown at ANY speed); then W31-T11
343
+ * gave Automation a spinner slot; then W33-T22 gave the Assistant one too. `ERROR 6.png` is the
344
+ * result: Home / Query / Inbox / Database / Connectors drawn in ink, **AI assistant and Automation
345
+ * drawn as spinners**, and a sixth spinner below Connectors. Two states in one list is exactly
346
+ * what "the navigation loads separately" describes, and a placeholder that ANNOUNCES itself as
347
+ * pending is a second state however tall it is.
348
+ *
349
+ * β›” SO THE FIX IS AT THE OTHER END: while `/nav` is in flight the rail draws THIS and no rows at
350
+ * all. One skeleton, seven identical bars, nothing claiming to be a destination β€” then the real
351
+ * rail arrives in a single paint. There is no frame in which one row is further along than
352
+ * another, which is the only form of the sentence that cannot come back a fifth time.
353
+ *
354
+ * ⚠ WHAT IT COSTS, STATED RATHER THAN DISCOVERED: Home / Query / Inbox / Database / Connectors
355
+ * are not clickable for the length of the `/nav` round trip, where before they were. That is a
356
+ * real trade and it is the owner's own priority β€” measured, `/nav` is **0.77 s cold on the
357
+ * deployed build and 26 ms warm** (wave 33 close record), so the window is short; and the four
358
+ * waves of reports are about the rail's shape, never about waiting for it. `phase: "error"` and
359
+ * `degraded` are UNCHANGED: they draw the real rows with their unavailable arms, because there
360
+ * the answer has arrived and is bad news, which is a fact worth showing rather than a wait.
361
+ *
362
+ * ⚠ The count is the rail's own maximum (7), not a guess: Home · AI assistant · Query · Inbox ·
363
+ * Automation Β· Database Β· Connectors. A skeleton one bar taller than the settled rail is a
364
+ * shrink, never the appear-from-nowhere shove W33-T22 was written about.
365
+ */
366
+ const RAIL_SKELETON_ROWS = 7;
367
+
368
+ function RailSkeleton() {
369
+ return (
370
+ <div className="shell-rail-skeleton" role="status" aria-label="Loading navigation">
371
+ {Array.from({ length: RAIL_SKELETON_ROWS }, (_, i) => (
372
+ <div className="shell-rail-skeleton-row" key={i} aria-hidden="true">
373
+ <span className="shell-rail-skeleton-mark" />
374
+ <span className="shell-rail-skeleton-bar" />
375
+ </div>
376
+ ))}
377
+ </div>
378
+ );
379
  }
380
 
381
  function useHashRoute(): string {
 
1025
  * recording "absent" from a failed read would suppress the placeholder on the next load for a
1026
  * row the user actually has [[empty-answer-vs-unfinished-answer]].
1027
  */
 
 
 
 
 
 
 
 
1028
  // NO_ENTRIES, not a fresh `[]`: a new array identity on every render would
1029
  // re-run the hash effect below on every render for no reason.
1030
  const entries = nav.phase === "ready" ? nav.entries : NO_ENTRIES;
 
1670
  * on one row; a second copy is how the two rows start disagreeing about what `degraded` means
1671
  * [[one-question-two-normalizers]]. `present` is the ONLY per-row input.
1672
  *
1673
+ * ⭐⭐ W33-T75 SUPERSEDES BOTH, AND DELETES THE `pending` STATE ENTIRELY. See `RailSkeleton`:
1674
+ * while `/nav` is in flight this rail draws NO rows, so there is no in-flight row state left
1675
+ * to name. What remains is what the ANSWER says β€” the row, an `unavailable` arm when the
1676
+ * answer was bad, and an inert `silent` box when the answer was "this workspace does not have
1677
+ * it" (which keeps the geometry identical either way, T22's own finding).
1678
+ *
1679
+ * β›” `railLoading` IS THE SAME PREDICATE, WRITTEN ONCE. It is what the rail renders the
1680
+ * skeleton on AND what makes the slot function unreachable, so the two cannot come to disagree
1681
+ * about when the nav is settled [[one-question-two-normalizers]].
1682
  */
1683
+ type RailSlot = "row" | "unavailable" | "silent";
1684
+ const railLoading = nav.phase === "loading" || nav.phase === "idle";
1685
+ const railSlot = (present: boolean): RailSlot =>
1686
  present
1687
  ? "row"
1688
+ : nav.phase === "error"
1689
+ ? "unavailable"
1690
+ : nav.phase === "ready" && (nav.degraded?.length ?? 0) > 0
 
 
 
 
 
 
1691
  ? "unavailable"
1692
+ : "silent";
1693
+ const automationSlot: RailSlot = railSlot(!!automation);
1694
+ const analystSlot: RailSlot = railSlot(!!analyst);
 
 
1695
  const dbEntries = databaseEntries(entries);
1696
  // WAVE 23 C10 β€” the flyout's search. A plain label substring, case-folded: this list is at
1697
  // most a few dozen rows, so anything cleverer (fuzzy, ranked) would be a scoring function
 
1748
  </button>
1749
  </div>
1750
 
1751
+ <nav className={"shell-nav" + (railLoading ? " is-rail-loading" : "")}>
1752
+ {/* ⭐⭐ W33-T75 β€” THE ONE IN-FLIGHT STATE. `is-rail-loading` hides every sibling below
1753
+ (`navExtras.css`), so a row cannot paint ahead of its neighbours. Rendering the rows
1754
+ and hiding them β€” rather than not rendering them β€” is deliberate: the flyout portal,
1755
+ the tooltips and the account menu all hang off this subtree, and unmounting them for
1756
+ the length of a fetch would tear down state that has nothing to do with the nav. */}
1757
+ {railLoading ? <RailSkeleton /> : null}
1758
  {/* ⭐ WAVE 23 item 9 (R7, contract C10) β€” HOME, the new landing, at the top of the rail.
1759
  An `<a>` to a CHROME route: it needs no grant because it renders nothing the server
1760
  did not already send (nav.ts' `CHROME_ROUTES` note carries the full argument, and
 
1803
  <SparkIcon />
1804
  <span className="shell-nav-label">AI assistant</span>
1805
  </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1806
  ) : analystSlot === "unavailable" ? (
1807
  /* β›” The payload never came, or came back `degraded`. Silence here is a claim that this
1808
  workspace has no assistant, and we do not know that β€” same argument as W31-T11's
 
1819
  <span className="shell-nav-label shell-nav-label--muted">AI assistant</span>
1820
  <span className="shell-nav-note-dot" aria-hidden="true" />
1821
  </div>
1822
+ ) : analystSlot === "silent" ? (
1823
+ <div className="shell-nav-item shell-nav-assist is-rail-skeleton" aria-hidden="true" />
1824
  ) : null}
1825
 
1826
  {/* β›” THE "AI agent" ROW WAS HERE AND IS DELETED (owner, 2026-08-14): *"AI assistant
 
1926
  )}
1927
  <span className="shell-nav-label">{automation.label}</span>
1928
  </a>
 
 
 
 
 
 
 
 
 
 
 
 
 
1929
  ) : automationSlot === "unavailable" ? (
1930
  /* β›” W31-T11 β€” NOT LOADING, AND NOT ABSENT-ON-PURPOSE: the server said this payload
1931
  is incomplete (`degraded`), or it never answered. The row states that rather than
 
1942
  <span className="shell-nav-label shell-nav-label--muted">Automation</span>
1943
  <span className="shell-nav-note-dot" aria-hidden="true" />
1944
  </div>
1945
+ ) : automationSlot === "silent" ? (
1946
+ <div className="shell-nav-item shell-nav-auto is-rail-skeleton" aria-hidden="true" />
1947
  ) : null}
1948
 
1949
  {/* ⭐ WAVE 23 item 9 (R7) β€” THE DATABASE BUTTON, and the end of the always-on band.
 
2336
  document.body
2337
  )
2338
  : null}
2339
+ {/* W33-T75: loading is represented only by the two row-local slots above. A separate
2340
+ nav-level spinner here sat beneath Connectors and looked like a late eighth row. */}
 
 
 
 
 
 
2341
  {/* Honest, and it names the fix. Inventing a nav here would show
2342
  surfaces the server never granted. */}
2343
  {nav.phase === "error" ? (
web/src/shell/navExtras.css CHANGED
@@ -43,6 +43,65 @@
43
  background: transparent;
44
  }
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  .shell-nav-row > .shell-dots,
47
  .shell-nav-folder > .shell-dots {
48
  flex: 0 0 auto;
 
43
  background: transparent;
44
  }
45
 
46
+ .shell-nav-item.is-rail-skeleton {
47
+ cursor: default;
48
+ pointer-events: none;
49
+ }
50
+
51
+ /* ⭐⭐ W33-T75 (owner item 1) β€” THE RAIL'S ONE IN-FLIGHT STATE.
52
+ β›” THE POINT IS THE `:not()`, NOT THE BARS. While `/nav` is in flight EVERY real row is
53
+ hidden, so the rail cannot show five finished rows beside two loading ones β€” which is what
54
+ `reference/ERROR 6.png` is a picture of, and what "the navigation loads separately" has meant
55
+ in all four reports. They are hidden rather than unmounted because the database flyout portal,
56
+ the tooltips and the account menu hang off this subtree (see Shell.tsx's note).
57
+ ⚠ Geometry is copied from `.shell-nav-item` in index.css and must stay copied: 34 px min-height,
58
+ 8 px gap, 7/8 padding, a 16 px mark. A skeleton row of a different height is the layout jump
59
+ this whole ticket is about, one step earlier. */
60
+ .shell-nav.is-rail-loading > *:not(.shell-rail-skeleton) {
61
+ display: none;
62
+ }
63
+
64
+ .shell-rail-skeleton {
65
+ display: flex;
66
+ flex-direction: column;
67
+ }
68
+
69
+ .shell-rail-skeleton-row {
70
+ display: flex;
71
+ align-items: center;
72
+ gap: 8px;
73
+ min-height: 34px;
74
+ padding: 7px 8px;
75
+ box-sizing: border-box;
76
+ }
77
+
78
+ .shell-rail-skeleton-mark,
79
+ .shell-rail-skeleton-bar {
80
+ /* One flat tone, no shimmer: an animated sweep is a second thing moving in a rail whose whole
81
+ complaint is movement, and `prefers-reduced-motion` would then need a third state. */
82
+ background: var(--lp-surface-2);
83
+ border-radius: var(--lp-r-sm);
84
+ }
85
+
86
+ .shell-rail-skeleton-mark {
87
+ flex: 0 0 auto;
88
+ width: 16px;
89
+ height: 16px;
90
+ }
91
+
92
+ .shell-rail-skeleton-bar {
93
+ flex: 0 0 auto;
94
+ width: 84px;
95
+ height: 9px;
96
+ border-radius: 4px;
97
+ }
98
+
99
+ /* Collapsed, the labels are gone from the real rail, so the skeleton drops its bars too β€”
100
+ otherwise the fold animation runs against a width the settled rail never has. */
101
+ .shell-side.is-collapsed .shell-rail-skeleton-bar {
102
+ display: none;
103
+ }
104
+
105
  .shell-nav-row > .shell-dots,
106
  .shell-nav-folder > .shell-dots {
107
  flex: 0 0 auto;