Deploy AIOS web (React glide grid + FastAPI slice)
Browse files- RELEASES.json +1 -1
- VERSION +1 -1
- api/deps.py +377 -0
- api/routes_query.py +666 -713
- platform/core/perm_scope.py +130 -0
- web/src/assistant/AssistantPage.tsx +342 -327
- web/src/assistant/assistant.css +384 -178
- web/src/customer-grid/CustomerGrid.tsx +187 -34
- web/src/customer-grid/queryPreview.ts +131 -0
- web/src/query/QueryPage.tsx +138 -222
- web/src/query/query.css +249 -66
- web/src/query/queryApi.ts +361 -177
- web/src/query/queryParts.tsx +112 -93
- web/src/shell/Shell.tsx +79 -94
- web/src/shell/navExtras.css +59 -0
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v25",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "abfbfbe",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v25",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 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 |
-
"""
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
"""
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
raw =
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
"
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
"
|
| 213 |
-
|
| 214 |
-
|
| 215 |
-
|
| 216 |
-
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
""
|
| 223 |
-
|
| 224 |
-
|
| 225 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
-
|
| 233 |
-
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
-
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
| 258 |
-
"""
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
-
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
-
|
| 286 |
-
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
except
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
|
| 330 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
"""
|
| 356 |
-
|
| 357 |
-
if not
|
| 358 |
-
return
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
| 389 |
-
|
| 390 |
-
|
| 391 |
-
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
|
| 398 |
-
|
| 399 |
-
|
| 400 |
-
|
| 401 |
-
|
| 402 |
-
|
| 403 |
-
|
| 404 |
-
|
| 405 |
-
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
| 416 |
-
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
|
| 445 |
-
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
| 452 |
-
|
| 453 |
-
|
| 454 |
-
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
|
| 472 |
-
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
| 480 |
-
|
| 481 |
-
|
| 482 |
-
|
| 483 |
-
|
| 484 |
-
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
|
| 505 |
-
|
| 506 |
-
|
| 507 |
-
|
| 508 |
-
|
| 509 |
-
|
| 510 |
-
|
| 511 |
-
|
| 512 |
-
return
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
def
|
| 516 |
-
|
| 517 |
-
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
|
| 540 |
-
|
| 541 |
-
|
| 542 |
-
|
| 543 |
-
|
| 544 |
-
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
|
| 549 |
-
|
| 550 |
-
|
| 551 |
-
|
| 552 |
-
|
| 553 |
-
|
| 554 |
-
|
| 555 |
-
|
| 556 |
-
|
| 557 |
-
|
| 558 |
-
|
| 559 |
-
|
| 560 |
-
|
| 561 |
-
|
| 562 |
-
|
| 563 |
-
|
| 564 |
-
|
| 565 |
-
|
| 566 |
-
|
| 567 |
-
|
| 568 |
-
|
| 569 |
-
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
|
| 574 |
-
|
| 575 |
-
|
| 576 |
-
def
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
return
|
| 592 |
-
|
| 593 |
-
if
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
return {"
|
| 597 |
-
"
|
| 598 |
-
"
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
def
|
| 603 |
-
|
| 604 |
-
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
| 612 |
-
|
| 613 |
-
|
| 614 |
-
|
| 615 |
-
|
| 616 |
-
|
| 617 |
-
|
| 618 |
-
|
| 619 |
-
|
| 620 |
-
|
| 621 |
-
|
| 622 |
-
|
| 623 |
-
|
| 624 |
-
|
| 625 |
-
|
| 626 |
-
|
| 627 |
-
|
| 628 |
-
|
| 629 |
-
|
| 630 |
-
|
| 631 |
-
|
| 632 |
-
|
| 633 |
-
|
| 634 |
-
|
| 635 |
-
|
| 636 |
-
|
| 637 |
-
|
| 638 |
-
|
| 639 |
-
|
| 640 |
-
|
| 641 |
-
|
| 642 |
-
|
| 643 |
-
|
| 644 |
-
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 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 {
|
| 51 |
-
import
|
| 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: "
|
| 70 |
-
|
| 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 |
-
*
|
|
|
|
| 91 |
*
|
| 92 |
-
* β THE
|
| 93 |
-
*
|
| 94 |
-
*
|
| 95 |
-
*
|
| 96 |
-
*
|
|
|
|
| 97 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 98 |
export function openBuiltView(qid: string): () => void {
|
| 99 |
if (typeof window === "undefined") return () => {};
|
| 100 |
-
if (window.location.hash.replace(/^#\/?/, "") !== QUERY_ROUTE) {
|
| 101 |
-
|
| 102 |
-
}
|
| 103 |
-
return retryEmit(() => {
|
| 104 |
-
signal(QUERY_OPEN_EVENT, { qid });
|
| 105 |
-
});
|
| 106 |
}
|
| 107 |
|
| 108 |
/**
|
| 109 |
-
*
|
| 110 |
-
*
|
| 111 |
-
*
|
| 112 |
-
*
|
| 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
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
|
|
|
| 122 |
}) {
|
| 123 |
-
|
|
|
|
|
|
|
| 124 |
return (
|
| 125 |
-
<
|
| 126 |
-
<p className="as-
|
| 127 |
-
|
| 128 |
-
</section>
|
| 129 |
);
|
| 130 |
}
|
| 131 |
-
|
| 132 |
return (
|
| 133 |
-
<
|
| 134 |
-
<p className="as-
|
| 135 |
-
{
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 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 |
-
|
| 152 |
-
<button type="button" className="as-
|
| 153 |
-
|
| 154 |
-
{
|
|
|
|
|
|
|
|
|
|
| 155 |
</button>
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
<
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
);
|
| 162 |
}
|
| 163 |
|
| 164 |
-
|
| 165 |
-
|
| 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 [
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
*
|
| 209 |
-
*
|
| 210 |
-
* `
|
| 211 |
-
*
|
| 212 |
-
*
|
| 213 |
-
*
|
| 214 |
-
*
|
| 215 |
-
*
|
| 216 |
*/
|
| 217 |
-
const
|
| 218 |
-
(
|
| 219 |
-
|
| 220 |
-
|
| 221 |
-
|
| 222 |
-
|
| 223 |
-
|
| 224 |
-
|
| 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
|
| 236 |
setLoaded(true);
|
| 237 |
-
if (
|
| 238 |
-
|
| 239 |
-
|
| 240 |
}
|
| 241 |
}, []);
|
| 242 |
|
|
|
|
|
|
|
| 243 |
useEffect(() => {
|
| 244 |
-
|
| 245 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 246 |
|
| 247 |
const ask = useCallback(async () => {
|
| 248 |
-
const
|
| 249 |
-
if (!
|
| 250 |
-
setBusy(true);
|
| 251 |
-
|
| 252 |
-
|
| 253 |
-
const res = await buildQuery(q, scope);
|
| 254 |
setBusy(false);
|
| 255 |
-
if (!
|
| 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 |
-
|
| 274 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 275 |
|
| 276 |
-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
|
| 282 |
-
|
| 283 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
|
| 285 |
-
const
|
| 286 |
openCancel.current?.();
|
| 287 |
openCancel.current = openBuiltView(qid);
|
| 288 |
}, []);
|
| 289 |
|
| 290 |
-
const
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 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 |
-
|
| 331 |
-
|
| 332 |
-
|
| 333 |
-
|
| 334 |
-
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
/>
|
| 341 |
-
|
| 342 |
-
<button
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
>
|
| 348 |
-
{busy ? "Workingβ¦" : "Build a view"}
|
| 349 |
</button>
|
| 350 |
-
</
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
<
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
<
|
| 381 |
-
{
|
| 382 |
-
<
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
| 388 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 389 |
))}
|
| 390 |
-
|
| 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
|
| 3 |
-
2026-08-14).
|
| 4 |
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
both files would be two places for one look to drift.
|
| 10 |
|
| 11 |
-
|
| 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 |
-
|
| 21 |
-
|
| 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-
|
|
|
|
|
|
|
| 29 |
height: 100%;
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
background: var(--lp-wash);
|
| 34 |
}
|
| 35 |
|
| 36 |
-
|
| 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-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
-
.as-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
color: var(--lp-ink);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
-
|
| 62 |
|
| 63 |
-
.as-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
max-width: 940px;
|
| 69 |
}
|
| 70 |
|
| 71 |
-
.as-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
display: flex;
|
| 73 |
flex-direction: column;
|
| 74 |
-
gap:
|
| 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-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
width:
|
| 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-
|
| 102 |
-
.as-
|
| 103 |
-
|
| 104 |
-
outline-offset: 1px;
|
| 105 |
-
}
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
|
|
|
| 112 |
border-radius: var(--lp-r-md);
|
| 113 |
-
background:
|
| 114 |
color: var(--lp-ink);
|
| 115 |
-
font
|
| 116 |
-
font-
|
| 117 |
-
|
|
|
|
|
|
|
|
|
|
| 118 |
cursor: pointer;
|
| 119 |
}
|
| 120 |
|
| 121 |
-
.as-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
}
|
| 124 |
|
| 125 |
-
.as-
|
| 126 |
-
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
-
.as-
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
color: var(--lp-surface);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
}
|
| 135 |
|
| 136 |
-
.as-
|
| 137 |
-
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
}
|
| 146 |
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
}
|
| 151 |
|
| 152 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
|
| 154 |
-
.as-
|
| 155 |
-
|
| 156 |
-
|
| 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-
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
-
.as-
|
| 169 |
margin: 0 0 6px;
|
| 170 |
-
|
|
|
|
| 171 |
font-weight: 650;
|
| 172 |
-
color: var(--lp-muted);
|
| 173 |
}
|
| 174 |
|
| 175 |
-
.as-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
| 178 |
font-size: var(--lp-fs-sm);
|
| 179 |
line-height: var(--lp-lh);
|
| 180 |
-
color: var(--lp-ink);
|
| 181 |
}
|
| 182 |
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
display: flex;
|
| 197 |
-
|
| 198 |
-
margin-top: 14px;
|
| 199 |
}
|
| 200 |
|
| 201 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
|
|
|
|
|
|
|
|
|
| 206 |
font-size: var(--lp-fs-sm);
|
| 207 |
line-height: var(--lp-lh);
|
| 208 |
-
|
|
|
|
| 209 |
}
|
| 210 |
|
| 211 |
-
.as-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
}
|
| 214 |
|
| 215 |
-
.as-
|
|
|
|
|
|
|
|
|
|
| 216 |
display: flex;
|
| 217 |
flex-direction: column;
|
| 218 |
-
gap:
|
| 219 |
-
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
padding: 0;
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
}
|
| 224 |
|
| 225 |
-
.as-
|
| 226 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
border: 1px solid var(--lp-line);
|
| 228 |
-
border-radius:
|
| 229 |
background: var(--lp-surface);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 230 |
}
|
| 231 |
|
| 232 |
-
.as-
|
|
|
|
|
|
|
| 233 |
display: flex;
|
| 234 |
-
|
| 235 |
-
|
| 236 |
-
|
| 237 |
}
|
| 238 |
|
| 239 |
-
.as-
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
border: 0;
|
| 242 |
background: transparent;
|
| 243 |
-
color: var(--lp-
|
| 244 |
-
font
|
| 245 |
-
font-
|
| 246 |
-
font-weight:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 247 |
cursor: pointer;
|
| 248 |
-
text-align: left;
|
| 249 |
}
|
| 250 |
|
| 251 |
-
.as-
|
| 252 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
}
|
| 254 |
|
| 255 |
-
.as-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 256 |
font-size: var(--lp-fs-2xs);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 257 |
color: var(--lp-muted);
|
| 258 |
}
|
| 259 |
|
| 260 |
-
.as-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
|
|
|
|
|
|
| 266 |
}
|
| 267 |
|
| 268 |
-
.as-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
color: var(--lp-muted);
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
}
|
| 275 |
|
| 276 |
-
.as-
|
| 277 |
-
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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: !
|
| 680 |
-
includeWorkspace: !
|
| 681 |
-
writable: !
|
| 682 |
});
|
| 683 |
const embeddedIdsKey = embeddedRecordIds?.join(",") ?? "";
|
| 684 |
/**
|
|
@@ -780,7 +864,7 @@ function CustomerGrid({
|
|
| 780 |
*/
|
| 781 |
const [alerted, setAlerted] = useState<string[]>([]);
|
| 782 |
useEffect(() => {
|
| 783 |
-
if (
|
| 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 |
-
}, [
|
| 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 =
|
|
|
|
|
|
|
| 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 (!
|
| 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,
|
| 1081 |
|
| 1082 |
useEffect(() => {
|
| 1083 |
-
if (!workspaceReady ||
|
| 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,
|
| 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 ||
|
| 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,
|
| 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 = !
|
| 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 ||
|
| 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,
|
| 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 =
|
| 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 => !
|
| 1546 |
-
[
|
| 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 |
-
|
| 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 |
-
|
|
|
|
|
|
|
| 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 ||
|
| 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={
|
| 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 |
-
|
| 3 |
-
|
| 4 |
-
/
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
//
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
/
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
/
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
const
|
| 118 |
-
const
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 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 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
`
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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
|
| 29 |
|
| 30 |
-
.qy-
|
| 31 |
display: flex;
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
margin-left: auto;
|
| 35 |
min-width: 0;
|
|
|
|
|
|
|
| 36 |
}
|
| 37 |
|
| 38 |
-
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
font-weight: 600;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
color: var(--lp-muted);
|
|
|
|
|
|
|
| 42 |
}
|
| 43 |
|
| 44 |
-
.qy-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 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-
|
| 57 |
-
|
| 58 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
}
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
.qy-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
display: flex;
|
| 65 |
flex-direction: column;
|
| 66 |
-
align-items: flex-start;
|
| 67 |
justify-content: center;
|
| 68 |
-
|
| 69 |
-
padding:
|
| 70 |
-
|
| 71 |
-
background:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
}
|
| 73 |
|
| 74 |
-
.qy-
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
| 79 |
}
|
| 80 |
|
| 81 |
-
.qy-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
font-size: var(--lp-fs-sm);
|
| 85 |
-
line-height: var(--lp-lh);
|
| 86 |
color: var(--lp-muted);
|
|
|
|
| 87 |
}
|
| 88 |
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
color: var(--lp-surface);
|
| 94 |
-
font
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
font-weight: 600;
|
| 96 |
text-decoration: none;
|
| 97 |
}
|
| 98 |
|
| 99 |
-
.qy-
|
| 100 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
//
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
| 72 |
-
|
| 73 |
-
|
| 74 |
-
}
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
}
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
export
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 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 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
};
|
| 38 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 39 |
/**
|
| 40 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
*
|
| 42 |
-
*
|
| 43 |
-
* *"exactly BE looking like the database module, COMPLETELY
|
| 44 |
-
*
|
| 45 |
-
*
|
|
|
|
|
|
|
| 46 |
*/
|
| 47 |
-
export function
|
| 48 |
-
|
| 49 |
activeId: string;
|
| 50 |
-
|
| 51 |
-
|
| 52 |
}) {
|
|
|
|
|
|
|
|
|
|
| 53 |
return (
|
| 54 |
-
<
|
| 55 |
-
<
|
| 56 |
-
<
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
))}
|
| 66 |
-
</
|
| 67 |
-
|
|
|
|
| 68 |
);
|
| 69 |
}
|
| 70 |
|
| 71 |
/**
|
| 72 |
-
*
|
| 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 |
-
*
|
| 80 |
-
*
|
| 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
|
|
|
|
| 89 |
return (
|
| 90 |
-
<div className="qy-
|
| 91 |
-
{
|
| 92 |
-
<span className="
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 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 |
-
|
| 331 |
-
|
| 332 |
-
|
| 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 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 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 |
-
*
|
| 1658 |
-
*
|
| 1659 |
-
*
|
| 1660 |
-
*
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1661 |
*/
|
| 1662 |
-
type RailSlot = "row" | "
|
| 1663 |
-
const
|
| 1664 |
-
const railSlot = (present: boolean
|
| 1665 |
present
|
| 1666 |
? "row"
|
| 1667 |
-
: nav.phase === "
|
| 1668 |
-
?
|
| 1669 |
-
|
| 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 |
-
:
|
| 1678 |
-
|
| 1679 |
-
|
| 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 |
-
{/*
|
| 2349 |
-
|
| 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;
|