Deploy AIOS web (React glide grid + FastAPI slice)
Browse filesThis view is limited to 50 files because it contains too many changes. Β See raw diff
- RELEASES.json +1 -1
- VERSION +1 -1
- api/ai_enrich.py +520 -0
- api/automation_engine.py +0 -0
- api/main.py +0 -0
- api/routes_automation.py +0 -0
- api/routes_nav.py +237 -10
- api/routes_query.py +0 -0
- api/routes_tables.py +186 -24
- api/web_agent.py +41 -23
- platform/aios_grid.py +12 -1
- platform/core/registry.py +26 -18
- platform/core/user_tables.py +390 -2
- web/src/assistant/AssistantPage.tsx +324 -26
- web/src/assistant/assistant.css +128 -0
- web/src/automation/AutomationBuilder.tsx +0 -0
- web/src/automation/AutomationChat.tsx +251 -0
- web/src/automation/AutomationDetail.tsx +147 -15
- web/src/automation/AutomationFind.tsx +1 -1
- web/src/automation/AutomationSurface.tsx +814 -806
- web/src/automation/AutomationTrigger.tsx +6 -6
- web/src/automation/automation.css +176 -0
- web/src/automation/automationApi.ts +100 -3
- web/src/automation/steps.ts +10 -0
- web/src/customer-grid/ColumnMenu.tsx +334 -6
- web/src/customer-grid/CustomerGrid.tsx +66 -17
- web/src/customer-grid/RecordDetail.tsx +0 -0
- web/src/customer-grid/ViewSidebar.tsx +236 -46
- web/src/customer-grid/apiBridge.ts +57 -0
- web/src/customer-grid/cells.ts +37 -0
- web/src/customer-grid/display.ts +10 -0
- web/src/customer-grid/folders.ts +128 -2
- web/src/customer-grid/iconShapes.ts +16 -0
- web/src/customer-grid/types.ts +63 -1
- web/src/filter-kit/FieldsHidePanel.tsx +1 -1
- web/src/filter-kit/FilterBuilderPanel.tsx +1003 -988
- web/src/filter-kit/ops.ts +400 -400
- web/src/inbox/InboxPage.tsx +9 -4
- web/src/inbox/inboxModel.ts +13 -2
- web/src/index.css +104 -12
- web/src/query/QueryPage.tsx +68 -44
- web/src/query/query.css +111 -0
- web/src/query/queryApi.ts +96 -3
- web/src/query/queryParts.tsx +187 -14
- web/src/settings/permsModel.ts +8 -0
- web/src/shell/LoginPage.tsx +161 -145
- web/src/shell/NavExtras.tsx +23 -2
- web/src/shell/Shell.tsx +0 -0
- web/src/shell/nav.ts +185 -3
- web/src/shell/navExtras.css +50 -0
RELEASES.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
{
|
| 2 |
-
"current": "
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v25",
|
|
|
|
| 1 |
{
|
| 2 |
+
"current": "91ad73a",
|
| 3 |
"releases": [
|
| 4 |
{
|
| 5 |
"version": "v25",
|
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
91ad73a
|
api/ai_enrich.py
ADDED
|
@@ -0,0 +1,520 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ai_enrich.py -- the AI ENRICHMENT column (wave 34, owner ruling R13).
|
| 2 |
+
|
| 3 |
+
R13: *"A field kind called AI enrichment: a prompt per row that populates text. Detailed
|
| 4 |
+
configuration in the field's own config, including a token-usage limit."*
|
| 5 |
+
|
| 6 |
+
WHERE THE HALVES LIVE, and why the line is drawn here rather than anywhere else:
|
| 7 |
+
|
| 8 |
+
core/user_tables.py the CONTRACT and the STORAGE -- `UT_FIELD_TYPES`, `_clean_ai_enrich`,
|
| 9 |
+
the per-cell provenance stratum, and the law about what an automatic
|
| 10 |
+
run may overwrite. It owns the store document, so it owns the marks.
|
| 11 |
+
api/ai_enrich.py THIS file: the PROMPT vocabulary, the run PLAN, and the token ceiling's
|
| 12 |
+
own report. `core` may not import upward, so anything that needs to
|
| 13 |
+
reach a vendor or an HTTP session lives here.
|
| 14 |
+
|
| 15 |
+
Everything in this module is a PURE FUNCTION of data it is handed. That is deliberate: it is the
|
| 16 |
+
half a gate can exercise without a network, a store, or a running server, which is what makes
|
| 17 |
+
`api_ai_enrich` a real gate rather than a mount check.
|
| 18 |
+
|
| 19 |
+
TOKEN ACCOUNTING, and the hole it is filling. `api/ai_review.py::decide` -- the product's only
|
| 20 |
+
other LLM entry -- has no token accounting of ANY kind: `max_tokens: 300` per call, one 20 s
|
| 21 |
+
timeout, no retry, and nothing anywhere counts what a run spent. R13 asks for a usage limit, so
|
| 22 |
+
there was nothing to build on and the ledger starts here. The two measured community complaints
|
| 23 |
+
about this feature elsewhere are both REPORTING gaps rather than capability gaps (a preview said
|
| 24 |
+
15 credits and the run spent 360; a finished run gave no completion signal), which is why
|
| 25 |
+
`ceiling_report` exists beside the ceiling itself.
|
| 26 |
+
"""
|
| 27 |
+
from __future__ import annotations
|
| 28 |
+
|
| 29 |
+
import os
|
| 30 |
+
import re
|
| 31 |
+
|
| 32 |
+
import requests
|
| 33 |
+
|
| 34 |
+
#: A prompt names other columns the way a formula does, so a user who has written one already
|
| 35 |
+
#: knows this syntax. β ONE parser, exported, because `user_tables.ai_enrich_input_hash` fingerprints
|
| 36 |
+
#: the referenced cells and a second copy of this pattern would disagree with it the first time the
|
| 37 |
+
#: token syntax gained a form ([[one-question-two-normalizers]]).
|
| 38 |
+
_REF = re.compile(r'\{([a-zA-Z0-9_]{1,60})\}')
|
| 39 |
+
|
| 40 |
+
#: What a cell can say about where its value came from. Mirrors
|
| 41 |
+
#: `user_tables.AI_ENRICH_STATES` plus the two states that are DERIVED rather than stored:
|
| 42 |
+
#: `stale` (the inputs moved since the value was written) and `empty` (nothing has run).
|
| 43 |
+
#: β Re-exported rather than re-declared: the stored vocabulary has exactly one owner.
|
| 44 |
+
CELL_STATES = ('empty', 'agent', 'human', 'stale', 'error')
|
| 45 |
+
|
| 46 |
+
#: β THERE IS DELIBERATELY NO PROVIDER LIST IN THIS FILE. The product already has TWO opinions
|
| 47 |
+
#: about provider order (`harness/analyst.PROVIDERS` says groq-first, `routes_query.
|
| 48 |
+
#: QUERY_PROVIDER_ORDER` says cerebras-first) and `W34-T37` exists to reconcile them. Declaring a
|
| 49 |
+
#: third here would make that ticket harder and this feature wrong in a new way, so the catalogue
|
| 50 |
+
#: is BORROWED from `ai_review` (`PROVIDERS` + `ladder()` + the key names + the cheap-first order
|
| 51 |
+
#: owner ruling R14 set). β The CALL is ours because the requirements differ: `ai_review` hardcodes
|
| 52 |
+
#: `max_tokens: 300` and reads no `usage`, and R13 needs a per-column ceiling and a real ledger.
|
| 53 |
+
#: One provider catalogue, two call shapes, and the reason is written down rather than inferred.
|
| 54 |
+
|
| 55 |
+
#: The run's per-cell wall clock. Separate from `ai_review.TIMEOUT_SECONDS` because a review is
|
| 56 |
+
#: one classification a person is waiting on and this is a loop over rows.
|
| 57 |
+
TIMEOUT_SECONDS = float(os.environ.get('AIOS_AI_ENRICH_TIMEOUT') or 30)
|
| 58 |
+
#: How many failing rows carry their error into the report before it is summarised. A report that
|
| 59 |
+
#: lists 900 identical `429`s is a report nobody reads.
|
| 60 |
+
MAX_REPORTED_ERRORS = 10
|
| 61 |
+
#: Bound on what one row hands the model, mirroring `ai_review`'s own two caps: a table can hold a
|
| 62 |
+
#: 32 KB JSON cell per row and no prompt needs it.
|
| 63 |
+
MAX_CELL_CHARS = 400
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def prompt_refs(prompt):
|
| 67 |
+
"""The column keys a prompt names, in first-appearance order, de-duplicated.
|
| 68 |
+
|
| 69 |
+
`"Summarise {name} for {segment}"` -> `['name', 'segment']`.
|
| 70 |
+
"""
|
| 71 |
+
seen, out = set(), []
|
| 72 |
+
for key in _REF.findall(str(prompt or '')):
|
| 73 |
+
if key not in seen:
|
| 74 |
+
seen.add(key)
|
| 75 |
+
out.append(key)
|
| 76 |
+
return out
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
def render_prompt(prompt, row, field_keys=()):
|
| 80 |
+
"""`(text, unresolved)` -- the prompt with `{token}` replaced by this row's cells.
|
| 81 |
+
|
| 82 |
+
β AN UNKNOWN TOKEN IS REPORTED, NOT SILENTLY BLANKED. A prompt naming a column that does not
|
| 83 |
+
exist is a prompt that will quietly ask a different question on every row, and the resulting
|
| 84 |
+
text looks exactly like a correct answer. `unresolved` is what the caller shows the user;
|
| 85 |
+
R6's standing rule is the same sentence one layer up (a limit that cannot be removed must be
|
| 86 |
+
reported with its cause).
|
| 87 |
+
|
| 88 |
+
β A token naming a real column whose CELL is empty is NOT unresolved: an empty cell is data.
|
| 89 |
+
"""
|
| 90 |
+
known = set(field_keys or ())
|
| 91 |
+
unresolved = []
|
| 92 |
+
|
| 93 |
+
def _sub(match):
|
| 94 |
+
key = match.group(1)
|
| 95 |
+
if known and key not in known:
|
| 96 |
+
unresolved.append(key)
|
| 97 |
+
return match.group(0)
|
| 98 |
+
return str((row or {}).get(key, ''))
|
| 99 |
+
|
| 100 |
+
return _REF.sub(_sub, str(prompt or '')), sorted(set(unresolved))
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def ceiling_report(spent, ceiling, rows_done, rows_total):
|
| 104 |
+
"""R6's second sentence as DATA, or None while the run is still inside its budget.
|
| 105 |
+
|
| 106 |
+
`{subject, limit, spent, effect, cause, recommendation}` -- the same shape
|
| 107 |
+
`user_tables.limit_report` returns, so the client renders one vocabulary for every limit the
|
| 108 |
+
product enforces rather than a second one for this feature.
|
| 109 |
+
|
| 110 |
+
β `effect` is `stopped`, never `truncated`. A run that quietly stopped filling cells leaves a
|
| 111 |
+
column that looks complete and is not, which is the failure the whole reporting rule is
|
| 112 |
+
arranged against. The caller STOPS and says so.
|
| 113 |
+
"""
|
| 114 |
+
if ceiling is None or spent < ceiling:
|
| 115 |
+
return None
|
| 116 |
+
left = max(0, int(rows_total or 0) - int(rows_done or 0))
|
| 117 |
+
rest = f'{left:,} rows' if left != 1 else '1 row'
|
| 118 |
+
return {
|
| 119 |
+
'subject': 'tokens',
|
| 120 |
+
'limit': int(ceiling),
|
| 121 |
+
'spent': int(spent),
|
| 122 |
+
'effect': 'stopped',
|
| 123 |
+
'cause': f'this run reached its token ceiling of {int(ceiling):,} after filling '
|
| 124 |
+
f'{int(rows_done or 0):,} of {int(rows_total or 0):,} rows',
|
| 125 |
+
'recommendation': f'raise the column\'s token limit, shorten its prompt, or run the '
|
| 126 |
+
f'remaining {rest} again. Nothing was truncated and no cell holds a '
|
| 127 |
+
f'partial answer',
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
|
| 131 |
+
def plan_rows(rows, col_id, config, marks, cost_of=None):
|
| 132 |
+
"""Which rows an automatic run should fill, and why the others were left alone.
|
| 133 |
+
|
| 134 |
+
Returns `{'run': [row_id, ...], 'skipped': {reason: count}, 'estimate': int}`.
|
| 135 |
+
|
| 136 |
+
β THE SELECTION IS SEPARATE FROM THE RUN ON PURPOSE. It is pure, so a gate can prove the
|
| 137 |
+
never-overwrite-a-human-edit law without a vendor, and so the client can PREVIEW a run
|
| 138 |
+
honestly before it spends anything -- which is exactly the complaint R13 cites (a preview
|
| 139 |
+
that said 15 credits against a run that spent 360).
|
| 140 |
+
|
| 141 |
+
`cost_of(row)` estimates one row's tokens; absent, the column's own ceiling is used per row,
|
| 142 |
+
which is the pessimistic direction and the right one for a preview.
|
| 143 |
+
"""
|
| 144 |
+
import core.user_tables as ut
|
| 145 |
+
|
| 146 |
+
cfg = config if isinstance(config, dict) else {}
|
| 147 |
+
prompt = str(cfg.get('prompt') or '')
|
| 148 |
+
policy = str(cfg.get('overwrite') or 'blank')
|
| 149 |
+
refs = prompt_refs(prompt)
|
| 150 |
+
per_row = int(cfg.get('maxTokens') or 0)
|
| 151 |
+
run, skipped, estimate = [], {}, 0
|
| 152 |
+
for row_id, row in (rows or {}).items():
|
| 153 |
+
row = row if isinstance(row, dict) else {}
|
| 154 |
+
mark = (marks or {}).get(str(row_id)) or {}
|
| 155 |
+
fresh = ut.ai_enrich_input_hash(prompt, row, refs)
|
| 156 |
+
has_value = bool(str(row.get(str(col_id)) or '').strip())
|
| 157 |
+
if ut.ai_enrich_may_write(mark, policy, fresh, has_value=has_value):
|
| 158 |
+
run.append(str(row_id))
|
| 159 |
+
estimate += int(cost_of(row)) if callable(cost_of) else per_row
|
| 160 |
+
else:
|
| 161 |
+
# The same predicate again, so the count a preview shows and the cells a run leaves
|
| 162 |
+
# alone can never be two different sets.
|
| 163 |
+
reason = ('human_edited' if ut.ai_enrich_human_authored(mark, has_value)
|
| 164 |
+
else 'unchanged' if has_value else 'skipped')
|
| 165 |
+
skipped[reason] = skipped.get(reason, 0) + 1
|
| 166 |
+
return {'run': run, 'skipped': skipped, 'estimate': estimate}
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def cell_state(mark, config, row, col_id):
|
| 170 |
+
"""What ONE cell should say about itself: one of `CELL_STATES`.
|
| 171 |
+
|
| 172 |
+
β `stale` and `empty` are computed here and never stored -- see the stratum's own note in
|
| 173 |
+
`core/user_tables.py`. A stored staleness flag needs a sweep to stay true, and a sweep nobody
|
| 174 |
+
runs is a flag shipped without its writer.
|
| 175 |
+
"""
|
| 176 |
+
import core.user_tables as ut
|
| 177 |
+
|
| 178 |
+
mark = mark if isinstance(mark, dict) else {}
|
| 179 |
+
has_value = bool(str((row or {}).get(str(col_id)) or '').strip())
|
| 180 |
+
if str(mark.get('state') or '') == 'error':
|
| 181 |
+
return 'error'
|
| 182 |
+
# β THE SAME PREDICATE THE RUNNER OBEYS, called rather than restated. A cell this reports as
|
| 183 |
+
# the human's while `ai_enrich_may_write` would overwrite it is the worst of both answers.
|
| 184 |
+
if ut.ai_enrich_human_authored(mark, has_value):
|
| 185 |
+
return 'human'
|
| 186 |
+
if str(mark.get('state') or '') != 'agent':
|
| 187 |
+
return 'empty'
|
| 188 |
+
cfg = config if isinstance(config, dict) else {}
|
| 189 |
+
prompt = str(cfg.get('prompt') or '')
|
| 190 |
+
fresh = ut.ai_enrich_input_hash(prompt, row or {}, prompt_refs(prompt))
|
| 191 |
+
return 'stale' if ut.ai_enrich_is_stale(mark, fresh) else 'agent'
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ THE RUN
|
| 195 |
+
# οΏ½οΏ½οΏ½β THE LEDGER IS THE POINT OF THIS HALF. `ai_review.decide` (the product's only other LLM entry)
|
| 196 |
+
# counts nothing: it sends `max_tokens: 300` and never reads `usage` off the response, so no run in
|
| 197 |
+
# this product has ever been able to say what it cost. R13 asks for a token-usage limit, so the
|
| 198 |
+
# accounting starts here, and every leg below reads `usage` even where it is not needed, because a
|
| 199 |
+
# ledger with one blind provider is not a ledger.
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _usage_tokens(body):
|
| 203 |
+
"""Total tokens for one call, from whichever shape the provider answered in.
|
| 204 |
+
|
| 205 |
+
β RETURNS None WHEN THE PROVIDER DID NOT SAY, and the caller treats that as a real unknown
|
| 206 |
+
rather than as zero. A ledger that silently books an unmeasured call at 0 reports a cheaper
|
| 207 |
+
run than happened, which is exactly the cost-surprise complaint R13 cites.
|
| 208 |
+
"""
|
| 209 |
+
usage = (body or {}).get('usage')
|
| 210 |
+
if not isinstance(usage, dict):
|
| 211 |
+
return None
|
| 212 |
+
for key in ('total_tokens', 'totalTokens'):
|
| 213 |
+
got = usage.get(key)
|
| 214 |
+
if isinstance(got, int) and not isinstance(got, bool):
|
| 215 |
+
return got
|
| 216 |
+
ins = usage.get('prompt_tokens', usage.get('input_tokens'))
|
| 217 |
+
outs = usage.get('completion_tokens', usage.get('output_tokens'))
|
| 218 |
+
if isinstance(ins, int) and isinstance(outs, int):
|
| 219 |
+
return ins + outs
|
| 220 |
+
return None
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
#: R6's subject: the em dash (U+2014) and the en dash (U+2013), as a regex character class.
|
| 224 |
+
#: β BUILT FROM CODE POINTS, NOT TYPED, AND THIS IS NOT COSMETIC. `web_prose` reads a Python
|
| 225 |
+
#: string's VALUE off the AST, so a class written `'[<em><en>]'` is reported as two findings in
|
| 226 |
+
#: the one function whose entire job is to REMOVE those characters. That is a false positive, and
|
| 227 |
+
#: the wrong fix would be to silence the gate or to weaken the pattern. `chr()` keeps the value
|
| 228 |
+
#: byte-identical while putting no dash in a literal, so the gate sees what it should see and the
|
| 229 |
+
#: normalizer keeps working. β Lane D hit the same wall in `routes_query` (note D-13).
|
| 230 |
+
_DASH = '[' + chr(0x2014) + chr(0x2013) + ']'
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def no_dashes(text):
|
| 234 |
+
"""R6 applied to MODEL-AUTHORED text, before it is stored.
|
| 235 |
+
|
| 236 |
+
ββ A PROMPT INSTRUCTION DOES NOT ENFORCE THIS, AND THE MEASUREMENT IS LANE D'S, NOT A GUESS
|
| 237 |
+
(mailbox note D-4, 2026-08-16): a system prompt ending *"Never use an em dash or an en dash"*
|
| 238 |
+
was answered on the very next live turn with *"Which view type would you like-grid, chart,
|
| 239 |
+
list, or another?"* carrying U+2014 (cerebras). `web_prose` scans SOURCE, so it is
|
| 240 |
+
structurally blind to a dash that arrives at runtime.
|
| 241 |
+
β AND THIS COLUMN IS THE WORST CASE IN THE WAVE, which is why D routed the note here: chat
|
| 242 |
+
prose is read once, but an enrichment value is WRITTEN INTO A CELL, then re-read and
|
| 243 |
+
re-rendered forever. Normalising after the write would never reach the rows already stored.
|
| 244 |
+
|
| 245 |
+
β A DIGIT RANGE IS A DIFFERENT SENTENCE and gets the first rule: "10-20" means "10 to 20", and
|
| 246 |
+
rewriting it as "10, 20" states two numbers where the model stated a span. That is a wrong
|
| 247 |
+
value, not a punctuation fix.
|
| 248 |
+
|
| 249 |
+
ββ THIS IS THE SECOND IMPLEMENTATION IN THE PRODUCT and that is a known cost, taken
|
| 250 |
+
deliberately rather than by drift. The first is `routes_query._no_dashes` (lane D's, shipped
|
| 251 |
+
first). Importing it would point a LIBRARY module at a ROUTE module, which is backwards, and
|
| 252 |
+
lane D's file is not in this lane's fence to move it. So the two are held in step by a PARITY
|
| 253 |
+
CHECK instead of by hope: `verify_ai_enrich` section 10 imports D's function and asserts both
|
| 254 |
+
agree over a corpus including every shape either docstring names. One shared home is owed and
|
| 255 |
+
is booked as a PENDING line, not left implicit ([[one-question-two-normalizers]]).
|
| 256 |
+
"""
|
| 257 |
+
text = str(text or '')
|
| 258 |
+
text = re.sub(rf'(?<=\d)\s*{_DASH}\s*(?=\d)', ' to ', text)
|
| 259 |
+
text = re.sub(rf'\s*{_DASH}\s*(?=[,.;:!?])', '', text) # abutting punctuation: it just goes
|
| 260 |
+
text = re.sub(rf'(?<=[,;:])\s*{_DASH}\s*', ' ', text) # already punctuated: one space
|
| 261 |
+
return re.sub(rf'\s*{_DASH}\s*', ', ', text)
|
| 262 |
+
|
| 263 |
+
|
| 264 |
+
#: What the model is told about its job. It fills ONE cell, so anything conversational it adds is
|
| 265 |
+
#: a defect in the column rather than a nicety. `UNKNOWN` is the honest out: a row the data cannot
|
| 266 |
+
#: answer is recorded as an error against that row, never as an invented value.
|
| 267 |
+
#: β The dash sentence is here for the same reason lane D kept theirs: it costs nothing and it
|
| 268 |
+
#: reduces how often `no_dashes` has to act. It is NOT the enforcement; `no_dashes` is.
|
| 269 |
+
_SYSTEM = ('You fill in ONE cell of a spreadsheet. Answer with the value only: no preamble, no '
|
| 270 |
+
'quotes, no markdown, no explanation. Never use an em dash or an en dash; use a comma, '
|
| 271 |
+
'a colon or a full stop. If the information given does not let you answer, reply with '
|
| 272 |
+
'exactly: UNKNOWN')
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _ask(provider, model, prompt, max_tokens, timeout):
|
| 276 |
+
"""One cell's answer. `(text, tokens_or_None, problem)`; a truthy `problem` means no text.
|
| 277 |
+
|
| 278 |
+
β Both request shapes are `ai_review`'s, changed in exactly two ways: they carry the COLUMN's
|
| 279 |
+
ceiling instead of a hardcoded 300, and they read `usage` back.
|
| 280 |
+
"""
|
| 281 |
+
key = (os.environ.get(provider['env']) or '').strip()
|
| 282 |
+
if not key:
|
| 283 |
+
return '', None, f"{provider['name']} has no key on this deployment"
|
| 284 |
+
try:
|
| 285 |
+
if provider['shape'] == 'anthropic':
|
| 286 |
+
r = requests.post(
|
| 287 |
+
provider['url'], timeout=timeout,
|
| 288 |
+
headers={'x-api-key': key, 'anthropic-version': '2023-06-01',
|
| 289 |
+
'content-type': 'application/json'},
|
| 290 |
+
json={'model': model, 'max_tokens': max_tokens, 'system': _SYSTEM,
|
| 291 |
+
'messages': [{'role': 'user', 'content': prompt}]})
|
| 292 |
+
if r.status_code >= 400:
|
| 293 |
+
return '', None, f"{provider['name']} answered {r.status_code}"
|
| 294 |
+
body = r.json()
|
| 295 |
+
# β stop_reason FIRST. A safety refusal is a 200 with an EMPTY content list, so
|
| 296 |
+
# reading content[0] before this turns a refusal into an IndexError inside the loop.
|
| 297 |
+
if body.get('stop_reason') == 'refusal':
|
| 298 |
+
return '', _usage_tokens(body), f"{provider['name']} declined this row"
|
| 299 |
+
parts = [b.get('text') or '' for b in (body.get('content') or [])
|
| 300 |
+
if isinstance(b, dict) and b.get('type') == 'text']
|
| 301 |
+
return ''.join(parts).strip(), _usage_tokens(body), ''
|
| 302 |
+
r = requests.post(
|
| 303 |
+
provider['url'], timeout=timeout,
|
| 304 |
+
headers={'Authorization': f"Bearer {key}", 'Content-Type': 'application/json'},
|
| 305 |
+
json={'model': model, 'max_tokens': max_tokens, 'temperature': 0,
|
| 306 |
+
'messages': [{'role': 'system', 'content': _SYSTEM},
|
| 307 |
+
{'role': 'user', 'content': prompt}]})
|
| 308 |
+
if r.status_code >= 400:
|
| 309 |
+
return '', None, f"{provider['name']} answered {r.status_code}"
|
| 310 |
+
body = r.json()
|
| 311 |
+
choices = body.get('choices') or []
|
| 312 |
+
if not choices:
|
| 313 |
+
return '', _usage_tokens(body), f"{provider['name']} returned no choices"
|
| 314 |
+
text = ((choices[0] or {}).get('message') or {}).get('content') or ''
|
| 315 |
+
return str(text).strip(), _usage_tokens(body), ''
|
| 316 |
+
except Exception as exc: # noqa: BLE001
|
| 317 |
+
return '', None, f"{provider['name']} failed: {type(exc).__name__}"
|
| 318 |
+
|
| 319 |
+
|
| 320 |
+
def on_change_fields(defn, changed_keys):
|
| 321 |
+
"""Which `ai_enrich` columns in this table want a run because one of their inputs moved.
|
| 322 |
+
|
| 323 |
+
A column qualifies when its trigger is `on_change` AND its prompt names at least one of the
|
| 324 |
+
columns that just changed. β A column that names NOTHING (a prompt with no `{token}`) never
|
| 325 |
+
fires on change, whatever its trigger says: it would re-ask the same question of the same row
|
| 326 |
+
forever, once per unrelated edit, and bill for every one.
|
| 327 |
+
|
| 328 |
+
β PURE, AND HANDED THE DEFINITION IT SHOULD USE. `automation_engine.grid_hook` calls
|
| 329 |
+
`all_definitions(st)` once PER EVENT, which turns a 20,000-row import into 20,000 whole
|
| 330 |
+
document reads (`D-134`); this function reads no store at all, so the caller can do one read
|
| 331 |
+
for a whole batch. Rebuilding that shape here was the one thing T54's ticket said not to do.
|
| 332 |
+
"""
|
| 333 |
+
import core.user_tables as ut
|
| 334 |
+
|
| 335 |
+
touched = {str(k) for k in (changed_keys or ())}
|
| 336 |
+
if not touched:
|
| 337 |
+
return []
|
| 338 |
+
out = []
|
| 339 |
+
for field in ut.ai_enrich_fields(defn):
|
| 340 |
+
cfg = field.get('aiEnrich') or {}
|
| 341 |
+
if (cfg.get('trigger') or {}).get('mode') != 'on_change':
|
| 342 |
+
continue
|
| 343 |
+
refs = set(prompt_refs(cfg.get('prompt')))
|
| 344 |
+
# β A column never fires on a change to ITSELF. Its own value moving is either the run
|
| 345 |
+
# that just wrote it or a person typing over it, and both would loop.
|
| 346 |
+
refs.discard(str(field.get('key') or ''))
|
| 347 |
+
if refs & touched:
|
| 348 |
+
out.append(field)
|
| 349 |
+
return out
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def scheduled_fields(defn):
|
| 353 |
+
"""`[(field, cron)]` for every enrichment column that runs on a cadence.
|
| 354 |
+
|
| 355 |
+
β IT DOES NOT PARSE THE CRON AND MUST NOT. `core.user_tables` stores that string opaque
|
| 356 |
+
precisely so the cadence vocabulary has ONE owner, and the owner is the scheduler
|
| 357 |
+
(`automation_engine`), not the field layer. A second parser here would be a door that starts
|
| 358 |
+
refusing a cadence the other one accepts, which is exactly the divergence the whole field
|
| 359 |
+
contract keeps paying for ([[one-question-two-normalizers]]).
|
| 360 |
+
|
| 361 |
+
β SO THIS IS HALF A FEATURE ON PURPOSE, and the other half is a cross-fence ask: something has
|
| 362 |
+
to TICK. Nothing in lane F's fence runs on a timer.
|
| 363 |
+
"""
|
| 364 |
+
import core.user_tables as ut
|
| 365 |
+
|
| 366 |
+
out = []
|
| 367 |
+
for field in ut.ai_enrich_fields(defn):
|
| 368 |
+
trigger = ((field.get('aiEnrich') or {}).get('trigger') or {})
|
| 369 |
+
if trigger.get('mode') == 'schedule' and str(trigger.get('cron') or '').strip():
|
| 370 |
+
out.append((field, str(trigger['cron'])))
|
| 371 |
+
return out
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def run_field(table_key, col_id, *, st, rows=None, manual=False, policy=None, budget=None,
|
| 375 |
+
timeout=None, ask=None):
|
| 376 |
+
"""Fill one `ai_enrich` column over the rows an automatic run is allowed to touch.
|
| 377 |
+
|
| 378 |
+
Returns a REPORT and never raises for a vendor problem:
|
| 379 |
+
`{planned, filled, failed, skipped{reason: count}, tokens, provider, model,
|
| 380 |
+
limit: <ceiling_report or None>, errors[], problem}`
|
| 381 |
+
|
| 382 |
+
β THE RUN BUDGET IS DERIVED FROM THE COLUMN'S OWN CEILING, and the arithmetic is the point:
|
| 383 |
+
`budget = maxTokens * planned`. `maxTokens` bounds the OUTPUT of one call while spend counts
|
| 384 |
+
INPUT tokens too, so a real run crosses this before it finishes every row. That is intended:
|
| 385 |
+
R13 asked for a token-usage limit and a ceiling that can never bite is not one. A caller may
|
| 386 |
+
pass a TIGHTER `budget`; nothing may pass a looser one.
|
| 387 |
+
|
| 388 |
+
β TWO STORE WRITES FOR THE WHOLE RUN, not two per row: one `patch_many_cells` for the values
|
| 389 |
+
and one `stamp_ai_enrich` for the marks. Each is a read-modify-write of the whole tenant
|
| 390 |
+
document, which is why `patch_cells` in a loop was never an option (D-134's shape).
|
| 391 |
+
|
| 392 |
+
β `ask` is injectable so a gate can exercise the ledger, the ceiling and every failure path
|
| 393 |
+
with no network and no vendor bill. It defaults to the real `_ask`.
|
| 394 |
+
"""
|
| 395 |
+
import ai_review
|
| 396 |
+
import core.user_tables as ut
|
| 397 |
+
|
| 398 |
+
defn = ut.get(table_key, st=st) or {}
|
| 399 |
+
field = next((f for f in ut.ai_enrich_fields(defn) if f.get('key') == str(col_id)), None)
|
| 400 |
+
if field is None:
|
| 401 |
+
return {'problem': f'{col_id!r} is not an AI enrichment column on this database',
|
| 402 |
+
'planned': 0, 'filled': 0, 'failed': 0, 'skipped': {}, 'tokens': 0,
|
| 403 |
+
'errors': [], 'limit': None, 'provider': '', 'model': ''}
|
| 404 |
+
cfg = field.get('aiEnrich') or {}
|
| 405 |
+
# β THE BULK MENU'S TWO CHOICES ARE THIS ONE OVERRIDE (`W34-T54`): "Rows never filled" is
|
| 406 |
+
# `blank` and "All rows" is `always`. β It overrides the column's SAVED POLICY for one run and
|
| 407 |
+
# NOTHING ELSE: `ai_enrich_may_write` still refuses a human-edited cell under every value, so
|
| 408 |
+
# "All rows" means every row the AGENT wrote, which is exactly what the menu label says.
|
| 409 |
+
# β An unrecognised value falls back to the column's own setting rather than to a default: a
|
| 410 |
+
# typo'd override must not quietly widen what a run touches.
|
| 411 |
+
if policy in ut.AI_ENRICH_OVERWRITE:
|
| 412 |
+
cfg = {**cfg, 'overwrite': policy}
|
| 413 |
+
all_rows = defn.get('rows') or {}
|
| 414 |
+
marks = ut.ai_enrich_marks(table_key, col_id, st=st)
|
| 415 |
+
# ββ `rows` NARROWS; `manual` DECIDES WHETHER THE POLICY APPLIES. They are two questions and
|
| 416 |
+
# an early draft of this function conflated them, which would have let an ON-CHANGE fire (an
|
| 417 |
+
# automatic act, scoped to one row) overwrite a cell a person had typed into. That is the one
|
| 418 |
+
# law this whole feature is arranged around, broken by a parameter name.
|
| 419 |
+
# rows=None -> the automatic plan over every row
|
| 420 |
+
# rows=[...], manual=False -> the automatic plan, INTERSECTED with those rows (on_change)
|
| 421 |
+
# rows=[...], manual=True -> exactly those rows, policy skipped
|
| 422 |
+
# `manual` is skippable because a person clicking "run this cell" has asked, in front of the
|
| 423 |
+
# value being replaced; refusing them would be the product overruling an explicit act with a
|
| 424 |
+
# rule written for an implicit one. `ai_enrich_may_write`'s own docstring says exactly this.
|
| 425 |
+
# β A named row is always INTERSECTED with the table's own row set, so a stale caller cannot
|
| 426 |
+
# name a record that has since been deleted.
|
| 427 |
+
named = None if rows is None else [str(r) for r in rows if str(r) in all_rows]
|
| 428 |
+
if named is not None and manual:
|
| 429 |
+
plan = {'run': named, 'skipped': {},
|
| 430 |
+
'estimate': int(cfg.get('maxTokens') or 0) * len(named)}
|
| 431 |
+
else:
|
| 432 |
+
plan = plan_rows(all_rows, col_id, cfg, marks)
|
| 433 |
+
if named is not None:
|
| 434 |
+
keep = set(named)
|
| 435 |
+
dropped = [r for r in plan['run'] if r not in keep]
|
| 436 |
+
plan = {**plan, 'run': [r for r in plan['run'] if r in keep]}
|
| 437 |
+
if dropped:
|
| 438 |
+
plan['skipped'] = {**plan['skipped'], 'not_in_this_run': len(dropped)}
|
| 439 |
+
rows = all_rows
|
| 440 |
+
keys = [f.get('key') for f in (defn.get('fields') or []) if f.get('key')]
|
| 441 |
+
report = {'planned': len(plan['run']), 'filled': 0, 'failed': 0,
|
| 442 |
+
'skipped': dict(plan['skipped']), 'tokens': 0, 'errors': [], 'limit': None,
|
| 443 |
+
'provider': '', 'model': '', 'problem': ''}
|
| 444 |
+
if not plan['run']:
|
| 445 |
+
return report
|
| 446 |
+
|
| 447 |
+
caller = ask or _ask
|
| 448 |
+
ceiling = int(cfg.get('maxTokens') or 0) * len(plan['run'])
|
| 449 |
+
if isinstance(budget, int) and not isinstance(budget, bool):
|
| 450 |
+
ceiling = min(ceiling, max(0, budget))
|
| 451 |
+
live = ai_review.ladder()
|
| 452 |
+
if not live and ask is None:
|
| 453 |
+
# β FAIL CLOSED AND SAY SO. A run that quietly filled nothing looks identical to a run
|
| 454 |
+
# with nothing to do; `planned` above is the number that distinguishes them.
|
| 455 |
+
report['problem'] = 'no AI provider is configured on this deployment'
|
| 456 |
+
return report
|
| 457 |
+
provider = (live or [{'name': 'injected', 'model': 'injected', 'env': '',
|
| 458 |
+
'shape': 'openai', 'url': ''}])[0]
|
| 459 |
+
model = str(cfg.get('model') or provider['model'])
|
| 460 |
+
report['provider'], report['model'] = provider['name'], model
|
| 461 |
+
tmo = float(timeout or TIMEOUT_SECONDS)
|
| 462 |
+
|
| 463 |
+
values, stamps = {}, {}
|
| 464 |
+
for row_id in plan['run']:
|
| 465 |
+
if ceiling and report['tokens'] >= ceiling:
|
| 466 |
+
# β STOP AND REPORT (R6's second sentence). Cells already written STAY written and
|
| 467 |
+
# carry honest marks; nothing is truncated and no cell holds half an answer.
|
| 468 |
+
report['limit'] = ceiling_report(report['tokens'], ceiling,
|
| 469 |
+
report['filled'], report['planned'])
|
| 470 |
+
left = report['planned'] - report['filled'] - report['failed']
|
| 471 |
+
if left > 0:
|
| 472 |
+
report['skipped']['over_budget'] = left
|
| 473 |
+
break
|
| 474 |
+
row = rows.get(row_id) or {}
|
| 475 |
+
text, unresolved = render_prompt(cfg.get('prompt'), row, keys)
|
| 476 |
+
if unresolved:
|
| 477 |
+
# Reported per row rather than refused up front: the prompt is valid for the column,
|
| 478 |
+
# and naming the missing token is what lets somebody fix it.
|
| 479 |
+
why = ('the prompt names ' + ', '.join(unresolved)
|
| 480 |
+
+ ', which is not a column on this database')
|
| 481 |
+
report['failed'] += 1
|
| 482 |
+
stamps[row_id] = {'state': 'error', 'error': why}
|
| 483 |
+
if len(report['errors']) < MAX_REPORTED_ERRORS:
|
| 484 |
+
report['errors'].append({'row': row_id, 'error': why})
|
| 485 |
+
continue
|
| 486 |
+
answer, tokens, problem = caller(provider, model, text[:MAX_CELL_CHARS * 8],
|
| 487 |
+
int(cfg.get('maxTokens') or 300), tmo)
|
| 488 |
+
if isinstance(tokens, int) and not isinstance(tokens, bool):
|
| 489 |
+
report['tokens'] += tokens
|
| 490 |
+
if problem or not answer or answer.strip().upper() == 'UNKNOWN':
|
| 491 |
+
# β THE CELL IS LEFT ALONE. An errored row keeps whatever it held; only the MARK
|
| 492 |
+
# changes, so a failed run never destroys a value it could not replace.
|
| 493 |
+
why = problem or ('the model had nothing to answer from' if not answer
|
| 494 |
+
else 'the model answered UNKNOWN for this row')
|
| 495 |
+
report['failed'] += 1
|
| 496 |
+
stamps[row_id] = {'state': 'error', 'error': why, 'model': model}
|
| 497 |
+
if len(report['errors']) < MAX_REPORTED_ERRORS:
|
| 498 |
+
report['errors'].append({'row': row_id, 'error': why})
|
| 499 |
+
continue
|
| 500 |
+
# β NORMALISED BEFORE THE WRITE, NOT AFTER (note D-6). This is the ONE line where model
|
| 501 |
+
# prose becomes stored product data; a cell fixed after the fact leaves every row already
|
| 502 |
+
# written carrying the dash, and `web_prose` can never see it because it scans source.
|
| 503 |
+
values[row_id] = no_dashes(answer)[:MAX_CELL_CHARS]
|
| 504 |
+
stamps[row_id] = {'state': 'agent', 'model': model,
|
| 505 |
+
'hash': ut.ai_enrich_input_hash(cfg.get('prompt'), row,
|
| 506 |
+
prompt_refs(cfg.get('prompt'))),
|
| 507 |
+
'tokens': tokens if isinstance(tokens, int) else 0}
|
| 508 |
+
report['filled'] += 1
|
| 509 |
+
|
| 510 |
+
if values:
|
| 511 |
+
ut.patch_many_cells(table_key, {r: {str(col_id): v} for r, v in values.items()}, st=st)
|
| 512 |
+
if stamps:
|
| 513 |
+
ut.stamp_ai_enrich(table_key, col_id, stamps, st=st)
|
| 514 |
+
# β The ceiling is re-asked AFTER the loop too: a run whose LAST row crossed the budget spent
|
| 515 |
+
# over it without re-entering the guard, and staying silent there would under-report the one
|
| 516 |
+
# run most likely to surprise somebody.
|
| 517 |
+
if report['limit'] is None and ceiling and report['tokens'] >= ceiling:
|
| 518 |
+
report['limit'] = ceiling_report(report['tokens'], ceiling,
|
| 519 |
+
report['filled'], report['planned'])
|
| 520 |
+
return report
|
api/automation_engine.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/main.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/routes_automation.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/routes_nav.py
CHANGED
|
@@ -248,6 +248,191 @@ def _read_recents(runtime, uname, allowed):
|
|
| 248 |
return out[:_MAX_RECENTS]
|
| 249 |
|
| 250 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 251 |
@router.get("/nav")
|
| 252 |
def nav(session: Session = Depends(require_session)):
|
| 253 |
"""`{pages: [{key,label,source?,chrome}], landing}`.
|
|
@@ -336,7 +521,7 @@ def nav(session: Session = Depends(require_session)):
|
|
| 336 |
# empty rather than leaving 200-[] ambiguous.
|
| 337 |
return {"pages": [], "landing": None, "empty": "no_databases"}
|
| 338 |
raise err(403, "no_surfaces",
|
| 339 |
-
"your account has no dashboards assigned
|
| 340 |
# WAVE 19 (R8 / C1): the tenant's name + icon overrides, merged LAST β after the registry
|
| 341 |
# rows, after the tenant module filter, after the user tables. Merged HERE rather than
|
| 342 |
# applied by the client for one reason: `label` is what every reader of this payload shows,
|
|
@@ -350,6 +535,38 @@ def nav(session: Session = Depends(require_session)):
|
|
| 350 |
# leak with no symptom until two tenants rename the same registry key. Copy the rows before
|
| 351 |
# merging on the day that invariant changes.
|
| 352 |
meta = _read_nav_meta(session.runtime)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
# Wave 21 (C3): the definitions, once β `manage`/`canDelete` below answer from `createdBy`.
|
| 354 |
# WAVE 27 (C9): `locked` answers from `recordMode`, off the same one read.
|
| 355 |
# β W31-T10: that read is now the SAME one the merge above did β it used to be a second,
|
|
@@ -398,6 +615,13 @@ def nav(session: Session = Depends(require_session)):
|
|
| 398 |
p["locked"] = True
|
| 399 |
elif session.admin:
|
| 400 |
p["manage"] = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 401 |
entry = meta.get(p.get("key")) if meta else None
|
| 402 |
if not entry:
|
| 403 |
continue
|
|
@@ -422,8 +646,11 @@ def nav(session: Session = Depends(require_session)):
|
|
| 422 |
# both doors finally agree on what a placeable key is. This block keeps using the assembled
|
| 423 |
# list because it already holds it: re-enumerating here would be a second store read for an
|
| 424 |
# answer sitting in a local variable.
|
| 425 |
-
|
| 426 |
-
|
|
|
|
|
|
|
|
|
|
| 427 |
# β W31-T11 β TWO KINDS OF ABSENCE, NAMED SEPARATELY, and both keys are ALWAYS PRESENT.
|
| 428 |
# `omitted` β this workspace's catalogue does not include these modules. Deliberate.
|
| 429 |
# `degraded` β a part of this payload could not be read. NOT deliberate, and the shell says
|
|
@@ -460,7 +687,7 @@ def nav_opened(body: dict = Body(default=None),
|
|
| 460 |
raise err(400, "bad_request", "no page was named")
|
| 461 |
if not session.runtime.available():
|
| 462 |
raise err(503, "store_unavailable",
|
| 463 |
-
"the tenant store is unavailable
|
| 464 |
stamp, uname = _now(), session.uname
|
| 465 |
|
| 466 |
def _up(data):
|
|
@@ -484,7 +711,7 @@ def nav_opened(body: dict = Body(default=None),
|
|
| 484 |
session.runtime.update(_NAV_RECENTS_KEY, _up, flush='async')
|
| 485 |
except Exception:
|
| 486 |
raise err(503, "store_unavailable",
|
| 487 |
-
"the tenant store refused the write
|
| 488 |
return {"key": key, "at": stamp}
|
| 489 |
|
| 490 |
|
|
@@ -549,7 +776,7 @@ def save_nav_meta(body: dict = Body(default=None),
|
|
| 549 |
if "name" in body:
|
| 550 |
if not key.startswith("ut_"):
|
| 551 |
raise err(400, "name_not_allowed",
|
| 552 |
-
"only a database you created can be renamed
|
| 553 |
"from the module registry")
|
| 554 |
name = " ".join(str(body.get("name") or "").split())[:_MAX_NAV_NAME]
|
| 555 |
if not name:
|
|
@@ -560,7 +787,7 @@ def save_nav_meta(body: dict = Body(default=None),
|
|
| 560 |
|
| 561 |
if not session.runtime.available():
|
| 562 |
raise err(503, "store_unavailable",
|
| 563 |
-
"the tenant store is unavailable
|
| 564 |
|
| 565 |
def _up(data):
|
| 566 |
data = data if isinstance(data, dict) else {}
|
|
@@ -583,7 +810,7 @@ def save_nav_meta(body: dict = Body(default=None),
|
|
| 583 |
session.runtime.update(_NAV_META_KEY, _up)
|
| 584 |
except Exception:
|
| 585 |
raise err(503, "store_unavailable",
|
| 586 |
-
"the change was not saved
|
| 587 |
return {"key": key, "meta": _read_nav_meta(session.runtime).get(key, {})}
|
| 588 |
|
| 589 |
|
|
@@ -608,7 +835,7 @@ def save_nav_prefs(body: dict = Body(default=None),
|
|
| 608 |
clean = _clean_nav_prefs(body or {}, keys, keep_unknown_ut=not enumerated)
|
| 609 |
if not session.runtime.available():
|
| 610 |
raise err(503, "store_unavailable",
|
| 611 |
-
"the tenant store is unavailable
|
| 612 |
|
| 613 |
def _up(data):
|
| 614 |
data = data if isinstance(data, dict) else {}
|
|
@@ -622,7 +849,7 @@ def save_nav_prefs(body: dict = Body(default=None),
|
|
| 622 |
session.runtime.update(_NAV_PREFS_KEY, _up)
|
| 623 |
except Exception:
|
| 624 |
raise err(503, "store_unavailable",
|
| 625 |
-
"the folder change was not saved
|
| 626 |
return {"prefs": clean}
|
| 627 |
|
| 628 |
|
|
|
|
| 248 |
return out[:_MAX_RECENTS]
|
| 249 |
|
| 250 |
|
| 251 |
+
# ββ W34-T10 (ruling R1, contract C1): the "mark important" counts, per database ββββββββββββββ
|
| 252 |
+
|
| 253 |
+
#: A wall-clock ceiling on the WHOLE counting block, checked between databases.
|
| 254 |
+
#:
|
| 255 |
+
#: β IT IS A COLD-START BOUND, NOT A PERFORMANCE BUDGET, and the measurement is the reason it
|
| 256 |
+
#: exists at all. Every database's views live in its OWN store bucket (`<key>_table_workspace`),
|
| 257 |
+
#: never in the `user_tables` document this route already holds β so this block is `N` reads on a
|
| 258 |
+
#: route D-175 spent a wave reducing to one. What makes it affordable is that those buckets are
|
| 259 |
+
#: TINY. Measured on tenant #0, 2026-08-16: twelve of them cost **6.2 ms WARM in total**, and the
|
| 260 |
+
#: largest (`customer_data`) is 45,035 bytes against the 28.6 MB `user_tables` document; ten of the
|
| 261 |
+
#: twelve are under 1 KB. COLD is the other half of the truth: the same twelve cost 7,331 ms of
|
| 262 |
+
#: first fetch. So this budget bounds the FIRST request after a container starts, and a database it
|
| 263 |
+
#: does not reach is reported through `degraded` rather than quietly carrying no number.
|
| 264 |
+
#:
|
| 265 |
+
#: β THE NUMBER IS CHOSEN AGAINST THE CLIENT'S DEADLINE, NOT AGAINST A FEELING. `nav.ts`'s
|
| 266 |
+
#: `NAV_TIMEOUT_MS` is 20 s and a cold `/nav` already spends most of that on the `user_tables`
|
| 267 |
+
#: download; letting this block run unbounded (measured: 7,331 ms for twelve cold buckets) would
|
| 268 |
+
#: convert a slow success into a manufactured failure, which is the exact mistake that deadline's
|
| 269 |
+
#: own comment warns about. 2.5 s is a bound the route can afford to lose.
|
| 270 |
+
_IMPORTANT_BUDGET_S = 2.5
|
| 271 |
+
|
| 272 |
+
#: Suffix `core.view_templates.workspace_key` appends. Stripping it is how a page key becomes the
|
| 273 |
+
#: TOPIC key the cohort bucket is named from β `customer_data` -> `customer_table_workspace` ->
|
| 274 |
+
#: `customer` -> `customer_cohorts`. Derived rather than re-listed on purpose: `_WS_KEYS` is
|
| 275 |
+
#: already the one place `customer_data`/`product_data` are mapped to their topic, and a second
|
| 276 |
+
#: copy here would be free to drift from it ([[one-question-two-normalizers]]).
|
| 277 |
+
_WS_SUFFIX = "_table_workspace"
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
def _visible_views(doc, uname, is_admin, granted_ids):
|
| 281 |
+
"""Every view on ONE database that THIS caller can see, from an already-read workspace doc.
|
| 282 |
+
|
| 283 |
+
β PER-CALLER, NOT TENANT-WIDE, and this is the half a server-side count is most likely to get
|
| 284 |
+
wrong. `<key>_table_workspace` is `{username: {views, fields, overlays}, '__shared__': {...}}`
|
| 285 |
+
β one home per view, never both β so "how many views are marked important here" has a
|
| 286 |
+
DIFFERENT answer for every account. Measured on tenant #0: `leadership` has one marked view and
|
| 287 |
+
`admin` has none, on the same database. A tenant-wide count would put a number in the owner's
|
| 288 |
+
rail that no view sidebar they can open would ever add up to.
|
| 289 |
+
|
| 290 |
+
β `table_store._may_see` is imported rather than re-expressed. It is private, and reaching for
|
| 291 |
+
it is still the right call: the alternative is `TableOps.shared_views`, which re-reads the whole
|
| 292 |
+
bucket per database (the N whole reads this block exists to avoid), and the only other option is
|
| 293 |
+
a second copy of a permission predicate. A wrong copy of `_may_see` widens what a user is told
|
| 294 |
+
exists; a private import cannot.
|
| 295 |
+
"""
|
| 296 |
+
import core.table_store as table_store
|
| 297 |
+
out = {}
|
| 298 |
+
for vid, view in ((doc.get(uname) or {}).get("views") or {}).items():
|
| 299 |
+
if isinstance(view, dict):
|
| 300 |
+
out[str(vid)] = view
|
| 301 |
+
for vid, view in ((doc.get(table_store.SHARED_KEY) or {}).get("views") or {}).items():
|
| 302 |
+
if isinstance(view, dict) and table_store._may_see(view, uname, is_admin):
|
| 303 |
+
out.setdefault(str(vid), view)
|
| 304 |
+
# The wave-21 named-user grants. The ids come from ONE tenant-wide bucket read once for the
|
| 305 |
+
# whole request; the RECORD is already in hand, in whichever stratum its owner keeps it.
|
| 306 |
+
if granted_ids:
|
| 307 |
+
for stratum, blob in doc.items():
|
| 308 |
+
if stratum == uname or not isinstance(blob, dict):
|
| 309 |
+
continue
|
| 310 |
+
for vid, view in (blob.get("views") or {}).items():
|
| 311 |
+
if str(vid) in granted_ids and isinstance(view, dict):
|
| 312 |
+
out.setdefault(str(vid), view)
|
| 313 |
+
return out
|
| 314 |
+
|
| 315 |
+
|
| 316 |
+
def _view_record_count(cfg, cohorts):
|
| 317 |
+
"""How many RECORDS this view resolves to, or None when that cannot be answered for free.
|
| 318 |
+
|
| 319 |
+
β `None` IS AN ANSWER AND IT IS THE IMPORTANT ONE. Two of the three shapes below are exact
|
| 320 |
+
because the view CARRIES its row set; the third β an ordinary filtered view β can only be
|
| 321 |
+
counted by running its filters over the records, and the records are the one thing this route
|
| 322 |
+
must never read (`D-175`/`D-185`: `/nav` is a rows-free projection, and reaching for `rows`
|
| 323 |
+
here raises by that projection's own contract). So a filtered view is reported as UNCOUNTED and
|
| 324 |
+
the database's `partial` flag says so, which is the whole of R6's second sentence applied to a
|
| 325 |
+
badge: a limit that cannot be removed is REPORTED with its cause, never papered over with a
|
| 326 |
+
number that is short by an unknown amount.
|
| 327 |
+
|
| 328 |
+
β THIS IS ALSO WHY THE SERVER DOES NOT SIMPLY MIRROR THE CLIENT. `CustomerGrid::alertCounts`
|
| 329 |
+
counts a filtered view fine and gives up on a SERVER-WINDOWED one (`D-205`'s `Important 0+`);
|
| 330 |
+
this end is the exact inverse β it has no rows at all and no window either. The two are honest
|
| 331 |
+
about different halves, which is why `partial` had to be on the wire rather than derived.
|
| 332 |
+
"""
|
| 333 |
+
if not isinstance(cfg, dict):
|
| 334 |
+
return None
|
| 335 |
+
# A cohort-locked view IS its cohort: the lock and the id are the same fact (`grid_events`
|
| 336 |
+
# re-stamps it on every write), and a cohort's membership is a stored pid LIST, not a query.
|
| 337 |
+
lock = str(cfg.get("cohortLock") or "").strip()
|
| 338 |
+
if lock:
|
| 339 |
+
n = cohorts.get(lock)
|
| 340 |
+
return int(n) if isinstance(n, int) else None
|
| 341 |
+
# A curated row set carries its own count.
|
| 342 |
+
pids = cfg.get("memberPids")
|
| 343 |
+
if isinstance(pids, list) and pids:
|
| 344 |
+
return len(pids)
|
| 345 |
+
return None
|
| 346 |
+
|
| 347 |
+
|
| 348 |
+
def _important_counts(session, keys):
|
| 349 |
+
"""`({key: {marked, counted, partial}}, unread)` for the databases in `keys`.
|
| 350 |
+
|
| 351 |
+
`marked` = views this caller can see on that database whose `config.important is True`.
|
| 352 |
+
`counted` = the SUM of those views' record counts β a record matching two marked views
|
| 353 |
+
contributes twice, because that is what the badge the owner is moving has always
|
| 354 |
+
meant (`CustomerGrid::importantTotal`: *"a sum of per-view counts, which is what
|
| 355 |
+
was asked"*), and a distinct-record total would disagree with the per-view numbers
|
| 356 |
+
a user can read off the sidebar and add up themselves.
|
| 357 |
+
`partial` = at least one marked view could not be counted.
|
| 358 |
+
|
| 359 |
+
`unread` is the set of keys whose bucket did not answer β a store blip or the budget above.
|
| 360 |
+
They are reported through `degraded`, never as a confident zero.
|
| 361 |
+
"""
|
| 362 |
+
import core.shares as shares
|
| 363 |
+
import core.view_templates as view_templates
|
| 364 |
+
import modules.cohort as cohort_mod
|
| 365 |
+
|
| 366 |
+
uname, is_admin = session.uname, session.admin
|
| 367 |
+
out, unread = {}, set()
|
| 368 |
+
try:
|
| 369 |
+
# β THE ROLE FILTER IS NOT BELT-AND-BRACES. `shared_with` answers "is there an entry naming
|
| 370 |
+
# me", and `grid_events._granted_views` β the reader whose answer this badge has to agree
|
| 371 |
+
# with β then requires `role_for(...) in ('view','edit')`. Dropping that second test would
|
| 372 |
+
# count a view the sidebar does not list, i.e. a badge one higher than anything a person can
|
| 373 |
+
# add up. One tenant-wide bucket, read once and cached, so it costs a dict lookup per id.
|
| 374 |
+
granted = {str(v) for v in
|
| 375 |
+
((shares.shared_with(uname, kind="view", st=session.runtime) or {})
|
| 376 |
+
.get("view") or [])
|
| 377 |
+
if shares.role_for("view", str(v), uname,
|
| 378 |
+
st=session.runtime) in ("view", "edit")}
|
| 379 |
+
except Exception:
|
| 380 |
+
granted = set() # no grants is the fail-closed answer: a narrower count, never wider
|
| 381 |
+
cohort_cache = {}
|
| 382 |
+
started = time.perf_counter()
|
| 383 |
+
for key in keys:
|
| 384 |
+
if key in out or key in unread:
|
| 385 |
+
continue # the caller's order may repeat a key; a repeat must not respend
|
| 386 |
+
ws_key = view_templates.workspace_key(key)
|
| 387 |
+
if not ws_key:
|
| 388 |
+
continue # not a table at all (a module surface, a folder head)
|
| 389 |
+
if time.perf_counter() - started > _IMPORTANT_BUDGET_S:
|
| 390 |
+
unread.add(key)
|
| 391 |
+
continue
|
| 392 |
+
try:
|
| 393 |
+
# β PROJECTED, and the two dropped keys are the whole reason this is affordable.
|
| 394 |
+
# `overlays` is per-record field values and `fields` is the schema stratum; neither
|
| 395 |
+
# says anything about a view. On `customer_data` they are most of the bucket.
|
| 396 |
+
doc = session.runtime.get_projection(ws_key, drop=("overlays", "fields")) or {}
|
| 397 |
+
except Exception:
|
| 398 |
+
unread.add(key)
|
| 399 |
+
continue
|
| 400 |
+
marked = [v for v in _visible_views(doc, uname, is_admin, granted).values()
|
| 401 |
+
if ((v.get("config") or {}).get("important") is True)]
|
| 402 |
+
if not marked:
|
| 403 |
+
out[key] = {"marked": 0, "counted": 0, "partial": False}
|
| 404 |
+
continue
|
| 405 |
+
scope = ws_key[:-len(_WS_SUFFIX)] if ws_key.endswith(_WS_SUFFIX) else key
|
| 406 |
+
if scope not in cohort_cache:
|
| 407 |
+
try:
|
| 408 |
+
# β Read through `session.runtime`, NOT `modules.cohort`'s own module-level
|
| 409 |
+
# helpers: those call `core.store` directly, so they carry no tenant namespace.
|
| 410 |
+
# The MODULE is asked for the bucket NAME (it owns that rule) and this route does
|
| 411 |
+
# the reading, which is the only tenant-correct combination.
|
| 412 |
+
bucket = session.runtime.get(cohort_mod.key_for(scope)) or {}
|
| 413 |
+
# β THIS CALLER'S OWN COHORTS ONLY, and the consequence is deliberate: a SHARED
|
| 414 |
+
# view locked to a cohort somebody else owns finds no id here, so it is reported
|
| 415 |
+
# UNCOUNTED (`partial`) rather than counted from a stratum this session cannot
|
| 416 |
+
# see. Widening the read to every user's cohorts would make the badge disclose the
|
| 417 |
+
# SIZE of another person's private list, which is a leak wearing a bug fix.
|
| 418 |
+
mine = bucket.get(uname) or {}
|
| 419 |
+
cohort_cache[scope] = {
|
| 420 |
+
str(cid): len(c.get("members") or [])
|
| 421 |
+
for cid, c in mine.items() if isinstance(c, dict)
|
| 422 |
+
}
|
| 423 |
+
except Exception:
|
| 424 |
+
cohort_cache[scope] = {}
|
| 425 |
+
counted, partial = 0, False
|
| 426 |
+
for view in marked:
|
| 427 |
+
n = _view_record_count(view.get("config"), cohort_cache[scope])
|
| 428 |
+
if n is None:
|
| 429 |
+
partial = True
|
| 430 |
+
else:
|
| 431 |
+
counted += n
|
| 432 |
+
out[key] = {"marked": len(marked), "counted": counted, "partial": partial}
|
| 433 |
+
return out, unread
|
| 434 |
+
|
| 435 |
+
|
| 436 |
@router.get("/nav")
|
| 437 |
def nav(session: Session = Depends(require_session)):
|
| 438 |
"""`{pages: [{key,label,source?,chrome}], landing}`.
|
|
|
|
| 521 |
# empty rather than leaving 200-[] ambiguous.
|
| 522 |
return {"pages": [], "landing": None, "empty": "no_databases"}
|
| 523 |
raise err(403, "no_surfaces",
|
| 524 |
+
"your account has no dashboards assigned. Ask an administrator.")
|
| 525 |
# WAVE 19 (R8 / C1): the tenant's name + icon overrides, merged LAST β after the registry
|
| 526 |
# rows, after the tenant module filter, after the user tables. Merged HERE rather than
|
| 527 |
# applied by the client for one reason: `label` is what every reader of this payload shows,
|
|
|
|
| 535 |
# leak with no symptom until two tenants rename the same registry key. Copy the rows before
|
| 536 |
# merging on the day that invariant changes.
|
| 537 |
meta = _read_nav_meta(session.runtime)
|
| 538 |
+
# ββ W34-T10 (ruling R1, contract C1) β THE MARK-IMPORTANT NUMBERS, COMPUTED ONCE.
|
| 539 |
+
#
|
| 540 |
+
# β COMPUTED HERE RATHER THAN INSIDE THE LOOP BELOW, AND THAT PLACEMENT IS THE SAFETY
|
| 541 |
+
# ARGUMENT, not tidiness. The `for p in pages:` loop sits OUTSIDE the try/except that wraps
|
| 542 |
+
# the `all_defs()` read, so anything raising inside it takes the whole nav down for every
|
| 543 |
+
# user β a 500 where the store-blip path is careful to answer 200 with `degraded`. One call,
|
| 544 |
+
# one guard, and the loop stays a dict lookup.
|
| 545 |
+
#
|
| 546 |
+
# ββ AND THE ORDER IS LOAD-BEARING, WHICH IS THE ONE THING THE FIRST DRAFT GOT WRONG.
|
| 547 |
+
# A budget spent in whatever order the pages happen to arrive is a lottery: measured cold on
|
| 548 |
+
# tenant #0, the block spent all 1.5 s of its first draft on ten EMPTY Odoo buckets and was cut
|
| 549 |
+
# off two rows before `customer_data`, which holds the only marked view in the tenant. The
|
| 550 |
+
# feature would have shipped, been correct, and shown nothing on a cold container. Counting in
|
| 551 |
+
# the user's own RECENTS order first fixes that with a fact this route already holds: a mark
|
| 552 |
+
# lives on a database somebody works in, and `nav_recents` is exactly the list of those,
|
| 553 |
+
# newest first.
|
| 554 |
+
_page_keys = {str(p.get("key", "")) for p in pages}
|
| 555 |
+
recents = _read_recents(session.runtime, session.uname, _page_keys)
|
| 556 |
+
_recent_first = [r["key"] for r in recents]
|
| 557 |
+
_seen_first = set(_recent_first)
|
| 558 |
+
_recent_first += [k for k in (str(p.get("key", "")) for p in pages)
|
| 559 |
+
if k not in _seen_first]
|
| 560 |
+
_important, _imp_unread = {}, set()
|
| 561 |
+
try:
|
| 562 |
+
_important, _imp_unread = _important_counts(session, _recent_first)
|
| 563 |
+
except Exception:
|
| 564 |
+
_imp_unread = set(_page_keys)
|
| 565 |
+
if _imp_unread:
|
| 566 |
+
# The SAME honest-absence channel W31-T11 built for the `ut_*` merge. A database whose
|
| 567 |
+
# count could not be read must not be indistinguishable from one with nothing marked:
|
| 568 |
+
# both would render as no badge, and only one of them is true.
|
| 569 |
+
_degraded.append("important")
|
| 570 |
# Wave 21 (C3): the definitions, once β `manage`/`canDelete` below answer from `createdBy`.
|
| 571 |
# WAVE 27 (C9): `locked` answers from `recordMode`, off the same one read.
|
| 572 |
# β W31-T10: that read is now the SAME one the merge above did β it used to be a second,
|
|
|
|
| 615 |
p["locked"] = True
|
| 616 |
elif session.admin:
|
| 617 |
p["manage"] = True
|
| 618 |
+
# β W34-T10 / C1 β always emitted for a database this route could READ, including when
|
| 619 |
+
# nothing is marked (`{marked: 0, counted: 0, partial: false}`). That is the same rule
|
| 620 |
+
# `omitted`/`degraded` follow at the bottom of this function and for the same reason: a key
|
| 621 |
+
# a consumer has to test for is a key a consumer forgets to test for. ABSENT here means
|
| 622 |
+
# "not a database, or we could not read it" β the second case is named in `degraded`.
|
| 623 |
+
if key in _important:
|
| 624 |
+
p["important"] = _important[key]
|
| 625 |
entry = meta.get(p.get("key")) if meta else None
|
| 626 |
if not entry:
|
| 627 |
continue
|
|
|
|
| 646 |
# both doors finally agree on what a placeable key is. This block keeps using the assembled
|
| 647 |
# list because it already holds it: re-enumerating here would be a second store read for an
|
| 648 |
# answer sitting in a local variable.
|
| 649 |
+
#
|
| 650 |
+
# β W34-T10 MOVED THE READ, NOT THE RULE. `recents` is now computed ABOVE the enrichment loop,
|
| 651 |
+
# because the important-count block spends its budget in RECENTS ORDER (see there). It is still
|
| 652 |
+
# ONE read of `nav_recents`, still pruned against the assembled page list, and it is used here
|
| 653 |
+
# unchanged β a second `_read_recents` call would be the extra store read this comment forbids.
|
| 654 |
# β W31-T11 β TWO KINDS OF ABSENCE, NAMED SEPARATELY, and both keys are ALWAYS PRESENT.
|
| 655 |
# `omitted` β this workspace's catalogue does not include these modules. Deliberate.
|
| 656 |
# `degraded` β a part of this payload could not be read. NOT deliberate, and the shell says
|
|
|
|
| 687 |
raise err(400, "bad_request", "no page was named")
|
| 688 |
if not session.runtime.available():
|
| 689 |
raise err(503, "store_unavailable",
|
| 690 |
+
"the tenant store is unavailable. Nothing was recorded.")
|
| 691 |
stamp, uname = _now(), session.uname
|
| 692 |
|
| 693 |
def _up(data):
|
|
|
|
| 711 |
session.runtime.update(_NAV_RECENTS_KEY, _up, flush='async')
|
| 712 |
except Exception:
|
| 713 |
raise err(503, "store_unavailable",
|
| 714 |
+
"the tenant store refused the write. Nothing was recorded.")
|
| 715 |
return {"key": key, "at": stamp}
|
| 716 |
|
| 717 |
|
|
|
|
| 776 |
if "name" in body:
|
| 777 |
if not key.startswith("ut_"):
|
| 778 |
raise err(400, "name_not_allowed",
|
| 779 |
+
"only a database you created can be renamed. This one's name comes "
|
| 780 |
"from the module registry")
|
| 781 |
name = " ".join(str(body.get("name") or "").split())[:_MAX_NAV_NAME]
|
| 782 |
if not name:
|
|
|
|
| 787 |
|
| 788 |
if not session.runtime.available():
|
| 789 |
raise err(503, "store_unavailable",
|
| 790 |
+
"the tenant store is unavailable. Nothing was saved.")
|
| 791 |
|
| 792 |
def _up(data):
|
| 793 |
data = data if isinstance(data, dict) else {}
|
|
|
|
| 810 |
session.runtime.update(_NAV_META_KEY, _up)
|
| 811 |
except Exception:
|
| 812 |
raise err(503, "store_unavailable",
|
| 813 |
+
"the change was not saved: the store refused the write.")
|
| 814 |
return {"key": key, "meta": _read_nav_meta(session.runtime).get(key, {})}
|
| 815 |
|
| 816 |
|
|
|
|
| 835 |
clean = _clean_nav_prefs(body or {}, keys, keep_unknown_ut=not enumerated)
|
| 836 |
if not session.runtime.available():
|
| 837 |
raise err(503, "store_unavailable",
|
| 838 |
+
"the tenant store is unavailable. Nothing was saved.")
|
| 839 |
|
| 840 |
def _up(data):
|
| 841 |
data = data if isinstance(data, dict) else {}
|
|
|
|
| 849 |
session.runtime.update(_NAV_PREFS_KEY, _up)
|
| 850 |
except Exception:
|
| 851 |
raise err(503, "store_unavailable",
|
| 852 |
+
"the folder change was not saved: the store refused the write.")
|
| 853 |
return {"prefs": clean}
|
| 854 |
|
| 855 |
|
api/routes_query.py
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
api/routes_tables.py
CHANGED
|
@@ -165,7 +165,7 @@ def _records_or_refuse(session, table_key, st=None):
|
|
| 165 |
defn = _defn_or_refuse(session, table_key, st=st)
|
| 166 |
if not _ut().records_mutable(table_key, st=st):
|
| 167 |
raise err(403, "records_read_only",
|
| 168 |
-
"records in this automation-owned database are read-only
|
| 169 |
"handles in a Profile database and let enrichment populate this database")
|
| 170 |
return defn
|
| 171 |
|
|
@@ -376,7 +376,7 @@ def delete_shared_field(table_key: str, field_key: str,
|
|
| 376 |
owner = str(defn.get("createdBy") or "")
|
| 377 |
if not session.admin and owner != session.uname:
|
| 378 |
raise err(403, "forbidden",
|
| 379 |
-
f"a tenant-wide column can be removed by its creator or an admin
|
| 380 |
f"was added by {owner or 'somebody else'}, and dropping it would delete the "
|
| 381 |
f"value for every account")
|
| 382 |
dropped = shared_overlay.drop_field(table_key, str(field_key), st=session.runtime)
|
|
@@ -735,17 +735,17 @@ def create_table(body: dict = Body(default=None),
|
|
| 735 |
raise err(400, "bad_label", "give the database a name")
|
| 736 |
if not session.runtime.available():
|
| 737 |
raise err(503, "store_unavailable",
|
| 738 |
-
"the tenant store is unavailable
|
| 739 |
source = body.get("source")
|
| 740 |
try:
|
| 741 |
key = ut.create(label, session.uname, fields=body.get("fields"),
|
| 742 |
source=source, st=session.runtime)
|
| 743 |
except Exception:
|
| 744 |
raise err(503, "store_unavailable",
|
| 745 |
-
"the tenant store refused the write
|
| 746 |
if not key:
|
| 747 |
raise err(400, "refused",
|
| 748 |
-
f"could not create it
|
| 749 |
f"{ut.MAX_TABLES} databases")
|
| 750 |
return {"key": key}
|
| 751 |
|
|
@@ -845,7 +845,7 @@ def delete_table(table_key: str, session: Session = Depends(require_session)):
|
|
| 845 |
try:
|
| 846 |
_ut().delete(table_key, st=session.runtime)
|
| 847 |
except Exception:
|
| 848 |
-
raise err(503, "store_unavailable", "the delete did not land
|
| 849 |
return {"ok": True}
|
| 850 |
|
| 851 |
|
|
@@ -993,6 +993,9 @@ def table_rows(table_key: str, session: Session = Depends(require_session)):
|
|
| 993 |
# so a tenant using `json` for a short config sees no change at all.
|
| 994 |
rows = aios_grid.rows_from_pool(
|
| 995 |
g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
|
|
|
|
|
|
|
|
|
|
| 996 |
# β R6's SECOND SENTENCE, ON THE WIRE (W30-T29). *"if there is lag or it can't be done, you
|
| 997 |
# need to explicitly tell me why and recommend a fix."* A ceiling that still applies to this
|
| 998 |
# database says so here, with its cause and the recommendation, rather than waiting to be
|
|
@@ -1016,6 +1019,48 @@ def table_rows(table_key: str, session: Session = Depends(require_session)):
|
|
| 1016 |
"recordsMutable": _ut().records_mutable(table_key, st=_lent)}
|
| 1017 |
|
| 1018 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1019 |
@router.get("/tables/{table_key}/rows/{pid}/fields/{fkey}")
|
| 1020 |
def table_cell(table_key: str, pid: str, fkey: str,
|
| 1021 |
session: Session = Depends(require_session)):
|
|
@@ -1062,7 +1107,7 @@ def add_row(table_key: str, body: dict = Body(default=None),
|
|
| 1062 |
rid = ut.add_row(table_key, values, session.uname, st=session.runtime,
|
| 1063 |
rid=(body or {}).get("rid"))
|
| 1064 |
except Exception:
|
| 1065 |
-
raise err(503, "store_unavailable", "the row was not saved
|
| 1066 |
if rid is None:
|
| 1067 |
# C3 (wave 25): `add_row` also refuses a profile cell that is not a handle, so the cap
|
| 1068 |
# sentence alone would misdirect β the reader would go and count rows. Ask the same
|
|
@@ -1072,11 +1117,11 @@ def add_row(table_key: str, body: dict = Body(default=None),
|
|
| 1072 |
_h, ok = ut.normalize_profile(values[pf["key"]], pf["profile"].get("source"))
|
| 1073 |
if not ok:
|
| 1074 |
raise err(400, "refused",
|
| 1075 |
-
f"{str(values[pf['key']])[:80]!r} is not an Instagram profile
|
| 1076 |
f"{pf.get('label') or pf['key']!r} takes a handle (@name) or a "
|
| 1077 |
f"profile link (instagram.com/name)")
|
| 1078 |
raise err(400, "refused",
|
| 1079 |
-
f"row refused
|
| 1080 |
_refresh_relations(session)
|
| 1081 |
return {"rid": rid, "pid": int(rid)}
|
| 1082 |
|
|
@@ -1130,14 +1175,14 @@ def import_rows(table_key: str, body: dict = Body(default=None),
|
|
| 1130 |
for key, value in row.items():
|
| 1131 |
why = ut.cell_type_refusal(by_key[key], value)
|
| 1132 |
if why:
|
| 1133 |
-
raise err(400, "bad_value", f"row {index + 1}: {why}
|
| 1134 |
try:
|
| 1135 |
made = ut.add_rows(table_key, rows_in, session.uname, st=session.runtime)
|
| 1136 |
except Exception:
|
| 1137 |
-
raise err(503, "store_unavailable", "nothing was imported
|
| 1138 |
if made is None:
|
| 1139 |
raise err(400, "refused",
|
| 1140 |
-
f"nothing was imported
|
| 1141 |
f"its {ut.MAX_ROWS}-row cap, or a profile column rejected a value")
|
| 1142 |
_refresh_relations(session)
|
| 1143 |
return {"imported": len(made), "pids": [int(r) for r in made]}
|
|
@@ -1168,7 +1213,7 @@ def _field_or_refuse(session, table_key, fkey=""):
|
|
| 1168 |
ut = _ut()
|
| 1169 |
if not ut.is_user_table(table_key, st=session.runtime):
|
| 1170 |
raise err(400, "not_a_user_table",
|
| 1171 |
-
"only a user-created database has an editable schema
|
| 1172 |
"owns its own columns")
|
| 1173 |
if fkey and not ut.may_edit_field(table_key, fkey, session.uname, session.admin,
|
| 1174 |
st=session.runtime):
|
|
@@ -1221,12 +1266,41 @@ def add_field(table_key: str, body: dict = Body(default=None),
|
|
| 1221 |
# field relational", which is the question that was always meant.
|
| 1222 |
if field.get("type") in ("link", "rollup"):
|
| 1223 |
_refresh_relations(session)
|
| 1224 |
-
return {"field": field}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1225 |
|
| 1226 |
|
| 1227 |
def _refusal_sentence(ut, session, body, table_key="", fkey=""):
|
| 1228 |
"""Why was this column refused? The specific reason when we can name one, the general list
|
| 1229 |
otherwise β never a specific-sounding guess."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1230 |
# β C3 (wave 25, R7): the one-profile-per-table refusal NAMES THE EXISTING COLUMN, which is
|
| 1231 |
# what the contract asks for and what makes it actionable β "at most one" sends the reader
|
| 1232 |
# hunting through a 40-column schema for a flag they cannot see from the header.
|
|
@@ -1238,11 +1312,11 @@ def _refusal_sentence(ut, session, body, table_key="", fkey=""):
|
|
| 1238 |
# real mistake was the column type sends them to fix the wrong thing, which is the
|
| 1239 |
# misdirection D-46 closed one door over.
|
| 1240 |
if str((body or {}).get("type") or "text").strip().lower() != "text":
|
| 1241 |
-
return ("a profile column is a flag on an ordinary TEXT column
|
| 1242 |
"is typed into it, which it can only do for text")
|
| 1243 |
existing = ut.profile_field(table_key, st=session.runtime) if table_key else None
|
| 1244 |
if existing and existing.get("key") != str(fkey):
|
| 1245 |
-
return (f"this database already has a profile column
|
| 1246 |
f"{existing.get('label') or existing.get('key')!r}. A database has at most "
|
| 1247 |
f"one, so the automation knows which handle to enrich; edit that column, or "
|
| 1248 |
f"take the flag off it first")
|
|
@@ -1250,16 +1324,16 @@ def _refusal_sentence(ut, session, body, table_key="", fkey=""):
|
|
| 1250 |
if isinstance(bag, dict):
|
| 1251 |
flow = str(bag.get("flowId") or "").strip()
|
| 1252 |
if not flow:
|
| 1253 |
-
return ("an automation column has to name the automation that fills it
|
| 1254 |
"flow, or make this an ordinary column")
|
| 1255 |
if not ut.flow_bound(bag, st=session.runtime):
|
| 1256 |
return (f"this column names automation {flow!r}, which does not exist in this "
|
| 1257 |
-
f"workspace
|
| 1258 |
kind = str((body or {}).get("type") or "").strip()
|
| 1259 |
if kind and kind not in ut.UT_FIELD_TYPES:
|
| 1260 |
return (f"{kind!r} is not a column type here (types: "
|
| 1261 |
f"{', '.join(sorted(ut.UT_FIELD_TYPES))})")
|
| 1262 |
-
return (f"the column was refused
|
| 1263 |
f"{ut.MAX_FIELDS}-column cap (types: {', '.join(sorted(ut.UT_FIELD_TYPES))})")
|
| 1264 |
|
| 1265 |
|
|
@@ -1282,7 +1356,7 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
|
|
| 1282 |
try:
|
| 1283 |
migrated = ut.rename_choice_values(table_key, fkey, renames, st=session.runtime)
|
| 1284 |
except Exception:
|
| 1285 |
-
raise err(503, "store_unavailable", "the rename did not land
|
| 1286 |
# The per-user workspace strata and any view filter naming the old value are the OTHER
|
| 1287 |
# half of C-RENAME and belong to `core.table_store`. Called only if it is there: an
|
| 1288 |
# enumerator's mirror waits for its counterpart rather than guessing at its shape, and a
|
|
@@ -1309,7 +1383,79 @@ def patch_field(table_key: str, fkey: str, body: dict = Body(default=None),
|
|
| 1309 |
out = {"field": field}
|
| 1310 |
if migrated is not None:
|
| 1311 |
out["migrated"] = migrated
|
| 1312 |
-
return out
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1313 |
|
| 1314 |
|
| 1315 |
@router.delete("/tables/{table_key}/fields/{fkey}")
|
|
@@ -1317,7 +1463,7 @@ def delete_field(table_key: str, fkey: str, session: Session = Depends(require_s
|
|
| 1317 |
_field_or_refuse(session, table_key, fkey)
|
| 1318 |
if not _ut().delete_field(table_key, fkey, st=session.runtime):
|
| 1319 |
raise err(400, "refused",
|
| 1320 |
-
"that column could not be removed
|
| 1321 |
_refresh_relations(session)
|
| 1322 |
return {"deleted": fkey}
|
| 1323 |
|
|
@@ -1342,7 +1488,7 @@ def delete_row(table_key: str, rid: str, session: Session = Depends(require_sess
|
|
| 1342 |
try:
|
| 1343 |
ok = _ut().delete_row(table_key, rid, st=lent)
|
| 1344 |
except Exception:
|
| 1345 |
-
raise err(503, "store_unavailable", "the delete did not land
|
| 1346 |
if not ok:
|
| 1347 |
raise err(400, "refused", "rows can only be deleted from user-created databases")
|
| 1348 |
_refresh_relations(session)
|
|
@@ -1375,7 +1521,7 @@ def patch_row(table_key: str, pid: int, body: dict = Body(default=None),
|
|
| 1375 |
"pid": pid, "updates": updates}, ctx)
|
| 1376 |
except grid_events.StoreUnavailable:
|
| 1377 |
raise err(503, "store_unavailable",
|
| 1378 |
-
"the tenant store is unavailable
|
| 1379 |
# β THE READ-BACK IS THE DEFINITION ROW, AND ON A `ut_` SCOPE THAT IS THE WHOLE OF IT.
|
| 1380 |
#
|
| 1381 |
# β CORRECTED, wave-29 T22 (owner item 2a): this note used to say "THE READ-BACK SPANS BOTH
|
|
@@ -1414,6 +1560,22 @@ def patch_row(table_key: str, pid: int, body: dict = Body(default=None),
|
|
| 1414 |
return False
|
| 1415 |
|
| 1416 |
refused = sorted(k for k in updates if not _took(k))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1417 |
out = {"pid": pid, "updates": accepted}
|
| 1418 |
if refused:
|
| 1419 |
out["refused"] = refused
|
|
|
|
| 165 |
defn = _defn_or_refuse(session, table_key, st=st)
|
| 166 |
if not _ut().records_mutable(table_key, st=st):
|
| 167 |
raise err(403, "records_read_only",
|
| 168 |
+
"records in this automation-owned database are read-only. Add Instagram "
|
| 169 |
"handles in a Profile database and let enrichment populate this database")
|
| 170 |
return defn
|
| 171 |
|
|
|
|
| 376 |
owner = str(defn.get("createdBy") or "")
|
| 377 |
if not session.admin and owner != session.uname:
|
| 378 |
raise err(403, "forbidden",
|
| 379 |
+
f"a tenant-wide column can be removed by its creator or an admin. This one "
|
| 380 |
f"was added by {owner or 'somebody else'}, and dropping it would delete the "
|
| 381 |
f"value for every account")
|
| 382 |
dropped = shared_overlay.drop_field(table_key, str(field_key), st=session.runtime)
|
|
|
|
| 735 |
raise err(400, "bad_label", "give the database a name")
|
| 736 |
if not session.runtime.available():
|
| 737 |
raise err(503, "store_unavailable",
|
| 738 |
+
"the tenant store is unavailable. Nothing was created")
|
| 739 |
source = body.get("source")
|
| 740 |
try:
|
| 741 |
key = ut.create(label, session.uname, fields=body.get("fields"),
|
| 742 |
source=source, st=session.runtime)
|
| 743 |
except Exception:
|
| 744 |
raise err(503, "store_unavailable",
|
| 745 |
+
"the tenant store refused the write. Nothing was created")
|
| 746 |
if not key:
|
| 747 |
raise err(400, "refused",
|
| 748 |
+
f"could not create it. The name may be empty or this tenant already has "
|
| 749 |
f"{ut.MAX_TABLES} databases")
|
| 750 |
return {"key": key}
|
| 751 |
|
|
|
|
| 845 |
try:
|
| 846 |
_ut().delete(table_key, st=session.runtime)
|
| 847 |
except Exception:
|
| 848 |
+
raise err(503, "store_unavailable", "the delete did not land. Try again")
|
| 849 |
return {"ok": True}
|
| 850 |
|
| 851 |
|
|
|
|
| 993 |
# so a tenant using `json` for a short config sees no change at all.
|
| 994 |
rows = aios_grid.rows_from_pool(
|
| 995 |
g["rows_src"], g["fields"], _thin_json(g["fields"], merged, table_key), derived=g["derived"])
|
| 996 |
+
# ββ WAVE-34 (R13) β the per-cell enrichment STATE rides the row, beside `_created`/`lat`/
|
| 997 |
+
# `lon`. See `_stamp_ai_states` for why it is a row key rather than a map beside `rows`.
|
| 998 |
+
_stamp_ai_states(table_key, g["fields"], rows, st=_lent)
|
| 999 |
# β R6's SECOND SENTENCE, ON THE WIRE (W30-T29). *"if there is lag or it can't be done, you
|
| 1000 |
# need to explicitly tell me why and recommend a fix."* A ceiling that still applies to this
|
| 1001 |
# database says so here, with its cause and the recommendation, rather than waiting to be
|
|
|
|
| 1019 |
"recordsMutable": _ut().records_mutable(table_key, st=_lent)}
|
| 1020 |
|
| 1021 |
|
| 1022 |
+
#: The per-cell provenance a row carries on the wire, one key per enrichment column.
|
| 1023 |
+
#: β A ROW KEY RATHER THAN A SIBLING MAP, and the choice is load-bearing rather than cosmetic.
|
| 1024 |
+
#: A `{colId: {pid: state}}` map beside `rows` would need a new PROP on `RecordDetail` and a new
|
| 1025 |
+
#: argument at `CustomerGrid`'s call site, both in another lane's fence, to reach the two surfaces
|
| 1026 |
+
#: that must paint it. The row already carries `_created`, `lat` and `lon` for exactly this
|
| 1027 |
+
#: reason, so every reader already tolerates keys that are not columns, and both surfaces hold the
|
| 1028 |
+
#: row already. β COLLISION-PROOF BY CONSTRUCTION: `_clean_field` strips leading underscores off
|
| 1029 |
+
#: every field key, so no column can ever be called `_ai_*`.
|
| 1030 |
+
AI_STATE_PREFIX = "_ai_"
|
| 1031 |
+
|
| 1032 |
+
|
| 1033 |
+
def _stamp_ai_states(table_key, fields, rows, st=None):
|
| 1034 |
+
"""Add `_ai_<colId>` to each row for every `ai_enrich` column. Mutates and returns `rows`.
|
| 1035 |
+
|
| 1036 |
+
β A PROJECTION, NOT THE MARK SET. The stratum holds a hash, a model, a timestamp, a token
|
| 1037 |
+
count and an error per cell; a browser needs ONE WORD to paint a state, and shipping the rest
|
| 1038 |
+
would grow this payload by a dict per enriched cell for data no reader reads. The vocabulary
|
| 1039 |
+
is `agent`/`human`/`stale`/`error` (`api/ai_enrich.py::cell_state`), which is also what the
|
| 1040 |
+
RUNNER obeys, so the badge and the behaviour cannot disagree about whose cell it is.
|
| 1041 |
+
|
| 1042 |
+
β AN ABSENT KEY MEANS `empty`, and only non-empty states are stamped: a table with no
|
| 1043 |
+
enrichment column is untouched, and a freshly created column adds nothing until something
|
| 1044 |
+
runs. β `stale` is DERIVED here rather than stored, so it is computed against TODAY'S row
|
| 1045 |
+
instead of against whatever was true when the value was written.
|
| 1046 |
+
"""
|
| 1047 |
+
cols = [f for f in (fields or []) if str(f.get("type") or "") == "ai_enrich"]
|
| 1048 |
+
if not cols:
|
| 1049 |
+
return rows
|
| 1050 |
+
import ai_enrich as _ae
|
| 1051 |
+
for field in cols:
|
| 1052 |
+
col = str(field.get("key") or "")
|
| 1053 |
+
marks = _ut().ai_enrich_marks(table_key, col, st=st)
|
| 1054 |
+
cfg = field.get("aiEnrich") if isinstance(field.get("aiEnrich"), dict) else {}
|
| 1055 |
+
for row in (rows or []):
|
| 1056 |
+
if not isinstance(row, dict):
|
| 1057 |
+
continue
|
| 1058 |
+
state = _ae.cell_state(marks.get(str(row.get("pid"))), cfg, row, col)
|
| 1059 |
+
if state != "empty":
|
| 1060 |
+
row[AI_STATE_PREFIX + col] = state
|
| 1061 |
+
return rows
|
| 1062 |
+
|
| 1063 |
+
|
| 1064 |
@router.get("/tables/{table_key}/rows/{pid}/fields/{fkey}")
|
| 1065 |
def table_cell(table_key: str, pid: str, fkey: str,
|
| 1066 |
session: Session = Depends(require_session)):
|
|
|
|
| 1107 |
rid = ut.add_row(table_key, values, session.uname, st=session.runtime,
|
| 1108 |
rid=(body or {}).get("rid"))
|
| 1109 |
except Exception:
|
| 1110 |
+
raise err(503, "store_unavailable", "the row was not saved. The store refused")
|
| 1111 |
if rid is None:
|
| 1112 |
# C3 (wave 25): `add_row` also refuses a profile cell that is not a handle, so the cap
|
| 1113 |
# sentence alone would misdirect β the reader would go and count rows. Ask the same
|
|
|
|
| 1117 |
_h, ok = ut.normalize_profile(values[pf["key"]], pf["profile"].get("source"))
|
| 1118 |
if not ok:
|
| 1119 |
raise err(400, "refused",
|
| 1120 |
+
f"{str(values[pf['key']])[:80]!r} is not an Instagram profile. "
|
| 1121 |
f"{pf.get('label') or pf['key']!r} takes a handle (@name) or a "
|
| 1122 |
f"profile link (instagram.com/name)")
|
| 1123 |
raise err(400, "refused",
|
| 1124 |
+
f"row refused. The table may be at its {ut.MAX_ROWS}-row cap")
|
| 1125 |
_refresh_relations(session)
|
| 1126 |
return {"rid": rid, "pid": int(rid)}
|
| 1127 |
|
|
|
|
| 1175 |
for key, value in row.items():
|
| 1176 |
why = ut.cell_type_refusal(by_key[key], value)
|
| 1177 |
if why:
|
| 1178 |
+
raise err(400, "bad_value", f"row {index + 1}: {why}. Nothing was imported")
|
| 1179 |
try:
|
| 1180 |
made = ut.add_rows(table_key, rows_in, session.uname, st=session.runtime)
|
| 1181 |
except Exception:
|
| 1182 |
+
raise err(503, "store_unavailable", "nothing was imported. The store refused")
|
| 1183 |
if made is None:
|
| 1184 |
raise err(400, "refused",
|
| 1185 |
+
f"nothing was imported. {len(rows_in)} rows would take this database past "
|
| 1186 |
f"its {ut.MAX_ROWS}-row cap, or a profile column rejected a value")
|
| 1187 |
_refresh_relations(session)
|
| 1188 |
return {"imported": len(made), "pids": [int(r) for r in made]}
|
|
|
|
| 1213 |
ut = _ut()
|
| 1214 |
if not ut.is_user_table(table_key, st=session.runtime):
|
| 1215 |
raise err(400, "not_a_user_table",
|
| 1216 |
+
"only a user-created database has an editable schema. A connected source "
|
| 1217 |
"owns its own columns")
|
| 1218 |
if fkey and not ut.may_edit_field(table_key, fkey, session.uname, session.admin,
|
| 1219 |
st=session.runtime):
|
|
|
|
| 1266 |
# field relational", which is the question that was always meant.
|
| 1267 |
if field.get("type") in ("link", "rollup"):
|
| 1268 |
_refresh_relations(session)
|
| 1269 |
+
return _with_dropped(ut, {"field": field}, body)
|
| 1270 |
+
|
| 1271 |
+
|
| 1272 |
+
def _with_dropped(ut, out, body):
|
| 1273 |
+
"""Attach the NAMED list of config keys the validator did not keep (wave 34, R13 / W34-T51).
|
| 1274 |
+
|
| 1275 |
+
ββ THIS EXISTS BECAUSE THE VALIDATOR HAS NO ERROR CHANNEL AND CANNOT GROW ONE. Every bag
|
| 1276 |
+
cleaner in `core/user_tables.py` returns `dict | None` and drops unknown keys in silence, and
|
| 1277 |
+
`verify_fields_contract` asserts that they do -- so the drop is correct and the SILENCE is the
|
| 1278 |
+
defect. T51's contract is that an unknown config key is dropped **and named**, so the naming
|
| 1279 |
+
rides the response beside the accepted field rather than inside the validator.
|
| 1280 |
+
|
| 1281 |
+
β OMITTED WHEN EMPTY, deliberately: an always-present `dropped: []` teaches every reader to
|
| 1282 |
+
ignore the key, which is how a report stops being read before it stops being true.
|
| 1283 |
+
"""
|
| 1284 |
+
dropped = ut.ai_enrich_dropped_keys((body or {}).get("aiEnrich"))
|
| 1285 |
+
if dropped:
|
| 1286 |
+
out = dict(out)
|
| 1287 |
+
out["dropped"] = dropped
|
| 1288 |
+
return out
|
| 1289 |
|
| 1290 |
|
| 1291 |
def _refusal_sentence(ut, session, body, table_key="", fkey=""):
|
| 1292 |
"""Why was this column refused? The specific reason when we can name one, the general list
|
| 1293 |
otherwise β never a specific-sounding guess."""
|
| 1294 |
+
# β WAVE-34 (R13): the enrichment column's own sentence, named BEFORE the automation bag
|
| 1295 |
+
# below. `_clean_field` DERIVES `field.automation` for this kind, so a refused enrichment
|
| 1296 |
+
# column would otherwise be explained by the flow law -- "pick a flow, or make this an
|
| 1297 |
+
# ordinary column" -- which is the D-46 misdirection exactly, pointing at a control the user
|
| 1298 |
+
# never touched.
|
| 1299 |
+
if str((body or {}).get("type") or "").strip().lower() == "ai_enrich":
|
| 1300 |
+
bag = (body or {}).get("aiEnrich")
|
| 1301 |
+
if not isinstance(bag, dict) or not str(bag.get("prompt") or "").strip():
|
| 1302 |
+
return ("an AI enrichment column needs a prompt. It is the only thing that can "
|
| 1303 |
+
"produce a value here, so a column without one would stay empty forever")
|
| 1304 |
# β C3 (wave 25, R7): the one-profile-per-table refusal NAMES THE EXISTING COLUMN, which is
|
| 1305 |
# what the contract asks for and what makes it actionable β "at most one" sends the reader
|
| 1306 |
# hunting through a 40-column schema for a flag they cannot see from the header.
|
|
|
|
| 1312 |
# real mistake was the column type sends them to fix the wrong thing, which is the
|
| 1313 |
# misdirection D-46 closed one door over.
|
| 1314 |
if str((body or {}).get("type") or "text").strip().lower() != "text":
|
| 1315 |
+
return ("a profile column is a flag on an ordinary TEXT column. It validates what "
|
| 1316 |
"is typed into it, which it can only do for text")
|
| 1317 |
existing = ut.profile_field(table_key, st=session.runtime) if table_key else None
|
| 1318 |
if existing and existing.get("key") != str(fkey):
|
| 1319 |
+
return (f"this database already has a profile column: "
|
| 1320 |
f"{existing.get('label') or existing.get('key')!r}. A database has at most "
|
| 1321 |
f"one, so the automation knows which handle to enrich; edit that column, or "
|
| 1322 |
f"take the flag off it first")
|
|
|
|
| 1324 |
if isinstance(bag, dict):
|
| 1325 |
flow = str(bag.get("flowId") or "").strip()
|
| 1326 |
if not flow:
|
| 1327 |
+
return ("an automation column has to name the automation that fills it. Pick a "
|
| 1328 |
"flow, or make this an ordinary column")
|
| 1329 |
if not ut.flow_bound(bag, st=session.runtime):
|
| 1330 |
return (f"this column names automation {flow!r}, which does not exist in this "
|
| 1331 |
+
f"workspace. It may have been deleted; pick a flow that is still there")
|
| 1332 |
kind = str((body or {}).get("type") or "").strip()
|
| 1333 |
if kind and kind not in ut.UT_FIELD_TYPES:
|
| 1334 |
return (f"{kind!r} is not a column type here (types: "
|
| 1335 |
f"{', '.join(sorted(ut.UT_FIELD_TYPES))})")
|
| 1336 |
+
return (f"the column was refused. Check the name and type, or the table may be at its "
|
| 1337 |
f"{ut.MAX_FIELDS}-column cap (types: {', '.join(sorted(ut.UT_FIELD_TYPES))})")
|
| 1338 |
|
| 1339 |
|
|
|
|
| 1356 |
try:
|
| 1357 |
migrated = ut.rename_choice_values(table_key, fkey, renames, st=session.runtime)
|
| 1358 |
except Exception:
|
| 1359 |
+
raise err(503, "store_unavailable", "the rename did not land. Try again")
|
| 1360 |
# The per-user workspace strata and any view filter naming the old value are the OTHER
|
| 1361 |
# half of C-RENAME and belong to `core.table_store`. Called only if it is there: an
|
| 1362 |
# enumerator's mirror waits for its counterpart rather than guessing at its shape, and a
|
|
|
|
| 1383 |
out = {"field": field}
|
| 1384 |
if migrated is not None:
|
| 1385 |
out["migrated"] = migrated
|
| 1386 |
+
return _with_dropped(ut, out, body)
|
| 1387 |
+
|
| 1388 |
+
|
| 1389 |
+
def _fire_on_change(table_key, pid, changed, session):
|
| 1390 |
+
"""Run any `on_change` enrichment column whose prompt names a cell that just moved.
|
| 1391 |
+
|
| 1392 |
+
β ONE DEFINITION READ FOR THE WHOLE WRITE, and that is the point rather than an optimisation.
|
| 1393 |
+
`automation_engine.grid_hook` calls `all_definitions(st)` once PER EVENT, which turns a
|
| 1394 |
+
20,000-row import into 20,000 whole-document reads on the single process this product runs
|
| 1395 |
+
(`D-134`, and `W34-T54`'s own `how:` says not to rebuild it). `on_change_fields` is pure and
|
| 1396 |
+
takes the definition, so this reads once and asks about every column.
|
| 1397 |
+
|
| 1398 |
+
β AND IT NEVER FAILS THE WRITE. The cell edit has already succeeded and been acknowledged;
|
| 1399 |
+
an enrichment that could not run is a missing value, not a lost edit, and the run's own report
|
| 1400 |
+
carries the reason. β It is also deliberately SYNCHRONOUS and bounded to this one row: a
|
| 1401 |
+
fan-out here would put a vendor call on the critical path of every keystroke-commit.
|
| 1402 |
+
"""
|
| 1403 |
+
import ai_enrich as _ae
|
| 1404 |
+
|
| 1405 |
+
try:
|
| 1406 |
+
defn = _ut().get(table_key, st=session.runtime) or {}
|
| 1407 |
+
wanted = _ae.on_change_fields(defn, changed.keys())
|
| 1408 |
+
for field in wanted:
|
| 1409 |
+
_ae.run_field(table_key, field["key"], st=session.runtime, rows=[str(pid)])
|
| 1410 |
+
except Exception: # noqa: BLE001
|
| 1411 |
+
pass
|
| 1412 |
+
|
| 1413 |
+
|
| 1414 |
+
@router.post("/tables/{table_key}/fields/{fkey}/enrich")
|
| 1415 |
+
def enrich_field(table_key: str, fkey: str, body: dict = Body(default=None),
|
| 1416 |
+
session: Session = Depends(require_session)):
|
| 1417 |
+
"""Run an AI enrichment column (wave 34, owner ruling R13). Returns the run's own REPORT.
|
| 1418 |
+
|
| 1419 |
+
β THIS DOOR SPENDS MONEY, so it rides the same wall every other schema write rides
|
| 1420 |
+
(`_field_or_refuse`) rather than a looser one of its own. A read-only viewer cannot bill the
|
| 1421 |
+
tenant by opening a grid.
|
| 1422 |
+
|
| 1423 |
+
`{"rows": ["3"]}` is a MANUAL run of exactly those records: a person asked, in front of the
|
| 1424 |
+
value being replaced, so it skips the `overwrite` policy. An ABSENT `rows` is the automatic
|
| 1425 |
+
plan, where the policy and the never-overwrite-a-human law both apply. The two are one
|
| 1426 |
+
function with one flag, not two runners.
|
| 1427 |
+
|
| 1428 |
+
β THE REPORT IS THE PRODUCT, not a status code. It carries `filled`, `failed`, `skipped` by
|
| 1429 |
+
reason, `tokens` spent, the provider, per-row errors, and `limit` (R6's second sentence: a
|
| 1430 |
+
ceiling that stopped the run names its cause and a remedy). A 200 with `filled: 0` and a
|
| 1431 |
+
populated `skipped` is a correct, informative answer, and the client must render it rather
|
| 1432 |
+
than treat it as success.
|
| 1433 |
+
"""
|
| 1434 |
+
_field_or_refuse(session, table_key, fkey)
|
| 1435 |
+
import ai_enrich as _ae
|
| 1436 |
+
|
| 1437 |
+
rows = (body or {}).get("rows")
|
| 1438 |
+
if rows is not None and not isinstance(rows, list):
|
| 1439 |
+
raise err(400, "bad_rows", "`rows` must be a list of record ids, or absent to run the "
|
| 1440 |
+
"rows this column's own settings choose")
|
| 1441 |
+
# The caller's permitted pool, the same one the row doors use. A named row outside it is
|
| 1442 |
+
# dropped rather than refused: a stale client naming a record that has been deleted or moved
|
| 1443 |
+
# out of scope should not fail a run over the rows it can legitimately fill.
|
| 1444 |
+
if rows is not None:
|
| 1445 |
+
allowed = {str(p) for p in scoped_pids(session, table_key)[0]}
|
| 1446 |
+
rows = [str(r) for r in rows if str(r) in allowed]
|
| 1447 |
+
# β `W34-T54`'s bulk menu is this one field: "Rows never filled" sends `blank`, "All rows"
|
| 1448 |
+
# sends `always`. Anything else falls back to the column's own saved policy rather than to a
|
| 1449 |
+
# default, so a typo cannot quietly widen what a run touches.
|
| 1450 |
+
report = _ae.run_field(table_key, fkey, st=session.runtime, rows=rows,
|
| 1451 |
+
policy=str((body or {}).get("scope") or "") or None,
|
| 1452 |
+
# A named row set through THIS door is a person asking.
|
| 1453 |
+
manual=rows is not None)
|
| 1454 |
+
if report.get("problem"):
|
| 1455 |
+
# A run that could not start at all is not a 200: nothing was attempted, nothing was
|
| 1456 |
+
# spent, and the reason is actionable (no provider configured, or the wrong column).
|
| 1457 |
+
raise err(400, "enrich_refused", str(report["problem"]))
|
| 1458 |
+
return report
|
| 1459 |
|
| 1460 |
|
| 1461 |
@router.delete("/tables/{table_key}/fields/{fkey}")
|
|
|
|
| 1463 |
_field_or_refuse(session, table_key, fkey)
|
| 1464 |
if not _ut().delete_field(table_key, fkey, st=session.runtime):
|
| 1465 |
raise err(400, "refused",
|
| 1466 |
+
"that column could not be removed. A database must keep at least one")
|
| 1467 |
_refresh_relations(session)
|
| 1468 |
return {"deleted": fkey}
|
| 1469 |
|
|
|
|
| 1488 |
try:
|
| 1489 |
ok = _ut().delete_row(table_key, rid, st=lent)
|
| 1490 |
except Exception:
|
| 1491 |
+
raise err(503, "store_unavailable", "the delete did not land. Try again")
|
| 1492 |
if not ok:
|
| 1493 |
raise err(400, "refused", "rows can only be deleted from user-created databases")
|
| 1494 |
_refresh_relations(session)
|
|
|
|
| 1521 |
"pid": pid, "updates": updates}, ctx)
|
| 1522 |
except grid_events.StoreUnavailable:
|
| 1523 |
raise err(503, "store_unavailable",
|
| 1524 |
+
"the tenant store is unavailable. Your change was not saved")
|
| 1525 |
# β THE READ-BACK IS THE DEFINITION ROW, AND ON A `ut_` SCOPE THAT IS THE WHOLE OF IT.
|
| 1526 |
#
|
| 1527 |
# β CORRECTED, wave-29 T22 (owner item 2a): this note used to say "THE READ-BACK SPANS BOTH
|
|
|
|
| 1560 |
return False
|
| 1561 |
|
| 1562 |
refused = sorted(k for k in updates if not _took(k))
|
| 1563 |
+
# ββ WAVE-34 (R13) β THE HUMAN-EDIT STAMP, AND IT HAS TO HAPPEN HERE. "Did a person write
|
| 1564 |
+
# this cell?" is not recoverable from the value afterwards, so the only place to record it is
|
| 1565 |
+
# the door a person writes through. `ai_enrich_may_write` then refuses to let any automatic
|
| 1566 |
+
# run overwrite it, whatever the column's `overwrite` policy says.
|
| 1567 |
+
# β STAMPED FROM THE CELLS THAT ACTUALLY TOOK, never from what was asked: marking a refused
|
| 1568 |
+
# write `human` would freeze a cell against the agent on the strength of an edit that never
|
| 1569 |
+
# landed. `note_human_edit` filters to the enrichment columns itself and is a no-op otherwise.
|
| 1570 |
+
took = {k: v for k, v in accepted.items() if k not in refused}
|
| 1571 |
+
if took:
|
| 1572 |
+
try:
|
| 1573 |
+
_ut().note_human_edit(table_key, took, pid, st=session.runtime)
|
| 1574 |
+
except Exception: # noqa: BLE001
|
| 1575 |
+
# Provenance is metadata about a write that has already succeeded. Failing the
|
| 1576 |
+
# request here would tell the user their edit was lost when it was not.
|
| 1577 |
+
pass
|
| 1578 |
+
_fire_on_change(table_key, pid, took, session)
|
| 1579 |
out = {"pid": pid, "updates": accepted}
|
| 1580 |
if refused:
|
| 1581 |
out["refused"] = refused
|
api/web_agent.py
CHANGED
|
@@ -1,9 +1,10 @@
|
|
| 1 |
"""web_agent.py β THE SEAM between an automation step and a browser that runs somewhere else.
|
| 2 |
|
| 3 |
-
CONTRACT C5 (wave 31, ruling R10 / D-51). E
|
| 4 |
-
`automation_engine.py`'s action dispatch
|
| 5 |
-
|
| 6 |
-
|
|
|
|
| 7 |
|
| 8 |
result, error = web_agent.run_step(step, ctx) # -> (dict|None, str)
|
| 9 |
|
|
@@ -30,22 +31,39 @@ foreground step of an automation RUN and far too slow for a route a person is wa
|
|
| 30 |
cost is per JOB, not per step β `run_plan` sends a whole flow's steps in ONE job for that reason.
|
| 31 |
|
| 32 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 33 |
-
β
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
`
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
"""
|
| 50 |
import base64
|
| 51 |
import hashlib
|
|
@@ -223,7 +241,7 @@ def _sentence_for(exc, namespace):
|
|
| 223 |
f"point this deployment at one that has it ({ENV_NAMESPACE} / {ENV_TOKEN[0]}). "
|
| 224 |
f"Nothing was read.")
|
| 225 |
if status in (401, 403):
|
| 226 |
-
return (f"Hugging Face refused the browser job as unauthorised for {where}
|
| 227 |
f"deployment's token does not carry `job.write` on that account. Point it at an "
|
| 228 |
f"account the token owns ({ENV_NAMESPACE}), or configure a token that owns this "
|
| 229 |
f"one ({ENV_TOKEN[0]}). Nothing was read.")
|
|
@@ -524,7 +542,7 @@ def _await(api, job_id, namespace, started, log):
|
|
| 524 |
break
|
| 525 |
if state in ("COMPLETED", "ERROR", "CANCELED"):
|
| 526 |
return None, (f"The browser job {job_id} ended {state} without returning a result"
|
| 527 |
-
f"{'
|
| 528 |
f"Nothing was read.")
|
| 529 |
if not booted and waited > START_TIMEOUT:
|
| 530 |
# The submitted-but-never-started case, named as itself.
|
|
@@ -534,7 +552,7 @@ def _await(api, job_id, namespace, started, log):
|
|
| 534 |
pass
|
| 535 |
return None, (f"The browser job {job_id} was accepted but had not started after "
|
| 536 |
f"{int(START_TIMEOUT)}s (it stayed {state or 'queued'}), so it was "
|
| 537 |
-
f"cancelled. Nothing was read
|
| 538 |
if waited > RUN_TIMEOUT:
|
| 539 |
try:
|
| 540 |
api.cancel_job(job_id=job_id, namespace=namespace)
|
|
|
|
| 1 |
"""web_agent.py β THE SEAM between an automation step and a browser that runs somewhere else.
|
| 2 |
|
| 3 |
+
CONTRACT C5 (wave 31, ruling R10 / D-51). E shipped this and C mounted it in
|
| 4 |
+
`automation_engine.py`'s action dispatch; both halves landed and the `verify_wiring` row exists.
|
| 5 |
+
β That sentence was written in the FUTURE TENSE as a wave-31 hand-off and stayed that way for
|
| 6 |
+
three waves, which is the smaller cousin of the correction below: a fence note describes one
|
| 7 |
+
wave's division of labour and reads, afterwards, as a description of the code.
|
| 8 |
|
| 9 |
result, error = web_agent.run_step(step, ctx) # -> (dict|None, str)
|
| 10 |
|
|
|
|
| 31 |
cost is per JOB, not per step β `run_plan` sends a whole flow's steps in ONE job for that reason.
|
| 32 |
|
| 33 |
ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 34 |
+
ββ WAVE 34 Β· W34-T44 β THIS BLOCK LISTED "THREE THINGS TRUE OF PRODUCTION TODAY". ALL THREE WERE
|
| 35 |
+
FALSE BY THE TIME ANYBODY READ THEM, AND THIS DOCSTRING IS WHERE THE ERROR CAME FROM.
|
| 36 |
+
|
| 37 |
+
It said the Space token 402s, that `deploy_web.py` never uploads `jobs/`, and that
|
| 38 |
+
`routes_web_agent.py` is not mounted β so the whole web-action family was inert in production.
|
| 39 |
+
**Four later documents repeated it** (the wave-34 PRD's scouted index, the plan-time scout's
|
| 40 |
+
report, `W34-T44`'s own `how:`, and `run_gates.ps1`'s annotation telling a deploy to expect
|
| 41 |
+
`web_agent` at 48/50), and every one of them traced back here. Re-checked line by line, against
|
| 42 |
+
the files themselves, on 2026-08-16:
|
| 43 |
+
|
| 44 |
+
1. THE TOKEN IS PUSHED. `deploy_web.py:1128` adds `AIOS_HF_TOKEN` to the Space's secrets when it
|
| 45 |
+
is set, and `ENV_TOKEN` below prefers it over `HF_TOKEN`, so the org token that 402s is never
|
| 46 |
+
the one reached. The 402 itself is still true OF THAT TOKEN and is why the order matters.
|
| 47 |
+
2. THE JOB SCRIPT SHIPS. `deploy_web.py::_jobs_files` (:413) collects `jobs/` and :867 uploads
|
| 48 |
+
it. The `check_upload()` caveat stands as a caveat: it walks IMPORTS and cannot see a file
|
| 49 |
+
opened by PATH, so this upload is covered by the manifest, never by that check.
|
| 50 |
+
3. IT IS MOUNTED. `main.py:264`, `app.include_router(routes_web_agent.router)` β and
|
| 51 |
+
`verify_web_agent.py` asserts the route is in the SERVED set (`app.openapi()["paths"]`),
|
| 52 |
+
which is green.
|
| 53 |
+
|
| 54 |
+
β MEASURED END TO END the same day, from a dev box: `capability()` = ready True / "configured" /
|
| 55 |
+
`AIOS_HF_TOKEN` / playwright 1.62.0, and a real `web_read` of `https://example.com h1` submitted in
|
| 56 |
+
3.8 s, was alive at 21.4 s and returned `value "Example Domain"`, status 200, 106 ms in-browser,
|
| 57 |
+
24.5 s wall clock cold.
|
| 58 |
+
β STILL UNPROVEN, so nobody over-corrects in the other direction: that run was NOT on the deployed
|
| 59 |
+
Space, which carries the secret only if the last deploy read this `.env`. Proving it needs a click
|
| 60 |
+
on staging, and until then "it works" means "it works from a box with the token".
|
| 61 |
+
|
| 62 |
+
β THE STANDING LESSON, kept here because this file is where it started: **a docstring stating
|
| 63 |
+
facts about PRODUCTION has no expiry and nothing re-reads it when the world changes.** Three
|
| 64 |
+
sentences about somebody else's file outlived all three of those files' fixes, and were quoted as
|
| 65 |
+
current by four documents and a ticket. `capability()` answers the same question in under a second
|
| 66 |
+
and returns TODAY's answer; prefer calling it to reading this.
|
| 67 |
"""
|
| 68 |
import base64
|
| 69 |
import hashlib
|
|
|
|
| 241 |
f"point this deployment at one that has it ({ENV_NAMESPACE} / {ENV_TOKEN[0]}). "
|
| 242 |
f"Nothing was read.")
|
| 243 |
if status in (401, 403):
|
| 244 |
+
return (f"Hugging Face refused the browser job as unauthorised for {where}. This "
|
| 245 |
f"deployment's token does not carry `job.write` on that account. Point it at an "
|
| 246 |
f"account the token owns ({ENV_NAMESPACE}), or configure a token that owns this "
|
| 247 |
f"one ({ENV_TOKEN[0]}). Nothing was read.")
|
|
|
|
| 542 |
break
|
| 543 |
if state in ("COMPLETED", "ERROR", "CANCELED"):
|
| 544 |
return None, (f"The browser job {job_id} ended {state} without returning a result"
|
| 545 |
+
f"{'. It never started printing' if not booted else ''}. "
|
| 546 |
f"Nothing was read.")
|
| 547 |
if not booted and waited > START_TIMEOUT:
|
| 548 |
# The submitted-but-never-started case, named as itself.
|
|
|
|
| 552 |
pass
|
| 553 |
return None, (f"The browser job {job_id} was accepted but had not started after "
|
| 554 |
f"{int(START_TIMEOUT)}s (it stayed {state or 'queued'}), so it was "
|
| 555 |
+
f"cancelled. Nothing was read. Try again, or the platform is busy.")
|
| 556 |
if waited > RUN_TIMEOUT:
|
| 557 |
try:
|
| 558 |
api.cancel_job(job_id=job_id, namespace=namespace)
|
platform/aios_grid.py
CHANGED
|
@@ -120,9 +120,20 @@ def _round(v):
|
|
| 120 |
#: surfaces that CREATE them are the user-table databases, where a relation between two tables is
|
| 121 |
#: a thing that exists. On the Odoo-backed Customer/Product grids there is no second user table to
|
| 122 |
#: point at, so the column menu there simply never offers one.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
CUSTOM_FIELD_TYPES = {"text", "select", "multiselect", "user", "int", "currency", "pct", "date",
|
| 124 |
"checkbox", "phone", "email", "url", "rating", "created_time", "formula",
|
| 125 |
-
"automation", "image", "json", "link", "rollup", "code"}
|
| 126 |
|
| 127 |
#: β WAVE-27 item 13 (owner ruling R13) β the `code` field's LANGUAGES.
|
| 128 |
#:
|
|
|
|
| 120 |
#: surfaces that CREATE them are the user-table databases, where a relation between two tables is
|
| 121 |
#: a thing that exists. On the Odoo-backed Customer/Product grids there is no second user table to
|
| 122 |
#: point at, so the column menu there simply never offers one.
|
| 123 |
+
#: ββ WAVE-34 (owner ruling R13) β `ai_enrich` JOINS, and it had to join HERE in the same change
|
| 124 |
+
#: that put it in `UT_FIELD_TYPES`, not a ticket later. The wave planned a SERVER-FIRST landing on
|
| 125 |
+
#: the reasoning that a kind the client does not offer is invisible while a kind the server refuses
|
| 126 |
+
#: deletes a column. That reasoning is sound and the conclusion was still wrong, because THREE
|
| 127 |
+
#: parity gates chain over these sets and none of them permits a partial landing:
|
| 128 |
+
#: `verify_api` W18-UT `UT_FIELD_TYPES - CUSTOM_FIELD_TYPES == set()` (this line)
|
| 129 |
+
#: `aw_fields_contract` Β§5 `CREATABLE_TYPES == CUSTOM_FIELD_TYPES`, EXACT set equality
|
| 130 |
+
#: `types.ts` `CREATABLE_TYPES: readonly FieldType[]`, so the union must carry it too
|
| 131 |
+
#: Measured live by lane B at 17:09: `api_api` went 969/969 to 968/969 the moment the kind entered
|
| 132 |
+
#: `core/user_tables.py` alone, printing `got {'ai_enrich'} want set()`. The comments on
|
| 133 |
+
#: `json`, `link`/`rollup` and `code` below all say the same thing in their own words.
|
| 134 |
CUSTOM_FIELD_TYPES = {"text", "select", "multiselect", "user", "int", "currency", "pct", "date",
|
| 135 |
"checkbox", "phone", "email", "url", "rating", "created_time", "formula",
|
| 136 |
+
"automation", "image", "json", "link", "rollup", "code", "ai_enrich"}
|
| 137 |
|
| 138 |
#: β WAVE-27 item 13 (owner ruling R13) β the `code` field's LANGUAGES.
|
| 139 |
#:
|
platform/core/registry.py
CHANGED
|
@@ -57,7 +57,7 @@ REGISTRY = [
|
|
| 57 |
# briefing still read it, and pages_sales.py survives UNREGISTERED as the Y1 envelope's
|
| 58 |
# template + verify_api's fixture.
|
| 59 |
{'key': 'sales', 'label': 'Sales', 'brand': True, 'hq': False, 'validate': True, 'source': 'Odoo', 'archived': True,
|
| 60 |
-
'note': 'Revenue, YoY, seasonality, reps, customers, SKUs
|
| 61 |
'rebuild as grid chart/dashboard views (compare series, KPI deltas, tables).'},
|
| 62 |
# ARCHIVED AS A PAGE (owner item 14, wave 8): "archive the current form of the Customer
|
| 63 |
# dashboard completely, i want to redesign it, exactly with the backend we have". So the
|
|
@@ -104,7 +104,7 @@ REGISTRY = [
|
|
| 104 |
# _LEGACY_KEYS maps cohortβcustomer_data so grants + ?page= deep links land on the grid
|
| 105 |
# that now hosts the cohorts.
|
| 106 |
{'key': 'cohort', 'label': 'Cohort', 'brand': True, 'hq': False, 'validate': False, 'source': 'Odoo', 'parent': 'customers', 'archived': True,
|
| 107 |
-
'note': 'Hand-curated, unchanging customer lists
|
| 108 |
'locked views (wave 16). The set is fixed: it changes only when someone adds '
|
| 109 |
'or removes a member. Open them from Customer > Views > Cohorts.'},
|
| 110 |
# ARCHIVED wave 16 (owner item 7, R11, 2026-08-02) beside Sales. The agent DRAWER and the
|
|
@@ -131,9 +131,9 @@ REGISTRY = [
|
|
| 131 |
'note': 'The SKU catalogue as a grid: per-product revenue, units and (consolidated) '
|
| 132 |
'stock columns, with saved views and custom fields. Identity = the SKU code.'},
|
| 133 |
{'key': 'products', 'label': 'SKU', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 134 |
-
'note': 'SKU movers, zombies, velocity, coverage, drawers
|
| 135 |
{'key': 'assortment', 'label': 'Assortment', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 136 |
-
'note': 'Facet-level performance (category/color/occasion/collection/season) + season readiness
|
| 137 |
{'key': 'financial', 'label': 'Financial', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 138 |
'note': 'Gross margin by BU/category/SKU. Cash-conversion cycle is HQ-consolidated.'},
|
| 139 |
{'key': 'pricing', 'label': 'Pricing', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
|
@@ -148,7 +148,7 @@ REGISTRY = [
|
|
| 148 |
'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 13: "We should be able to replace '
|
| 149 |
'Procurement completely, and add it as part of the Product database"). The buy '
|
| 150 |
'list is now a saved VIEW on the Product grid, filtered on a FORMULA field over '
|
| 151 |
-
'the supplier/lead-time columns and the demand measure
|
| 152 |
'"Buy list is just a View, with a Filter from a Formula field that taps into '
|
| 153 |
'Metrics Fields... the 8-month demand baseline etc. is just math in a Formula '
|
| 154 |
'field." The ROW STAYS validate_only: `modules/procurement.py` still owns the '
|
|
@@ -162,7 +162,7 @@ REGISTRY = [
|
|
| 162 |
'source': 'Odoo', 'api_surface': False, 'admin_only': True,
|
| 163 |
'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 15: "Collections should also be '
|
| 164 |
'entirely replicable as just a View under Customer"). The worklist is the shared '
|
| 165 |
-
'"Collections" view on the Customer grid
|
| 166 |
'reconciled blocks (ar_open/ar_overdue/ar_exposure/days_to_pay + the four aging '
|
| 167 |
'buckets). The ROW STAYS validate_only so validate.py keeps running ar.validate(), '
|
| 168 |
'which is the proof those columns rest on; archiving it would have skipped the '
|
|
@@ -171,7 +171,7 @@ REGISTRY = [
|
|
| 171 |
'sender (app._collections_statements -> modules/collections_send), the ONE '
|
| 172 |
'sanctioned Odoo writer, which the same ruling says stays untouched. So the row '
|
| 173 |
'keeps a Streamlit page for ADMINS ONLY (`admin_only`) and leaves the API payload '
|
| 174 |
-
'entirely (`api_surface: False`)
|
| 175 |
'worklist, and the biweekly send keeps its door.'},
|
| 176 |
{'key': 'returns', 'label': 'Returns', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
|
| 177 |
'note': 'Credit-note lens: refund concentration by SKU (quality) and customer (behavior). Company-level.'},
|
|
@@ -180,7 +180,7 @@ REGISTRY = [
|
|
| 180 |
{'key': 'backorders', 'label': 'Backorders', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 181 |
'note': 'Confirmed-undelivered order lines aged vs promise date, valued, with a supply-aware next action per row (ship / expedite / call). Wholesale scope.'},
|
| 182 |
{'key': 'pricecomp', 'label': 'Price Compliance', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 183 |
-
'note': 'Selling below the customer pricelist tier (LTM, per customer x SKU): the pocket-price floor worklist with annualized leak $. Sub-30% ratios usually mean a stale or pack-basis RULE
|
| 184 |
{'key': 'o2c', 'label': 'Cash Timing', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 185 |
'note': 'Order-to-cash stage decomposition (order-to-ship / ship-to-invoice / invoice-to-paid, each with its owner) + the terms-gap rollup: contractual vs actual days per payment term with the free-credit $ it strands.'},
|
| 186 |
{'key': 'bookings', 'label': 'Order Book', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
|
@@ -193,29 +193,37 @@ REGISTRY = [
|
|
| 193 |
'note': 'Operating expense from the GL (all expense-type accounts; COGS excluded): trend, operating leverage (opex % of revenue), the YoY cost bridge, XmR control-limit spike watch list, fixed/variable split and a drill-to-ledger category directory. Company-level.'},
|
| 194 |
{'key': 'health', 'label': 'Data Health', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
|
| 195 |
'note': 'Close/reconciliation scan; period-filtered, mostly company-level. ARCHITECTURE '
|
| 196 |
-
'(owner 2026-07-23): every discrepancy / potential-error marker lives here
|
| 197 |
'procurement mapping gaps, count-trust (unverified on-hand counts), untracked-on-'
|
| 198 |
-
'order
|
| 199 |
{'key': 'dictionary', 'label': 'Metric Dictionary', 'brand': False, 'hq': True, 'validate': True,
|
| 200 |
'nav': False, 'validate_only': True,
|
| 201 |
-
'note': 'PAGE RETIRED 2026-07-23 (owner: broken/not customer-facing)
|
| 202 |
'validate.py keeps running the semantic-layer contracts (the Analyst grounding '
|
| 203 |
-
'proof). No nav, no page. Wave 17 R8 ("I don\'t even know what it does
|
| 204 |
'them") made that literal: `validate_only` takes it out of the account menu too, '
|
| 205 |
'which is the last place it was still visible. The proof survives; the door does not.'},
|
| 206 |
-
{'key': 'automation', 'label': '
|
| 207 |
'nav': True,
|
| 208 |
-
'note': 'Wave 18 (C-AUTONAV):
|
| 209 |
-
'
|
| 210 |
-
'
|
| 211 |
-
'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
{'key': 'settings', 'label': 'Settings', 'brand': False, 'hq': True, 'validate': False,
|
| 213 |
'nav': False, # sidebar sentinel (account group) β owner IA 2026-07-12
|
| 214 |
'note': 'User scope settings: the Business Unit toggle (strict isolation) and the Data basis '
|
| 215 |
'toggle (Orders vs Invoiced) moved here from the sidebar.'},
|
| 216 |
{'key': 'analyst', 'label': 'AIOS Analyst', 'brand': False, 'hq': True, 'validate': False,
|
| 217 |
'nav': False, # sidebar sentinel button; eval gate = harness/evals.py (run pre-ship, NOT in validate.py β live LLM cost)
|
| 218 |
-
'note': 'Ask the business a question in plain language: a small AI model calls governed tools over the semantic layer
|
| 219 |
]
|
| 220 |
|
| 221 |
BY_KEY = {m['key']: m for m in REGISTRY}
|
|
|
|
| 57 |
# briefing still read it, and pages_sales.py survives UNREGISTERED as the Y1 envelope's
|
| 58 |
# template + verify_api's fixture.
|
| 59 |
{'key': 'sales', 'label': 'Sales', 'brand': True, 'hq': False, 'validate': True, 'source': 'Odoo', 'archived': True,
|
| 60 |
+
'note': 'Revenue, YoY, seasonality, reps, customers, SKUs. By BU. Retired wave 16: '
|
| 61 |
'rebuild as grid chart/dashboard views (compare series, KPI deltas, tables).'},
|
| 62 |
# ARCHIVED AS A PAGE (owner item 14, wave 8): "archive the current form of the Customer
|
| 63 |
# dashboard completely, i want to redesign it, exactly with the backend we have". So the
|
|
|
|
| 104 |
# _LEGACY_KEYS maps cohortβcustomer_data so grants + ?page= deep links land on the grid
|
| 105 |
# that now hosts the cohorts.
|
| 106 |
{'key': 'cohort', 'label': 'Cohort', 'brand': True, 'hq': False, 'validate': False, 'source': 'Odoo', 'parent': 'customers', 'archived': True,
|
| 107 |
+
'note': 'Hand-curated, unchanging customer lists. Folded into the Customer rail as '
|
| 108 |
'locked views (wave 16). The set is fixed: it changes only when someone adds '
|
| 109 |
'or removes a member. Open them from Customer > Views > Cohorts.'},
|
| 110 |
# ARCHIVED wave 16 (owner item 7, R11, 2026-08-02) beside Sales. The agent DRAWER and the
|
|
|
|
| 131 |
'note': 'The SKU catalogue as a grid: per-product revenue, units and (consolidated) '
|
| 132 |
'stock columns, with saved views and custom fields. Identity = the SKU code.'},
|
| 133 |
{'key': 'products', 'label': 'SKU', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 134 |
+
'note': 'SKU movers, zombies, velocity, coverage, drawers. By BU (sales-derived).'},
|
| 135 |
{'key': 'assortment', 'label': 'Assortment', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 136 |
+
'note': 'Facet-level performance (category/color/occasion/collection/season) + season readiness. By BU.'},
|
| 137 |
{'key': 'financial', 'label': 'Financial', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 138 |
'note': 'Gross margin by BU/category/SKU. Cash-conversion cycle is HQ-consolidated.'},
|
| 139 |
{'key': 'pricing', 'label': 'Pricing', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
|
|
|
| 148 |
'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 13: "We should be able to replace '
|
| 149 |
'Procurement completely, and add it as part of the Product database"). The buy '
|
| 150 |
'list is now a saved VIEW on the Product grid, filtered on a FORMULA field over '
|
| 151 |
+
'the supplier/lead-time columns and the demand measure. The owner\'s ruling R3: '
|
| 152 |
'"Buy list is just a View, with a Filter from a Formula field that taps into '
|
| 153 |
'Metrics Fields... the 8-month demand baseline etc. is just math in a Formula '
|
| 154 |
'field." The ROW STAYS validate_only: `modules/procurement.py` still owns the '
|
|
|
|
| 162 |
'source': 'Odoo', 'api_surface': False, 'admin_only': True,
|
| 163 |
'note': 'PAGE RETIRED 2026-08-03 (owner wave-17 item 15: "Collections should also be '
|
| 164 |
'entirely replicable as just a View under Customer"). The worklist is the shared '
|
| 165 |
+
'"Collections" view on the Customer grid. Same numbers, from this module\'s own '
|
| 166 |
'reconciled blocks (ar_open/ar_overdue/ar_exposure/days_to_pay + the four aging '
|
| 167 |
'buckets). The ROW STAYS validate_only so validate.py keeps running ar.validate(), '
|
| 168 |
'which is the proof those columns rest on; archiving it would have skipped the '
|
|
|
|
| 171 |
'sender (app._collections_statements -> modules/collections_send), the ONE '
|
| 172 |
'sanctioned Odoo writer, which the same ruling says stays untouched. So the row '
|
| 173 |
'keeps a Streamlit page for ADMINS ONLY (`admin_only`) and leaves the API payload '
|
| 174 |
+
'entirely (`api_surface: False`). No "Collections" in the React nav, no second '
|
| 175 |
'worklist, and the biweekly send keeps its door.'},
|
| 176 |
{'key': 'returns', 'label': 'Returns', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
|
| 177 |
'note': 'Credit-note lens: refund concentration by SKU (quality) and customer (behavior). Company-level.'},
|
|
|
|
| 180 |
{'key': 'backorders', 'label': 'Backorders', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 181 |
'note': 'Confirmed-undelivered order lines aged vs promise date, valued, with a supply-aware next action per row (ship / expedite / call). Wholesale scope.'},
|
| 182 |
{'key': 'pricecomp', 'label': 'Price Compliance', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 183 |
+
'note': 'Selling below the customer pricelist tier (LTM, per customer x SKU): the pocket-price floor worklist with annualized leak $. Sub-30% ratios usually mean a stale or pack-basis RULE. Fix the rule, not the rep.'},
|
| 184 |
{'key': 'o2c', 'label': 'Cash Timing', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
| 185 |
'note': 'Order-to-cash stage decomposition (order-to-ship / ship-to-invoice / invoice-to-paid, each with its owner) + the terms-gap rollup: contractual vs actual days per payment term with the free-credit $ it strands.'},
|
| 186 |
{'key': 'bookings', 'label': 'Order Book', 'brand': True, 'hq': False, 'validate': True, 'archived': True,
|
|
|
|
| 193 |
'note': 'Operating expense from the GL (all expense-type accounts; COGS excluded): trend, operating leverage (opex % of revenue), the YoY cost bridge, XmR control-limit spike watch list, fixed/variable split and a drill-to-ledger category directory. Company-level.'},
|
| 194 |
{'key': 'health', 'label': 'Data Health', 'brand': False, 'hq': True, 'validate': True, 'archived': True,
|
| 195 |
'note': 'Close/reconciliation scan; period-filtered, mostly company-level. ARCHITECTURE '
|
| 196 |
+
'(owner 2026-07-23): every discrepancy / potential-error marker lives here. '
|
| 197 |
'procurement mapping gaps, count-trust (unverified on-hand counts), untracked-on-'
|
| 198 |
+
'order. So operational workflows stay clean for doing the work.'},
|
| 199 |
{'key': 'dictionary', 'label': 'Metric Dictionary', 'brand': False, 'hq': True, 'validate': True,
|
| 200 |
'nav': False, 'validate_only': True,
|
| 201 |
+
'note': 'PAGE RETIRED 2026-07-23 (owner: broken/not customer-facing). Row kept ONLY so '
|
| 202 |
'validate.py keeps running the semantic-layer contracts (the Analyst grounding '
|
| 203 |
+
'proof). No nav, no page. Wave 17 R8 ("I don\'t even know what it does. Delete '
|
| 204 |
'them") made that literal: `validate_only` takes it out of the account menu too, '
|
| 205 |
'which is the last place it was still visible. The proof survives; the door does not.'},
|
| 206 |
+
{'key': 'automation', 'label': 'Agents', 'brand': False, 'hq': True, 'validate': False,
|
| 207 |
'nav': True,
|
| 208 |
+
'note': 'Wave 18 (C-AUTONAV): scheduled jobs that create and refresh user databases '
|
| 209 |
+
'(website scrape-to-DB, the Instagram field). React-only surface (no PAGE_FUNCS '
|
| 210 |
+
'entry, the no-new-Streamlit rule); admins hold it via "all", other users need the '
|
| 211 |
+
'explicit grant, fail-closed default. '
|
| 212 |
+
'β WAVE 34 Β· R12. THE LABEL IS "Agents" AND THE KEY IS STILL `automation`. The '
|
| 213 |
+
'owner ruled the module becomes Agent: this is our agent deployment surface going '
|
| 214 |
+
'forward. Only the LABEL moved, because the key is a permission scope, a nav '
|
| 215 |
+
'placement key, a grant name and the `#/automation` route; renaming it would revoke '
|
| 216 |
+
'every stored grant and 404 every bookmark to buy a caption. β The plural is not a '
|
| 217 |
+
'slip: R15 and the wave PRD both spell the rail row "Agents", and the degraded '
|
| 218 |
+
'fallback row in Shell.tsx hard-codes that spelling, so a singular here would paint '
|
| 219 |
+
'two different words in one list depending on whether /nav answered.'},
|
| 220 |
{'key': 'settings', 'label': 'Settings', 'brand': False, 'hq': True, 'validate': False,
|
| 221 |
'nav': False, # sidebar sentinel (account group) β owner IA 2026-07-12
|
| 222 |
'note': 'User scope settings: the Business Unit toggle (strict isolation) and the Data basis '
|
| 223 |
'toggle (Orders vs Invoiced) moved here from the sidebar.'},
|
| 224 |
{'key': 'analyst', 'label': 'AIOS Analyst', 'brand': False, 'hq': True, 'validate': False,
|
| 225 |
'nav': False, # sidebar sentinel button; eval gate = harness/evals.py (run pre-ship, NOT in validate.py β live LLM cost)
|
| 226 |
+
'note': 'Ask the business a question in plain language: a small AI model calls governed tools over the semantic layer. Answers carry their query trace and drill links. AI-generated output (Art. 50 labeled).'},
|
| 227 |
]
|
| 228 |
|
| 229 |
BY_KEY = {m['key']: m for m in REGISTRY}
|
platform/core/user_tables.py
CHANGED
|
@@ -336,7 +336,7 @@ def limit_report(table_key, st=None):
|
|
| 336 |
'cause': f'this database is EDITABLE, so its rows live in the shared `{STORE_KEY}` '
|
| 337 |
f'document that every request copies; at the widest shipped row (0.318 KB) '
|
| 338 |
f'{cap:,} rows is 19.1 MB against a 32 MB per-table budget',
|
| 339 |
-
'recommendation': 'connect it to a source instead
|
| 340 |
'read-through from the mirror and is not bounded (R6); for typed data '
|
| 341 |
'the increment is D-87, a per-table row key, not a bigger number',
|
| 342 |
}
|
|
@@ -439,9 +439,15 @@ def records_mutable(table_key, st=None):
|
|
| 439 |
#: waves because `verify_fields_contract` diffed the client's offer against the ODOO grid's
|
| 440 |
#: acceptance and never against this set. Wave-27 item 13 adds that derived leg, which is what
|
| 441 |
#: found it (measured, not reviewed: `_clean_field({'type':'image'})` returned None).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
UT_FIELD_TYPES = {'text', 'select', 'multiselect', 'user', 'int', 'currency', 'pct', 'date',
|
| 443 |
'checkbox', 'phone', 'email', 'url', 'rating', 'automation', 'json',
|
| 444 |
-
'link', 'rollup', 'code', 'image', 'formula'}
|
| 445 |
#: A cell is still a scalar on the wire (`Row` values are strings/numbers) β what this bounds is
|
| 446 |
#: the DOCUMENT inside it. Source data is one already-paid provider response; keep it whole so a
|
| 447 |
#: provider field is never silently discarded merely because the response is rich. 32 MiB matches
|
|
@@ -902,6 +908,19 @@ def clean_fields(raw):
|
|
| 902 |
entry = None
|
| 903 |
elif fx is not None:
|
| 904 |
entry['formula'] = fx
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 905 |
if entry is None:
|
| 906 |
seen.discard(key)
|
| 907 |
continue
|
|
@@ -1664,6 +1683,308 @@ def _clean_rollup(raw):
|
|
| 1664 |
return out
|
| 1665 |
|
| 1666 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1667 |
def _clean_field(raw, previous=None):
|
| 1668 |
"""One field dict β the stored shape, or None. The single validator for create AND patch, so
|
| 1669 |
a column cannot be typed one way on the way in and another on the way back."""
|
|
@@ -1899,6 +2220,28 @@ def _clean_field(raw, previous=None):
|
|
| 1899 |
out['formula'] = fx
|
| 1900 |
elif ftype == 'formula':
|
| 1901 |
return None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1902 |
return out
|
| 1903 |
|
| 1904 |
|
|
@@ -2094,6 +2437,16 @@ def flow_bound(bag, st=None):
|
|
| 2094 |
lives in `aios_grid._clean_automation` β this guards the WRITE doors only.)"""
|
| 2095 |
if not isinstance(bag, dict):
|
| 2096 |
return True # no bag, no law β an ordinary column
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2097 |
flow = str(bag.get('flowId') or '').strip()
|
| 2098 |
if not flow:
|
| 2099 |
return False
|
|
@@ -2399,6 +2752,41 @@ def patch_cells(table_key, row_id, values, st=None):
|
|
| 2399 |
return True
|
| 2400 |
|
| 2401 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2402 |
def patch_link_cell(table_key, row_id, fkey, value, st=None):
|
| 2403 |
"""Persist one user-picked Link cell in the shared row and return its canonical id string.
|
| 2404 |
|
|
|
|
| 336 |
'cause': f'this database is EDITABLE, so its rows live in the shared `{STORE_KEY}` '
|
| 337 |
f'document that every request copies; at the widest shipped row (0.318 KB) '
|
| 338 |
f'{cap:,} rows is 19.1 MB against a 32 MB per-table budget',
|
| 339 |
+
'recommendation': 'connect it to a source instead. Connected data is served '
|
| 340 |
'read-through from the mirror and is not bounded (R6); for typed data '
|
| 341 |
'the increment is D-87, a per-table row key, not a bigger number',
|
| 342 |
}
|
|
|
|
| 439 |
#: waves because `verify_fields_contract` diffed the client's offer against the ODOO grid's
|
| 440 |
#: acceptance and never against this set. Wave-27 item 13 adds that derived leg, which is what
|
| 441 |
#: found it (measured, not reviewed: `_clean_field({'type':'image'})` returned None).
|
| 442 |
+
#: β WAVE-34 (owner ruling R13) β `ai_enrich` JOINS, and it lands HERE FIRST rather than last.
|
| 443 |
+
#: The block above records that `image` reached four of the five surfaces and missed this one, so
|
| 444 |
+
#: the client offered a column the server deleted on the next read. `ai_enrich` is added to this
|
| 445 |
+
#: set in `W34-T51` (server) while the CLIENT half waits for `W34-T53`, which is the safe order:
|
| 446 |
+
#: a kind the server accepts and the client does not offer is invisible, whereas a kind the client
|
| 447 |
+
#: offers and the server refuses is a column created, named, configured and gone.
|
| 448 |
UT_FIELD_TYPES = {'text', 'select', 'multiselect', 'user', 'int', 'currency', 'pct', 'date',
|
| 449 |
'checkbox', 'phone', 'email', 'url', 'rating', 'automation', 'json',
|
| 450 |
+
'link', 'rollup', 'code', 'image', 'formula', 'ai_enrich'}
|
| 451 |
#: A cell is still a scalar on the wire (`Row` values are strings/numbers) β what this bounds is
|
| 452 |
#: the DOCUMENT inside it. Source data is one already-paid provider response; keep it whole so a
|
| 453 |
#: provider field is never silently discarded merely because the response is rich. 32 MiB matches
|
|
|
|
| 908 |
entry = None
|
| 909 |
elif fx is not None:
|
| 910 |
entry['formula'] = fx
|
| 911 |
+
# ββ WAVE-34 (R13) β the AI ENRICHMENT bag rides THIS door too, and it is the SIXTH time
|
| 912 |
+
# this seam has been fixed after the fact (`pinned`, `code`, `link`/`rollup`, `formula`,
|
| 913 |
+
# `agg`). It is written in the same change as `_clean_field`'s arm rather than a wave
|
| 914 |
+
# later, which is the only difference between this entry and the five above it.
|
| 915 |
+
if entry is not None:
|
| 916 |
+
ae = (_clean_ai_enrich(f.get('aiEnrich'))
|
| 917 |
+
if f.get('aiEnrich') is not None else None)
|
| 918 |
+
if (ftype == 'ai_enrich') != (ae is not None):
|
| 919 |
+
entry = None
|
| 920 |
+
elif ae is not None:
|
| 921 |
+
entry['aiEnrich'] = ae
|
| 922 |
+
# C3: derived, never typed beside the config. Same call as the other door.
|
| 923 |
+
entry['automation'] = _field_agent_binding(entry)
|
| 924 |
if entry is None:
|
| 925 |
seen.discard(key)
|
| 926 |
continue
|
|
|
|
| 1683 |
return out
|
| 1684 |
|
| 1685 |
|
| 1686 |
+
# ---------------------------------------------------------------------------------------------
|
| 1687 |
+
# THE AI ENRICHMENT COLUMN (wave 34, owner ruling R13)
|
| 1688 |
+
# ---------------------------------------------------------------------------------------------
|
| 1689 |
+
# R13: *"a field kind called AI enrichment: a prompt per row that populates text. Detailed
|
| 1690 |
+
# configuration in the field's own config, including a token-usage limit."*
|
| 1691 |
+
#
|
| 1692 |
+
# β POSTURE: `link`/`rollup`/`formula`'s, NOT `code`'s. The bag DEFINES the column β a prompt is
|
| 1693 |
+
# the only thing that can ever produce a value here β so the type/bag pairing is enforced BOTH
|
| 1694 |
+
# WAYS and either half alone refuses the field. An `ai_enrich` column with no prompt is a column
|
| 1695 |
+
# that renders, sorts, filters and can never hold anything, which is the silent kind of broken
|
| 1696 |
+
# every other bag on this door is paired to prevent.
|
| 1697 |
+
#
|
| 1698 |
+
# β AND THE VALUE IS STORED, NOT COMPUTED. `formula` is client-recomputed on every paint and
|
| 1699 |
+
# `rollup` is read-through; neither persists. An enrichment that vanished on reload would not be
|
| 1700 |
+
# the feature (PRD assumption A6), so an `ai_enrich` cell is an ordinary stored string, a human
|
| 1701 |
+
# may type over it, and it is deliberately NOT in `is_computed_cell`.
|
| 1702 |
+
|
| 1703 |
+
#: The bag's own key set, read by BOTH the cleaner and the reporter below so they cannot disagree
|
| 1704 |
+
#: about what "unknown" means. β ONE constant, because T51's contract is that an unknown key is
|
| 1705 |
+
#: DROPPED **and NAMED** β two hand-maintained lists would eventually drop a key the report did
|
| 1706 |
+
#: not mention, which reads to a user as the config silently not saving.
|
| 1707 |
+
AI_ENRICH_KEYS = ('prompt', 'model', 'maxTokens', 'trigger', 'overwrite')
|
| 1708 |
+
#: When the column runs. `manual` = only when a person asks; `on_change` = when a referenced cell
|
| 1709 |
+
#: moves; `schedule` = on a cadence. The community ask R13 cites ("enrichment on a schedule") is
|
| 1710 |
+
#: the third of these, so it is in the vocabulary from the start rather than added later.
|
| 1711 |
+
AI_ENRICH_TRIGGER_MODES = ('manual', 'on_change', 'schedule')
|
| 1712 |
+
#: What an AUTOMATIC run may replace. β A HUMAN-EDITED CELL IS NEVER OVERWRITTEN AND THAT IS NOT
|
| 1713 |
+
#: IN THIS VOCABULARY β it is a law (`ai_enrich_may_write`), not a policy, so no configuration can
|
| 1714 |
+
#: turn it off. These three decide only what happens to cells the AGENT itself wrote.
|
| 1715 |
+
AI_ENRICH_OVERWRITE = ('blank', 'stale', 'always')
|
| 1716 |
+
#: Bounded because the ceiling is the feature (R13). The floor is one useful sentence; the roof is
|
| 1717 |
+
#: what one cell can cost before the run has to say so out loud.
|
| 1718 |
+
AI_ENRICH_MIN_TOKENS, AI_ENRICH_MAX_TOKENS = 16, 4000
|
| 1719 |
+
AI_ENRICH_DEFAULT_TOKENS = 300
|
| 1720 |
+
#: A prompt is a template, not an essay. Long enough for real instructions plus a few `{field}`
|
| 1721 |
+
#: tokens, short enough that the per-cell input cost is predictable.
|
| 1722 |
+
AI_ENRICH_PROMPT_MAX = 2000
|
| 1723 |
+
#: `cron` is stored as an opaque bounded string. β It is NOT parsed here on purpose: this module
|
| 1724 |
+
#: owns the field contract, and the scheduler that reads it owns the cadence vocabulary. Parsing
|
| 1725 |
+
#: it in two places is how one door starts refusing a cadence the other accepts.
|
| 1726 |
+
AI_ENRICH_CRON_MAX = 120
|
| 1727 |
+
|
| 1728 |
+
|
| 1729 |
+
def _clean_ai_enrich(raw):
|
| 1730 |
+
"""The `ai_enrich` config bag β the stored shape, or None if the column cannot run.
|
| 1731 |
+
|
| 1732 |
+
β Every key is normalised to a canonical form, so a definition saved and re-read is
|
| 1733 |
+
byte-identical (`_clean_ai_enrich(_clean_ai_enrich(x)) == _clean_ai_enrich(x)`). That
|
| 1734 |
+
idempotence is what makes the round-trip clause testable rather than a claim.
|
| 1735 |
+
"""
|
| 1736 |
+
if not isinstance(raw, dict):
|
| 1737 |
+
return None
|
| 1738 |
+
prompt = str(raw.get('prompt') or '').strip()[:AI_ENRICH_PROMPT_MAX]
|
| 1739 |
+
if not prompt:
|
| 1740 |
+
return None # the prompt IS the column; see the posture note above
|
| 1741 |
+
out = {'prompt': prompt}
|
| 1742 |
+
# The model is OPTIONAL and an unknown name is DROPPED rather than refused: it selects a
|
| 1743 |
+
# PROVIDER for a column that already knows what to ask, so a stale name should fall back to
|
| 1744 |
+
# the ladder's own order, never destroy the column. (`code`'s language argument, same shape.)
|
| 1745 |
+
model = str(raw.get('model') or '').strip().lower()[:60]
|
| 1746 |
+
if model:
|
| 1747 |
+
out['model'] = model
|
| 1748 |
+
tokens = raw.get('maxTokens')
|
| 1749 |
+
# `bool` is an `int` subclass β `True` would otherwise validate as a 1-token ceiling.
|
| 1750 |
+
if isinstance(tokens, bool) or not isinstance(tokens, (int, float)):
|
| 1751 |
+
tokens = AI_ENRICH_DEFAULT_TOKENS
|
| 1752 |
+
out['maxTokens'] = max(AI_ENRICH_MIN_TOKENS, min(int(tokens), AI_ENRICH_MAX_TOKENS))
|
| 1753 |
+
trig = raw.get('trigger') if isinstance(raw.get('trigger'), dict) else {}
|
| 1754 |
+
mode = str(trig.get('mode') or '').strip().lower()
|
| 1755 |
+
trigger = {'mode': mode if mode in AI_ENRICH_TRIGGER_MODES else 'manual'}
|
| 1756 |
+
# β `cron` is kept ONLY on a scheduled column. A cadence stored under `manual` is a setting
|
| 1757 |
+
# the user can see and nothing will ever read β the shape D-246 wears on the Odoo sync, where
|
| 1758 |
+
# a `manual` preset does nothing because no branch reads it.
|
| 1759 |
+
cron = str(trig.get('cron') or '').strip()[:AI_ENRICH_CRON_MAX]
|
| 1760 |
+
if trigger['mode'] == 'schedule' and cron:
|
| 1761 |
+
trigger['cron'] = cron
|
| 1762 |
+
out['trigger'] = trigger
|
| 1763 |
+
overwrite = str(raw.get('overwrite') or '').strip().lower()
|
| 1764 |
+
out['overwrite'] = overwrite if overwrite in AI_ENRICH_OVERWRITE else 'blank'
|
| 1765 |
+
return out
|
| 1766 |
+
|
| 1767 |
+
|
| 1768 |
+
def ai_enrich_dropped_keys(raw):
|
| 1769 |
+
"""The config keys `_clean_ai_enrich` will NOT keep, sorted β the NAMING half of T51's
|
| 1770 |
+
"an unknown config key is DROPPED and NAMED, never silently eaten".
|
| 1771 |
+
|
| 1772 |
+
β IT EXISTS BECAUSE EVERY OTHER BAG CLEANER IN THIS FILE HAS NO ERROR CHANNEL. `_clean_link`,
|
| 1773 |
+
`_clean_rollup`, `_clean_profile` and `_clean_format` all return `dict | None` and drop
|
| 1774 |
+
unknown keys in silence β `verify_fields_contract` even asserts that they do. Copying one of
|
| 1775 |
+
them as a template gets the DROP right and the NAMING wrong by construction, so the report is
|
| 1776 |
+
a separate function reading the SAME `AI_ENRICH_KEYS` constant rather than a second list.
|
| 1777 |
+
|
| 1778 |
+
β It reports UNKNOWN keys only. A known key whose VALUE was normalised (an out-of-range
|
| 1779 |
+
ceiling clamped, an unknown model dropped) is not a surprise worth a sentence β the stored
|
| 1780 |
+
definition comes straight back to the editor, so the user sees the accepted value itself.
|
| 1781 |
+
"""
|
| 1782 |
+
if not isinstance(raw, dict):
|
| 1783 |
+
return []
|
| 1784 |
+
return sorted(str(k) for k in raw if k not in AI_ENRICH_KEYS)
|
| 1785 |
+
|
| 1786 |
+
|
| 1787 |
+
def ai_enrich_fields(defn):
|
| 1788 |
+
"""Every `ai_enrich` column in this table definition, in declaration order.
|
| 1789 |
+
|
| 1790 |
+
One reader for "which columns are field agents", so the Agents module (contract C3), the
|
| 1791 |
+
runner and the scheduler cannot disagree about the set ([[one-evaluator-per-question]]).
|
| 1792 |
+
"""
|
| 1793 |
+
return [f for f in ((defn or {}).get('fields') or [])
|
| 1794 |
+
if isinstance(f, dict) and f.get('type') == 'ai_enrich'
|
| 1795 |
+
and isinstance(f.get('aiEnrich'), dict)]
|
| 1796 |
+
|
| 1797 |
+
|
| 1798 |
+
def _field_agent_binding(field):
|
| 1799 |
+
"""Contract C3's `field.automation` bag, DERIVED from the column rather than typed beside it.
|
| 1800 |
+
|
| 1801 |
+
ββ THIS IS DERIVED ON PURPOSE AND IT IS THE ONE DESIGN CALL IN T51 WORTH ARGUING WITH.
|
| 1802 |
+
C3 as drafted has F *store* `{kind, table, field, trigger}` β but `field` is already
|
| 1803 |
+
`field['key']` and `trigger` is already `aiEnrich['trigger']`, so two of its four keys would
|
| 1804 |
+
be a second copy of a fact this same dict already carries. Two copies of one fact is
|
| 1805 |
+
[[one-question-two-normalizers]] with a guaranteed drift date: the first PATCH that changes
|
| 1806 |
+
the trigger through the enrichment editor and not through the automation editor.
|
| 1807 |
+
Computing it inside the SINGLE validator both write doors run means they cannot diverge.
|
| 1808 |
+
|
| 1809 |
+
β `table` IS ABSENT, and that is the deviation E must know about: this function is handed one
|
| 1810 |
+
field dict and no table context, while `_clean_field(f) == f` is asserted by two gates over
|
| 1811 |
+
engine-seeded field lists β stamping the table key from a caller that happens to know it would
|
| 1812 |
+
make a stored field unequal to its own validator's output. E iterates tables to build the
|
| 1813 |
+
synthetic rows, so E already holds the key that would go here.
|
| 1814 |
+
"""
|
| 1815 |
+
bag = (field or {}).get('aiEnrich')
|
| 1816 |
+
if not isinstance(bag, dict):
|
| 1817 |
+
return None
|
| 1818 |
+
return {'kind': 'field_agent', 'field': str((field or {}).get('key') or ''),
|
| 1819 |
+
'trigger': dict(bag.get('trigger') or {'mode': 'manual'})}
|
| 1820 |
+
|
| 1821 |
+
|
| 1822 |
+
# ---------------------------------------------------------------------------------------------
|
| 1823 |
+
# PER-CELL PROVENANCE (wave 34, W34-T51 β "stamp it FROM DAY ONE")
|
| 1824 |
+
# ---------------------------------------------------------------------------------------------
|
| 1825 |
+
# ββ IT CANNOT LIVE IN THE ROW, AND THAT IS MEASURED RATHER THAN ASSUMED. `add_row` builds its
|
| 1826 |
+
# row as `{k: str(v) for k, v in values.items() if k in valid}` where `valid` is the declared
|
| 1827 |
+
# field keys β a row dict is field-keys-only by construction, so a reserved `__ai__` key inside
|
| 1828 |
+
# one would be stripped on the way in. Widening that filter to admit a private key would weaken
|
| 1829 |
+
# the wall that stops a client inventing columns. So provenance is a SIBLING STRATUM of `rows`,
|
| 1830 |
+
# exactly as `fields` and `rows` are siblings today.
|
| 1831 |
+
#
|
| 1832 |
+
# β AND `stale` IS DERIVED, NEVER STORED. A stored `stale` flag needs a sweep to stay true, and a
|
| 1833 |
+
# sweep nobody runs is [[flag-shipped-without-its-writer]]. What IS stored is the INPUT HASH the
|
| 1834 |
+
# value was produced from; `ai_enrich_is_stale` compares it to today's inputs, so staleness is a
|
| 1835 |
+
# question asked at read time and can never be out of date.
|
| 1836 |
+
|
| 1837 |
+
#: The stratum's key inside a table document, beside `fields` and `rows`.
|
| 1838 |
+
AI_ENRICH_MARK_KEY = 'aiEnrich'
|
| 1839 |
+
#: What a mark can say. β `stale` is absent ON PURPOSE (see above) and so is `generating`: an
|
| 1840 |
+
#: in-flight run is a fact about a REQUEST, not about stored data, and persisting it would leave
|
| 1841 |
+
#: a cell stuck on "generating" forever the first time a process dies mid-run.
|
| 1842 |
+
AI_ENRICH_STATES = ('agent', 'human', 'error')
|
| 1843 |
+
#: A bound on the stratum, in the shape `row_limit` uses: this document is copied whole on every
|
| 1844 |
+
#: read, so a mark set that can outgrow the rows it describes is a store problem wearing a
|
| 1845 |
+
#: feature's name. One mark per (row, column) is the natural size; the cap is the guard against a
|
| 1846 |
+
#: column deleted without its marks.
|
| 1847 |
+
AI_ENRICH_MAX_MARKS = MAX_ROWS
|
| 1848 |
+
#
|
| 1849 |
+
# β THERE IS DELIBERATELY NO `drop_marks(column)`, AND THE ABSENCE IS THE DECISION. `delete_field`
|
| 1850 |
+
# leaves a deleted column's CELLS in the rows on purpose β an accidental delete stays recoverable,
|
| 1851 |
+
# and re-adding the column under the same key brings the values back. Marks must follow the same
|
| 1852 |
+
# rule or a restored column's agent-written values would come back looking human-authored, which
|
| 1853 |
+
# is the one distinction this whole stratum exists to make. A table that is deleted takes its
|
| 1854 |
+
# document (and therefore its marks) with it, so neither lifecycle leaks.
|
| 1855 |
+
|
| 1856 |
+
|
| 1857 |
+
def ai_enrich_input_hash(prompt, row, refs):
|
| 1858 |
+
"""The fingerprint a stored value was produced FROM: the prompt plus the referenced cells.
|
| 1859 |
+
|
| 1860 |
+
β `refs` is passed in rather than re-parsed here, so the token vocabulary is owned by one
|
| 1861 |
+
module (`api/ai_enrich.py::prompt_refs`) instead of two. A hash built from a second parse
|
| 1862 |
+
would disagree with the runner's the first time the token syntax gains a form.
|
| 1863 |
+
"""
|
| 1864 |
+
payload = [str(prompt or '')]
|
| 1865 |
+
for key in sorted(str(r) for r in (refs or ())):
|
| 1866 |
+
payload.append(f'{key}={(row or {}).get(key, "")}')
|
| 1867 |
+
return hashlib.sha256('\x1f'.join(payload).encode('utf-8')).hexdigest()[:32]
|
| 1868 |
+
|
| 1869 |
+
|
| 1870 |
+
def ai_enrich_marks(table_key, col_id, st=None):
|
| 1871 |
+
"""`{row_id: mark}` for ONE column. Absent = a cell nobody has written yet."""
|
| 1872 |
+
stratum = (get(table_key, st) or {}).get(AI_ENRICH_MARK_KEY) or {}
|
| 1873 |
+
got = stratum.get(str(col_id))
|
| 1874 |
+
return dict(got) if isinstance(got, dict) else {}
|
| 1875 |
+
|
| 1876 |
+
|
| 1877 |
+
def ai_enrich_is_stale(mark, fresh_hash):
|
| 1878 |
+
"""Was this value produced from inputs that have since changed?
|
| 1879 |
+
|
| 1880 |
+
A human edit is never stale β a person's own words do not go out of date because a
|
| 1881 |
+
neighbouring cell moved. Only an agent-written value can be.
|
| 1882 |
+
"""
|
| 1883 |
+
if not isinstance(mark, dict) or mark.get('state') != 'agent':
|
| 1884 |
+
return False
|
| 1885 |
+
return bool(fresh_hash) and str(mark.get('hash') or '') != str(fresh_hash)
|
| 1886 |
+
|
| 1887 |
+
|
| 1888 |
+
def ai_enrich_human_authored(mark, has_value=False):
|
| 1889 |
+
"""Did somebody other than the agent put this value here?
|
| 1890 |
+
|
| 1891 |
+
β TWO CASES, AND THE SECOND IS THE ONE THAT GETS MISSED. An explicit `human` mark is obvious.
|
| 1892 |
+
The other is a cell that holds a value with NO mark at all: a value typed, pasted or imported
|
| 1893 |
+
before the column became an enrichment column. The agent did not write it, so the agent does
|
| 1894 |
+
not own it. Without this arm, `overwrite: 'always'` would eat exactly the pre-existing work
|
| 1895 |
+
R8's *"we are helping the team put their work into the system"* is about.
|
| 1896 |
+
|
| 1897 |
+
β ONE PREDICATE, because `ai_enrich_may_write` and `api/ai_enrich.py::cell_state` both answer
|
| 1898 |
+
"is this the human's?" and a cell the UI labels human-written while the runner overwrites it
|
| 1899 |
+
is the worst of both ([[one-question-two-normalizers]]).
|
| 1900 |
+
"""
|
| 1901 |
+
if isinstance(mark, dict) and mark.get('state') == 'human':
|
| 1902 |
+
return True
|
| 1903 |
+
return bool(has_value) and not (isinstance(mark, dict) and mark.get('state') == 'agent')
|
| 1904 |
+
|
| 1905 |
+
|
| 1906 |
+
def ai_enrich_may_write(mark, policy, fresh_hash, has_value=False):
|
| 1907 |
+
"""May an AUTOMATIC run write this cell? The law, in one place.
|
| 1908 |
+
|
| 1909 |
+
β THE FIRST CLAUSE IS NOT CONFIGURABLE. A cell a person typed into is never overwritten by an
|
| 1910 |
+
agent, whatever `overwrite` says β that is why `human` is not one of the `AI_ENRICH_OVERWRITE`
|
| 1911 |
+
values, and why `always` means "always refresh what the AGENT owns" rather than "overwrite
|
| 1912 |
+
everything". A policy that could turn it off would make the column unsafe to hand to a team.
|
| 1913 |
+
β A MANUAL, per-cell run is a different act with a different door; this predicate governs the
|
| 1914 |
+
automatic paths (`on_change`, `schedule`, and a bulk "all rows" sweep).
|
| 1915 |
+
"""
|
| 1916 |
+
if ai_enrich_human_authored(mark, has_value):
|
| 1917 |
+
return False
|
| 1918 |
+
if not has_value:
|
| 1919 |
+
return True # a blank cell is fillable under every policy
|
| 1920 |
+
if policy == 'always':
|
| 1921 |
+
return True
|
| 1922 |
+
if policy == 'stale':
|
| 1923 |
+
return ai_enrich_is_stale(mark, fresh_hash)
|
| 1924 |
+
return False # 'blank': an agent never replaces a value it wrote
|
| 1925 |
+
|
| 1926 |
+
|
| 1927 |
+
def stamp_ai_enrich(table_key, col_id, marks, st=None):
|
| 1928 |
+
"""Write MANY marks for one column in ONE store update. Returns the number stored.
|
| 1929 |
+
|
| 1930 |
+
β BULK BY CONSTRUCTION, and the signature is what enforces it. Every write here is a
|
| 1931 |
+
read-modify-write of the whole tenant document, so a per-row stamp inside a run loop is the
|
| 1932 |
+
same shape as the 2,000-full-document-copies problem `add_rows` exists to avoid. There is
|
| 1933 |
+
deliberately no `stamp_one`.
|
| 1934 |
+
"""
|
| 1935 |
+
col_id = str(col_id)
|
| 1936 |
+
clean = {}
|
| 1937 |
+
for row_id, mark in (marks or {}).items():
|
| 1938 |
+
if not isinstance(mark, dict):
|
| 1939 |
+
continue
|
| 1940 |
+
state = str(mark.get('state') or '')
|
| 1941 |
+
if state not in AI_ENRICH_STATES:
|
| 1942 |
+
continue
|
| 1943 |
+
entry = {'state': state, 'at': _dt.datetime.now(_dt.timezone.utc)
|
| 1944 |
+
.replace(microsecond=0).isoformat()}
|
| 1945 |
+
for key, cap in (('hash', 64), ('model', 60), ('error', 300)):
|
| 1946 |
+
got = str(mark.get(key) or '').strip()[:cap]
|
| 1947 |
+
if got:
|
| 1948 |
+
entry[key] = got
|
| 1949 |
+
tokens = mark.get('tokens')
|
| 1950 |
+
if isinstance(tokens, int) and not isinstance(tokens, bool) and tokens >= 0:
|
| 1951 |
+
entry['tokens'] = tokens
|
| 1952 |
+
clean[str(row_id)] = entry
|
| 1953 |
+
if not clean:
|
| 1954 |
+
return 0
|
| 1955 |
+
|
| 1956 |
+
def _set(cur):
|
| 1957 |
+
t = cur.get(str(table_key))
|
| 1958 |
+
if t is not None:
|
| 1959 |
+
col = t.setdefault(AI_ENRICH_MARK_KEY, {}).setdefault(col_id, {})
|
| 1960 |
+
col.update(clean)
|
| 1961 |
+
# The cap is enforced by DROPPING THE OLDEST, never by refusing the write: a run that
|
| 1962 |
+
# produced values and could not record where they came from would leave the cells
|
| 1963 |
+
# looking human-authored, which is the one state this stratum exists to distinguish.
|
| 1964 |
+
if len(col) > AI_ENRICH_MAX_MARKS:
|
| 1965 |
+
for rid in sorted(col, key=lambda r: str(col[r].get('at') or ''))[
|
| 1966 |
+
:len(col) - AI_ENRICH_MAX_MARKS]:
|
| 1967 |
+
col.pop(rid, None)
|
| 1968 |
+
return cur
|
| 1969 |
+
|
| 1970 |
+
_st(st).update(STORE_KEY, _set, flush='async')
|
| 1971 |
+
return len(clean)
|
| 1972 |
+
|
| 1973 |
+
|
| 1974 |
+
def note_human_edit(table_key, values, row_id, st=None):
|
| 1975 |
+
"""A person typed into these cells: mark any `ai_enrich` column among them `human`.
|
| 1976 |
+
|
| 1977 |
+
β THE STAMP HAS TO HAPPEN AT THE WRITE DOOR, not at read time, because "did a human write
|
| 1978 |
+
this" is not recoverable from the value afterwards. Called by `routes_tables.patch_row`;
|
| 1979 |
+
a no-op (and cheap) for the overwhelming case where the edited columns are ordinary.
|
| 1980 |
+
"""
|
| 1981 |
+
defn = get(table_key, st) or {}
|
| 1982 |
+
touched = {f['key'] for f in ai_enrich_fields(defn)} & {str(k) for k in (values or {})}
|
| 1983 |
+
for col_id in touched:
|
| 1984 |
+
stamp_ai_enrich(table_key, col_id, {str(row_id): {'state': 'human'}}, st=st)
|
| 1985 |
+
return sorted(touched)
|
| 1986 |
+
|
| 1987 |
+
|
| 1988 |
def _clean_field(raw, previous=None):
|
| 1989 |
"""One field dict β the stored shape, or None. The single validator for create AND patch, so
|
| 1990 |
a column cannot be typed one way on the way in and another on the way back."""
|
|
|
|
| 2220 |
out['formula'] = fx
|
| 2221 |
elif ftype == 'formula':
|
| 2222 |
return None
|
| 2223 |
+
# ββ WAVE-34 (owner ruling R13) β THE AI ENRICHMENT BAG. Posture and inheritance copied from
|
| 2224 |
+
# `formula` two lines up, deliberately and for the same reason: the bag defines the column, so
|
| 2225 |
+
# the pairing is enforced both ways, and an inherited bag is only inherited while the type
|
| 2226 |
+
# still wants it (retyping an enrichment column to text must not resurrect its prompt through
|
| 2227 |
+
# `prev` and then refuse the whole write, which is the bug the `prev_type == ftype` guard on
|
| 2228 |
+
# `link`/`rollup`/`formula` exists to record).
|
| 2229 |
+
enrich_raw = (raw.get('aiEnrich') if 'aiEnrich' in raw
|
| 2230 |
+
else (prev.get('aiEnrich') if prev_type == ftype else None))
|
| 2231 |
+
if enrich_raw is not None:
|
| 2232 |
+
ae = _clean_ai_enrich(enrich_raw)
|
| 2233 |
+
if ae is None or ftype != 'ai_enrich':
|
| 2234 |
+
return None
|
| 2235 |
+
out['aiEnrich'] = ae
|
| 2236 |
+
elif ftype == 'ai_enrich':
|
| 2237 |
+
return None
|
| 2238 |
+
# C3 (wave 34): the Agents module reads field agents off this bag. DERIVED here, inside the
|
| 2239 |
+
# one validator both write doors run, so the binding can never drift from the config it
|
| 2240 |
+
# describes β see `_field_agent_binding` for why it is computed rather than stored beside it.
|
| 2241 |
+
# β It is written AFTER the generic `automation` passthrough above, so a caller cannot hand us
|
| 2242 |
+
# a `field_agent` bag that disagrees with the column. Every OTHER automation bag is untouched.
|
| 2243 |
+
if ftype == 'ai_enrich':
|
| 2244 |
+
out['automation'] = _field_agent_binding(out)
|
| 2245 |
return out
|
| 2246 |
|
| 2247 |
|
|
|
|
| 2437 |
lives in `aios_grid._clean_automation` β this guards the WRITE doors only.)"""
|
| 2438 |
if not isinstance(bag, dict):
|
| 2439 |
return True # no bag, no law β an ordinary column
|
| 2440 |
+
# ββ WAVE-34 (R13 / contract C3) β A FIELD AGENT IS BOUND BY ITS OWN COLUMN, NOT BY A FLOW,
|
| 2441 |
+
# and without this arm the whole feature is unsavable. `_clean_field` stamps
|
| 2442 |
+
# `{kind:'field_agent', ...}` on every `ai_enrich` column; `add_field` then asks this
|
| 2443 |
+
# predicate, which read `flowId` and only `flowId` β so every AI enrichment column would have
|
| 2444 |
+
# been refused at the create door by the guard for a different feature that happens to share
|
| 2445 |
+
# the `automation` key. β The binding is REAL, not waived: the bag names the column it belongs
|
| 2446 |
+
# to, and `_clean_field` derives that name from the field itself, so it cannot name a column
|
| 2447 |
+
# that does not exist the way a stale `flowId` can name a deleted flow.
|
| 2448 |
+
if bag.get('kind') == 'field_agent':
|
| 2449 |
+
return bool(str(bag.get('field') or '').strip())
|
| 2450 |
flow = str(bag.get('flowId') or '').strip()
|
| 2451 |
if not flow:
|
| 2452 |
return False
|
|
|
|
| 2752 |
return True
|
| 2753 |
|
| 2754 |
|
| 2755 |
+
def patch_many_cells(table_key, updates, st=None):
|
| 2756 |
+
"""Write cells across MANY rows in ONE store update. Returns the number of rows touched.
|
| 2757 |
+
|
| 2758 |
+
β WHY THIS EXISTS RATHER THAN A LOOP OVER `patch_cells`, which is the same argument
|
| 2759 |
+
`add_rows` makes one screen up: every write here is a read-modify-write of the WHOLE tenant
|
| 2760 |
+
document, so filling a 1,000-row enrichment column row by row is 1,000 full-document copies
|
| 2761 |
+
under one lock on the single uvicorn process this product runs. One `update()` writes them
|
| 2762 |
+
all.
|
| 2763 |
+
|
| 2764 |
+
β `updates` is `{row_id: {field_key: value}}`, and a row id that does not exist is SKIPPED
|
| 2765 |
+
rather than created: this is a cell writer, not an insert door, and inventing a row here would
|
| 2766 |
+
let a stale plan resurrect a record somebody deleted mid-run.
|
| 2767 |
+
"""
|
| 2768 |
+
if not is_user_table(table_key, st):
|
| 2769 |
+
return 0
|
| 2770 |
+
have = set((get(table_key, st) or {}).get('rows') or {})
|
| 2771 |
+
clean = {str(r): {str(k): str(v) for k, v in (cells or {}).items()}
|
| 2772 |
+
for r, cells in (updates or {}).items()
|
| 2773 |
+
if str(r) in have and isinstance(cells, dict) and cells}
|
| 2774 |
+
if not clean:
|
| 2775 |
+
return 0
|
| 2776 |
+
|
| 2777 |
+
def _set(cur):
|
| 2778 |
+
t = cur.get(str(table_key))
|
| 2779 |
+
if t is not None:
|
| 2780 |
+
rows = t.setdefault('rows', {})
|
| 2781 |
+
for rid, cells in clean.items():
|
| 2782 |
+
if rid in rows:
|
| 2783 |
+
rows[rid].update(cells)
|
| 2784 |
+
return cur
|
| 2785 |
+
|
| 2786 |
+
_st(st).update(STORE_KEY, _set, flush='sync')
|
| 2787 |
+
return len(clean)
|
| 2788 |
+
|
| 2789 |
+
|
| 2790 |
def patch_link_cell(table_key, row_id, fkey, value, st=None):
|
| 2791 |
"""Persist one user-picked Link cell in the shared row and return its canonical id string.
|
| 2792 |
|
web/src/assistant/AssistantPage.tsx
CHANGED
|
@@ -1,20 +1,81 @@
|
|
| 1 |
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
|
| 3 |
import { QUERY_OPEN_EVENT, signal } from "../apiContract";
|
| 4 |
import { FolderMark } from "../customer-grid/icons";
|
| 5 |
import { queryCitationLabel } from "../customer-grid/queryPreview";
|
| 6 |
import { retryEmit } from "../inbox/inboxModel";
|
|
|
|
| 7 |
import { DbIcon } from "../shell/dbFrame";
|
| 8 |
-
import { databaseEntries, QUERY_ROUTE } from "../shell/nav";
|
| 9 |
import type { NavEntry } from "../shell/nav";
|
| 10 |
import {
|
| 11 |
-
deleteThread, fetchQueries, submitChat, type QueryCitation,
|
| 12 |
-
type QueryMessage, type SavedQuery,
|
| 13 |
} from "../query/queryApi";
|
| 14 |
import "./assistant.css";
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
export interface AssistantPageProps { granted: NavEntry[]; }
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const KIND_LABEL: Record<string, string> = {
|
| 19 |
grid: "table", list: "list", chart: "chart", kanban: "board", calendar: "calendar",
|
| 20 |
timeseries: "time series", map: "map",
|
|
@@ -30,17 +91,42 @@ const KIND_LABEL: Record<string, string> = {
|
|
| 30 |
* whatever the server sent β an unknown key falls through to itself rather than being hidden, so a
|
| 31 |
* provider added server-side appears here the day it is added instead of the day someone
|
| 32 |
* remembers to edit this file [[a-flag-can-ship-without-its-writer]].
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
*/
|
| 34 |
const MODEL_LABEL: Record<string, string> = {
|
| 35 |
auto: "Auto", cerebras: "Cerebras", groq: "Groq", openrouter: "OpenRouter",
|
| 36 |
-
anthropic: "Claude", openai: "OpenAI",
|
| 37 |
};
|
| 38 |
const modelLabel = (key: string) => MODEL_LABEL[key] ?? key;
|
| 39 |
|
| 40 |
-
/**
|
| 41 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
if (typeof window === "undefined") return () => {};
|
| 43 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
return retryEmit(() => signal(QUERY_OPEN_EVENT, { qid }));
|
| 45 |
}
|
| 46 |
|
|
@@ -54,10 +140,25 @@ export function CitationLine({ citation }: { citation: QueryCitation }) {
|
|
| 54 |
return <a className="as-citation" href={citation.href}>{queryCitationLabel(citation)}</a>;
|
| 55 |
}
|
| 56 |
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
message: QueryMessage; view?: SavedQuery; citations: QueryCitation[]; onPreview: (id: string) => void;
|
|
|
|
|
|
|
| 59 |
}) {
|
| 60 |
const [openSources, setOpenSources] = useState(false);
|
|
|
|
|
|
|
| 61 |
const matching = citations.filter((citation) => (message.citationIds || []).includes(citation.id));
|
| 62 |
if (message.role === "user") {
|
| 63 |
return (
|
|
@@ -85,6 +186,38 @@ export function AssistantMessage({ message, view, citations, onPreview }: {
|
|
| 85 |
<span className="as-preview-meta">{`Open the ${KIND_LABEL[view.kind] ?? view.kind} in Query`}</span>
|
| 86 |
</button>
|
| 87 |
) : null}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
{matching.length ? (
|
| 89 |
<div className="as-sources">
|
| 90 |
<button type="button" className="as-sources-btn" aria-expanded={openSources}
|
|
@@ -103,7 +236,8 @@ export function AssistantMessage({ message, view, citations, onPreview }: {
|
|
| 103 |
}
|
| 104 |
|
| 105 |
function initialIndex(): QueryIndex {
|
| 106 |
-
return { threads: [], messages: [], views: [], citations: [], models: ["auto"],
|
|
|
|
| 107 |
}
|
| 108 |
|
| 109 |
/**
|
|
@@ -147,8 +281,14 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
|
|
| 147 |
const [problem, setProblem] = useState("");
|
| 148 |
const [loaded, setLoaded] = useState(false);
|
| 149 |
const [confirmThread, setConfirmThread] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
| 150 |
const openCancel = useRef<(() => void) | null>(null);
|
| 151 |
const foot = useRef<HTMLDivElement | null>(null);
|
|
|
|
|
|
|
| 152 |
|
| 153 |
const databases = useMemo(() => databaseEntries(granted).filter((entry) => entry.kind !== "group"), [granted]);
|
| 154 |
const labels = useMemo(() => new Map(databases.map((entry) => [entry.key, entry.label])), [databases]);
|
|
@@ -171,12 +311,24 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
|
|
| 171 |
const messages = index.messages.filter((row) => row.threadId === activeThread);
|
| 172 |
const viewById = useMemo(() => new Map(index.views.map((view) => [view.id, view])), [index.views]);
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
const reload = useCallback(async () => {
|
| 175 |
const result = await fetchQueries();
|
| 176 |
setLoaded(true);
|
| 177 |
if (result.ok) {
|
| 178 |
setIndex(result.value);
|
| 179 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 180 |
}
|
| 181 |
}, []);
|
| 182 |
|
|
@@ -190,6 +342,62 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
|
|
| 190 |
// The conversation reads bottom-up, like every chat the owner named.
|
| 191 |
useEffect(() => { foot.current?.scrollIntoView({ block: "end" }); }, [messages.length, busy]);
|
| 192 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 193 |
const newChat = useCallback(() => {
|
| 194 |
setActiveThread(""); setQuestion(""); setProblem("");
|
| 195 |
}, []);
|
|
@@ -202,8 +410,8 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
|
|
| 202 |
const permitted = databases.map((entry) => entry.key);
|
| 203 |
const sources = (row.sources || []).filter((key) => permitted.includes(key));
|
| 204 |
if (sources.length) { setSelected(sources); setTarget(sources[0]); }
|
| 205 |
-
if (row.model && index.models.includes(row.model)) setModel(row.model);
|
| 206 |
-
}, [databases, index.models, index.threads]);
|
| 207 |
|
| 208 |
const toggleSource = useCallback((key: string) => {
|
| 209 |
if (blockedReason(key)) return;
|
|
@@ -214,20 +422,48 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
|
|
| 214 |
});
|
| 215 |
}, [blockedReason]);
|
| 216 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
const ask = useCallback(async () => {
|
| 218 |
const text = question.trim();
|
| 219 |
if (!text || !target || !selected.includes(target) || busy) return;
|
| 220 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 221 |
const result = await submitChat({ question: text, database: target, sources: selected,
|
| 222 |
...(activeThread ? { threadId: activeThread } : {}), model });
|
| 223 |
setBusy(false);
|
| 224 |
-
if (!result.ok) {
|
| 225 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 231 |
views: result.value.view ? [result.value.view, ...current.views.filter((row) => row.id !== result.value.view?.id)] : current.views,
|
| 232 |
citations: [...current.citations, ...result.value.citations],
|
| 233 |
}));
|
|
@@ -246,11 +482,26 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
|
|
| 246 |
setConfirmThread("");
|
| 247 |
}, []);
|
| 248 |
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 252 |
}, []);
|
| 253 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 254 |
const empty = messages.length === 0;
|
| 255 |
const composer = (
|
| 256 |
<div className="as-composer">
|
|
@@ -274,9 +525,15 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
|
|
| 274 |
<span className="as-pick-label">Model</span>
|
| 275 |
<select className="as-pick-select" value={model} aria-label="Model"
|
| 276 |
onChange={(event) => setModel(event.currentTarget.value)}>
|
| 277 |
-
{index.models.map((choice) =>
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
</select>
|
| 281 |
</label>
|
| 282 |
{/* β ONLY WHEN THE ANSWER IS AMBIGUOUS. Every generated view names ONE database (R3);
|
|
@@ -338,6 +595,23 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
|
|
| 338 |
<div className="as-chat" aria-label="AI assistant">
|
| 339 |
{/* Airtable's panel: one door to a new chat, then the history. */}
|
| 340 |
<aside className="as-history">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
<button type="button" className="as-new" onClick={newChat}>
|
| 342 |
<svg viewBox="0 0 16 16" aria-hidden="true" width="14" height="14">
|
| 343 |
<path d="M8 3.5v9M3.5 8h9" />
|
|
@@ -367,9 +641,27 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
|
|
| 367 |
<p className="as-thread-empty">No chats yet.</p>
|
| 368 |
) : null}
|
| 369 |
</nav>
|
|
|
|
|
|
|
| 370 |
</aside>
|
| 371 |
<main className="as-conversation">
|
| 372 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
<div className="as-opening">
|
| 374 |
<h1 className="as-opening-h">How can I help?</h1>
|
| 375 |
<p className="as-opening-p">
|
|
@@ -385,10 +677,16 @@ export default function AssistantPage({ granted }: AssistantPageProps) {
|
|
| 385 |
{messages.map((message) => (
|
| 386 |
<AssistantMessage key={message.id} message={message}
|
| 387 |
view={message.viewId ? viewById.get(message.viewId) : undefined}
|
| 388 |
-
citations={index.citations} onPreview={preview}
|
|
|
|
|
|
|
| 389 |
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
{busy ? (
|
| 391 |
-
<p className="as-working" role="status">
|
| 392 |
<span className="lp-spin" aria-hidden="true" />
|
| 393 |
</p>
|
| 394 |
) : null}
|
|
|
|
| 1 |
+
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
|
| 2 |
|
| 3 |
import { QUERY_OPEN_EVENT, signal } from "../apiContract";
|
| 4 |
import { FolderMark } from "../customer-grid/icons";
|
| 5 |
import { queryCitationLabel } from "../customer-grid/queryPreview";
|
| 6 |
import { retryEmit } from "../inbox/inboxModel";
|
| 7 |
+
import { queryGroups, QueryRail } from "../query/queryParts";
|
| 8 |
import { DbIcon } from "../shell/dbFrame";
|
| 9 |
+
import { ASSISTANT_ROUTE, databaseEntries, QUERY_ROUTE, routeKeyOf } from "../shell/nav";
|
| 10 |
import type { NavEntry } from "../shell/nav";
|
| 11 |
import {
|
| 12 |
+
deleteQuery, deleteThread, fetchQueries, rateMessage, submitChat, type QueryCitation,
|
| 13 |
+
type QueryIndex, type QueryMessage, type SavedQuery,
|
| 14 |
} from "../query/queryApi";
|
| 15 |
import "./assistant.css";
|
| 16 |
|
| 17 |
+
/**
|
| 18 |
+
* β LAZY ON PURPOSE. `QueryPage` imports `CustomerGrid`, the largest module in the client; a
|
| 19 |
+
* static import here would put the whole grid in the chat bundle, so opening the Assistant to ask
|
| 20 |
+
* one question would download the surface it is merged WITH but not showing. The list itself
|
| 21 |
+
* comes from `queryParts`, which imports no grid, so the left panel renders in Query mode before
|
| 22 |
+
* this chunk arrives.
|
| 23 |
+
*/
|
| 24 |
+
const QueryWorkspace = lazy(() => import("../query/QueryPage"));
|
| 25 |
+
|
| 26 |
export interface AssistantPageProps { granted: NavEntry[]; }
|
| 27 |
|
| 28 |
+
/**
|
| 29 |
+
* ββ R14 / CONTRACT C2 β ONE MODULE, ONE ROUTE, TWO LISTS. Owner, 2026-08-16: *"Combine the AI
|
| 30 |
+
* assistant module AND the Query module. In the secondary navigation at the top, just above
|
| 31 |
+
* '+ New chat', an easily accessible toggle between Chat / Query; the list below then shows either
|
| 32 |
+
* Chats or Queries."*
|
| 33 |
+
*
|
| 34 |
+
* The toggle changes WHICH LIST the left panel shows, never which route you are on: `#/assistant`
|
| 35 |
+
* serves both, and `#/query` redirects here carrying `?mode=query`.
|
| 36 |
+
*/
|
| 37 |
+
export type AssistantMode = "chat" | "query";
|
| 38 |
+
const MODE_KEY = "aios-assistant-mode";
|
| 39 |
+
const isMode = (value: unknown): value is AssistantMode => value === "chat" || value === "query";
|
| 40 |
+
|
| 41 |
+
/**
|
| 42 |
+
* The hash wins on arrival, the stored choice answers a reload.
|
| 43 |
+
*
|
| 44 |
+
* `?mode=query` is the REDIRECT'S INSTRUCTION β somebody followed a `#/query` link or bookmark β
|
| 45 |
+
* and a preference stored days ago must not override the link just clicked. Everything else reads
|
| 46 |
+
* `localStorage`, which is what makes the choice survive a reload.
|
| 47 |
+
*
|
| 48 |
+
* β THE TOGGLE STILL DOES NOT WRITE THE HASH, AND THE REASON HAS CHANGED β corrected here rather
|
| 49 |
+
* than left, because a stale comment that later readers quote is this wave's own worst defect.
|
| 50 |
+
* It USED to be a hard constraint: `Shell.tsx::read` took the whole remainder of the hash as the
|
| 51 |
+
* route key, so writing `#/assistant?mode=query` resolved to no route and landed on Home. B's
|
| 52 |
+
* `routeKeyOf` splits on `?` now (NOTE B-8), so writing it would be safe. It is still not written,
|
| 53 |
+
* for a smaller reason: which LIST a panel shows is a preference, not a location, and the hash is
|
| 54 |
+
* B's to own. The arrival parameter is consumed once, below, and stored.
|
| 55 |
+
* Pure and exported so the decision can be checked without a browser.
|
| 56 |
+
*/
|
| 57 |
+
export function initialMode(hash: string, stored: string | null): AssistantMode {
|
| 58 |
+
const mark = hash.indexOf("?");
|
| 59 |
+
if (mark >= 0) {
|
| 60 |
+
const asked = new URLSearchParams(hash.slice(mark + 1)).get("mode");
|
| 61 |
+
if (isMode(asked)) return asked;
|
| 62 |
+
}
|
| 63 |
+
return isMode(stored) ? stored : "chat";
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
/** A private window refuses storage; that is a lost preference, never an error the reader sees. */
|
| 67 |
+
function readMode(): AssistantMode {
|
| 68 |
+
if (typeof window === "undefined") return "chat";
|
| 69 |
+
let stored: string | null = null;
|
| 70 |
+
try { stored = window.localStorage.getItem(MODE_KEY); } catch { stored = null; }
|
| 71 |
+
return initialMode(window.location.hash, stored);
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
+
function storeMode(mode: AssistantMode): void {
|
| 75 |
+
if (typeof window === "undefined") return;
|
| 76 |
+
try { window.localStorage.setItem(MODE_KEY, mode); } catch { /* see readMode */ }
|
| 77 |
+
}
|
| 78 |
+
|
| 79 |
const KIND_LABEL: Record<string, string> = {
|
| 80 |
grid: "table", list: "list", chart: "chart", kanban: "board", calendar: "calendar",
|
| 81 |
timeseries: "time series", map: "map",
|
|
|
|
| 91 |
* whatever the server sent β an unknown key falls through to itself rather than being hidden, so a
|
| 92 |
* provider added server-side appears here the day it is added instead of the day someone
|
| 93 |
* remembers to edit this file [[a-flag-can-ship-without-its-writer]].
|
| 94 |
+
*
|
| 95 |
+
* β `anthropic: "Claude"` AND `openai: "OpenAI"` ARE GONE, and the reason is worth keeping because
|
| 96 |
+
* the ticket that removed them described the defect backwards. They were never OFFERED: the picker
|
| 97 |
+
* renders `index.models`, which is `model_choices()` = `[auto, *QUERY_PROVIDER_ORDER]`, so no
|
| 98 |
+
* reader ever saw "Claude" in this control. They were labels for providers that do not exist in
|
| 99 |
+
* the platform ladder, i.e. dead weight, and the fall-through above already covers a provider
|
| 100 |
+
* added server-side. The REAL defect the ticket named lives one layer down and is fixed by
|
| 101 |
+
* `modelStatus`: a model that IS offered and whose key this deployment does not hold.
|
| 102 |
*/
|
| 103 |
const MODEL_LABEL: Record<string, string> = {
|
| 104 |
auto: "Auto", cerebras: "Cerebras", groq: "Groq", openrouter: "OpenRouter",
|
|
|
|
| 105 |
};
|
| 106 |
const modelLabel = (key: string) => MODEL_LABEL[key] ?? key;
|
| 107 |
|
| 108 |
+
/**
|
| 109 |
+
* Route to the exact Query-owned artefact. C owns the destination wiring, not this page.
|
| 110 |
+
*
|
| 111 |
+
* β `hosted` is R14's one change and it is a NAVIGATION suppressor, not a second path: when this
|
| 112 |
+
* surface is itself showing Query, there is nowhere to route TO β the workspace is already mounted
|
| 113 |
+
* in the main column β and setting the hash would send the reader to the standalone route we just
|
| 114 |
+
* merged away from. The qid contract is unchanged either way, which is the point: the same public
|
| 115 |
+
* hand-off answers an external link and an in-page click, so Query keeps ONE selection door.
|
| 116 |
+
* β The retry ladder still earns its keep here: the hosted workspace is lazy, so the event can
|
| 117 |
+
* fire before its chunk has finished loading and its listener exists.
|
| 118 |
+
*/
|
| 119 |
+
export function openBuiltView(qid: string, hosted = false): () => void {
|
| 120 |
if (typeof window === "undefined") return () => {};
|
| 121 |
+
// β `routeKeyOf`, NOT `hash.replace(...)` (B's NOTE B-14). Since T15 the hash is
|
| 122 |
+
// `#/assistant?mode=query`, so the whole-remainder idiom yields the key `assistant?mode=query`,
|
| 123 |
+
// which is never `query` β the guard was permanently true and its intent ("do not navigate, we
|
| 124 |
+
// are already there") had become unsatisfiable by any hash. Latent rather than live, because the
|
| 125 |
+
// one caller passes `hosted`, and fixed rather than left because a guard that cannot be
|
| 126 |
+
// satisfied is the kind a later reader trusts.
|
| 127 |
+
if (!hosted && routeKeyOf(window.location.hash) !== QUERY_ROUTE) {
|
| 128 |
+
window.location.hash = `#/${QUERY_ROUTE}`;
|
| 129 |
+
}
|
| 130 |
return retryEmit(() => signal(QUERY_OPEN_EVENT, { qid }));
|
| 131 |
}
|
| 132 |
|
|
|
|
| 140 |
return <a className="as-citation" href={citation.href}>{queryCitationLabel(citation)}</a>;
|
| 141 |
}
|
| 142 |
|
| 143 |
+
/** One glyph, drawn once and turned over for the other. R20 asked for thumbs; these are thumbs. */
|
| 144 |
+
function ThumbIcon({ down = false }: { down?: boolean }) {
|
| 145 |
+
return (
|
| 146 |
+
<svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13"
|
| 147 |
+
{...(down ? { transform: "rotate(180)" } : {})}>
|
| 148 |
+
<path d="M5.6 14.2V6.9h1.5l2.2-4.3a1 1 0 0 1 1.8.9l-.7 2.6h2.4a1 1 0 0 1 1 1.3l-1.1 4.6a1.5 1.5 0 0 1-1.5 1.1H5.6z" />
|
| 149 |
+
<path d="M5.6 6.9H2.6v7.3h3" />
|
| 150 |
+
</svg>
|
| 151 |
+
);
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
export function AssistantMessage({ message, view, citations, onPreview, onRate }: {
|
| 155 |
message: QueryMessage; view?: SavedQuery; citations: QueryCitation[]; onPreview: (id: string) => void;
|
| 156 |
+
/** Optional so the message can be rendered outside a live conversation without a rating door. */
|
| 157 |
+
onRate?: (id: string, rating: "up" | "down" | null, reason?: string) => void;
|
| 158 |
}) {
|
| 159 |
const [openSources, setOpenSources] = useState(false);
|
| 160 |
+
const [askWhy, setAskWhy] = useState(false);
|
| 161 |
+
const [why, setWhy] = useState("");
|
| 162 |
const matching = citations.filter((citation) => (message.citationIds || []).includes(citation.id));
|
| 163 |
if (message.role === "user") {
|
| 164 |
return (
|
|
|
|
| 186 |
<span className="as-preview-meta">{`Open the ${KIND_LABEL[view.kind] ?? view.kind} in Query`}</span>
|
| 187 |
</button>
|
| 188 |
) : null}
|
| 189 |
+
{/* β The thumbs are NOT a survey: the server reads a thumbs-down back into the next turn of
|
| 190 |
+
this thread. That is why a down asks for one short reason and why both are clearable. */}
|
| 191 |
+
{onRate ? (
|
| 192 |
+
<div className="as-rate">
|
| 193 |
+
<button type="button" aria-label="Helpful" aria-pressed={message.rating === "up"}
|
| 194 |
+
className={"as-rate-btn" + (message.rating === "up" ? " is-on" : "")}
|
| 195 |
+
onClick={() => { setAskWhy(false); onRate(message.id, message.rating === "up" ? null : "up"); }}>
|
| 196 |
+
<ThumbIcon />
|
| 197 |
+
</button>
|
| 198 |
+
<button type="button" aria-label="Not helpful" aria-pressed={message.rating === "down"}
|
| 199 |
+
className={"as-rate-btn" + (message.rating === "down" ? " is-on" : "")}
|
| 200 |
+
onClick={() => {
|
| 201 |
+
if (message.rating === "down") { setAskWhy(false); onRate(message.id, null); return; }
|
| 202 |
+
setAskWhy(true); onRate(message.id, "down");
|
| 203 |
+
}}>
|
| 204 |
+
<ThumbIcon down />
|
| 205 |
+
</button>
|
| 206 |
+
{message.rating === "down" && !askWhy && message.ratingReason ? (
|
| 207 |
+
<span className="as-rate-why-said">{message.ratingReason}</span>
|
| 208 |
+
) : null}
|
| 209 |
+
</div>
|
| 210 |
+
) : null}
|
| 211 |
+
{onRate && askWhy && message.rating === "down" ? (
|
| 212 |
+
<form className="as-rate-why" onSubmit={(event) => {
|
| 213 |
+
event.preventDefault(); setAskWhy(false); onRate(message.id, "down", why.trim());
|
| 214 |
+
}}>
|
| 215 |
+
<input className="as-rate-input" value={why} aria-label="What was wrong"
|
| 216 |
+
placeholder="What was wrong? Optional."
|
| 217 |
+
onChange={(event) => setWhy(event.currentTarget.value)} />
|
| 218 |
+
<button type="submit" className="as-rate-send">Send</button>
|
| 219 |
+
</form>
|
| 220 |
+
) : null}
|
| 221 |
{matching.length ? (
|
| 222 |
<div className="as-sources">
|
| 223 |
<button type="button" className="as-sources-btn" aria-expanded={openSources}
|
|
|
|
| 236 |
}
|
| 237 |
|
| 238 |
function initialIndex(): QueryIndex {
|
| 239 |
+
return { threads: [], messages: [], views: [], citations: [], models: ["auto"],
|
| 240 |
+
modelStatus: [], sources: [] };
|
| 241 |
}
|
| 242 |
|
| 243 |
/**
|
|
|
|
| 281 |
const [problem, setProblem] = useState("");
|
| 282 |
const [loaded, setLoaded] = useState(false);
|
| 283 |
const [confirmThread, setConfirmThread] = useState("");
|
| 284 |
+
const [mode, setMode] = useState<AssistantMode>(readMode);
|
| 285 |
+
const [activeQuery, setActiveQuery] = useState("");
|
| 286 |
+
/** Bumped on delete so the hosted workspace's own copy of the list cannot go stale behind us. */
|
| 287 |
+
const [queryRefresh, setQueryRefresh] = useState(0);
|
| 288 |
const openCancel = useRef<(() => void) | null>(null);
|
| 289 |
const foot = useRef<HTMLDivElement | null>(null);
|
| 290 |
+
/** A counter, not a random id: the optimistic message only has to be unique within this page. */
|
| 291 |
+
const pending = useRef(0);
|
| 292 |
|
| 293 |
const databases = useMemo(() => databaseEntries(granted).filter((entry) => entry.kind !== "group"), [granted]);
|
| 294 |
const labels = useMemo(() => new Map(databases.map((entry) => [entry.key, entry.label])), [databases]);
|
|
|
|
| 311 |
const messages = index.messages.filter((row) => row.threadId === activeThread);
|
| 312 |
const viewById = useMemo(() => new Map(index.views.map((view) => [view.id, view])), [index.views]);
|
| 313 |
|
| 314 |
+
/** Offered is not callable: a model whose key this deployment does not hold says so, and why. */
|
| 315 |
+
const modelBlocked = useCallback((key: string) => {
|
| 316 |
+
const row = index.modelStatus.find((item) => item.model === key);
|
| 317 |
+
return row && row.available === false
|
| 318 |
+
? row.reason || "this model is not available on this deployment" : "";
|
| 319 |
+
}, [index.modelStatus]);
|
| 320 |
+
|
| 321 |
const reload = useCallback(async () => {
|
| 322 |
const result = await fetchQueries();
|
| 323 |
setLoaded(true);
|
| 324 |
if (result.ok) {
|
| 325 |
setIndex(result.value);
|
| 326 |
+
// β Falls back to Auto when the SELECTED model is offered but not callable, not only when it
|
| 327 |
+
// has left the list: a stored thread can restore a model whose key was removed since, and a
|
| 328 |
+
// disabled option the picker cannot clear is a control that refuses its own value.
|
| 329 |
+
const callable = (key: string) => result.value.models.includes(key)
|
| 330 |
+
&& result.value.modelStatus.every((row) => row.model !== key || row.available);
|
| 331 |
+
setModel((current) => callable(current) ? current : "auto");
|
| 332 |
}
|
| 333 |
}, []);
|
| 334 |
|
|
|
|
| 342 |
// The conversation reads bottom-up, like every chat the owner named.
|
| 343 |
useEffect(() => { foot.current?.scrollIntoView({ block: "end" }); }, [messages.length, busy]);
|
| 344 |
|
| 345 |
+
/**
|
| 346 |
+
* The `?mode=` the redirect carried has been read into state; drop it so a toggle made later is
|
| 347 |
+
* not overridden by a stale parameter on the next reload. `replaceState` does NOT fire
|
| 348 |
+
* `hashchange`, so the shell never re-resolves the route and nothing flickers.
|
| 349 |
+
*
|
| 350 |
+
* β AND IT IS STORED IN THE SAME BREATH, WHICH IS THE HALF THAT WAS MISSING. `useState(readMode)`
|
| 351 |
+
* reads the hash and writes NOTHING; only `pickMode` persists. So a reader who arrived in Query
|
| 352 |
+
* by following a `#/query` link, never touching the toggle, would have the parameter stripped
|
| 353 |
+
* here and then land back in Chat on the next reload, restored from a choice made days earlier.
|
| 354 |
+
* Following the link IS choosing.
|
| 355 |
+
*/
|
| 356 |
+
useEffect(() => {
|
| 357 |
+
if (typeof window === "undefined" || !window.location.hash.includes("?")) return;
|
| 358 |
+
storeMode(mode);
|
| 359 |
+
window.history.replaceState(null, "", `#/${ASSISTANT_ROUTE}`);
|
| 360 |
+
// `mode` is read once, at mount, on purpose: this effect exists to consume the arrival
|
| 361 |
+
// parameter, not to mirror every later toggle (`pickMode` already does that).
|
| 362 |
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 363 |
+
}, []);
|
| 364 |
+
|
| 365 |
+
const pickMode = useCallback((next: AssistantMode) => {
|
| 366 |
+
setMode(next); storeMode(next); setProblem("");
|
| 367 |
+
}, []);
|
| 368 |
+
|
| 369 |
+
/** The saved Query views, grouped exactly as the standalone page groups them (ONE function). */
|
| 370 |
+
const queryList = useMemo(() => queryGroups(index.views, databases), [index.views, databases]);
|
| 371 |
+
|
| 372 |
+
const selectQuery = useCallback((qid: string) => {
|
| 373 |
+
setActiveQuery(qid);
|
| 374 |
+
openCancel.current?.();
|
| 375 |
+
openCancel.current = openBuiltView(qid, true);
|
| 376 |
+
}, []);
|
| 377 |
+
|
| 378 |
+
/** A renamed or duplicated artefact, merged in place, and the hosted workspace told to re-read. */
|
| 379 |
+
const upsertQuery = useCallback((view: SavedQuery) => {
|
| 380 |
+
setIndex((current) => ({
|
| 381 |
+
...current, views: [view, ...current.views.filter((row) => row.id !== view.id)],
|
| 382 |
+
}));
|
| 383 |
+
setQueryRefresh((count) => count + 1);
|
| 384 |
+
}, []);
|
| 385 |
+
|
| 386 |
+
const removeQuery = useCallback(async (qid: string) => {
|
| 387 |
+
const result = await deleteQuery(qid);
|
| 388 |
+
if (!result.ok) { setProblem(result.message); return; }
|
| 389 |
+
setIndex((current) => ({ ...current, views: current.views.filter((row) => row.id !== qid) }));
|
| 390 |
+
setActiveQuery((current) => (current === qid ? "" : current));
|
| 391 |
+
setQueryRefresh((count) => count + 1);
|
| 392 |
+
}, []);
|
| 393 |
+
|
| 394 |
+
/** Arriving in Query with nothing chosen selects the newest view of the first database. */
|
| 395 |
+
useEffect(() => {
|
| 396 |
+
if (mode !== "query" || activeQuery) return;
|
| 397 |
+
const first = queryList[0]?.views[0];
|
| 398 |
+
if (first) selectQuery(first.id);
|
| 399 |
+
}, [mode, activeQuery, queryList, selectQuery]);
|
| 400 |
+
|
| 401 |
const newChat = useCallback(() => {
|
| 402 |
setActiveThread(""); setQuestion(""); setProblem("");
|
| 403 |
}, []);
|
|
|
|
| 410 |
const permitted = databases.map((entry) => entry.key);
|
| 411 |
const sources = (row.sources || []).filter((key) => permitted.includes(key));
|
| 412 |
if (sources.length) { setSelected(sources); setTarget(sources[0]); }
|
| 413 |
+
if (row.model && index.models.includes(row.model) && !modelBlocked(row.model)) setModel(row.model);
|
| 414 |
+
}, [databases, index.models, index.threads, modelBlocked]);
|
| 415 |
|
| 416 |
const toggleSource = useCallback((key: string) => {
|
| 417 |
if (blockedReason(key)) return;
|
|
|
|
| 422 |
});
|
| 423 |
}, [blockedReason]);
|
| 424 |
|
| 425 |
+
/**
|
| 426 |
+
* ββ R16 β ENTER GOES TO THE CHAT, NOT TO A WAIT. Owner: *"Pressing enter goes immediately to
|
| 427 |
+
* the chat view showing that it is loading; there is a thinking animation."*
|
| 428 |
+
*
|
| 429 |
+
* This used to POST and only then touch `index`, so for the whole model call the reader sat on
|
| 430 |
+
* the opening screen looking at their own unsent sentence with nothing moving. The message is
|
| 431 |
+
* appended FIRST, which is what flips `empty` and hands the surface to the stream; `busy` then
|
| 432 |
+
* draws the app's one loading mark under it.
|
| 433 |
+
*
|
| 434 |
+
* β THE INVERSE IS CAPTURED BEFORE THE WRITE. An optimistic append that is not reconciled on
|
| 435 |
+
* failure leaves a message on screen the server never received, and the next turn replays it to
|
| 436 |
+
* the model as if it had [[undo-capture-before-the-write]]. The typed text goes back in the
|
| 437 |
+
* composer too: losing somebody's sentence to a failed request is the second injury.
|
| 438 |
+
*/
|
| 439 |
const ask = useCallback(async () => {
|
| 440 |
const text = question.trim();
|
| 441 |
if (!text || !target || !selected.includes(target) || busy) return;
|
| 442 |
+
const pendingId = `pending_${(pending.current += 1)}`;
|
| 443 |
+
const optimistic: QueryMessage = {
|
| 444 |
+
id: pendingId, threadId: activeThread, role: "user", content: text,
|
| 445 |
+
createdAt: new Date().toISOString(), targetDatabase: target,
|
| 446 |
+
sources: selected, requestedModel: model,
|
| 447 |
+
};
|
| 448 |
+
const withoutPending = (rows: QueryMessage[]) => rows.filter((row) => row.id !== pendingId);
|
| 449 |
+
setBusy(true); setProblem(""); setQuestion("");
|
| 450 |
+
setIndex((current) => ({ ...current, messages: [...current.messages, optimistic] }));
|
| 451 |
const result = await submitChat({ question: text, database: target, sources: selected,
|
| 452 |
...(activeThread ? { threadId: activeThread } : {}), model });
|
| 453 |
setBusy(false);
|
| 454 |
+
if (!result.ok) {
|
| 455 |
+
setIndex((current) => ({ ...current, messages: withoutPending(current.messages) }));
|
| 456 |
+
setQuestion(text);
|
| 457 |
+
setProblem(result.message);
|
| 458 |
+
return;
|
| 459 |
+
}
|
| 460 |
setActiveThread(result.value.thread.id);
|
| 461 |
setIndex((current) => ({
|
| 462 |
...current,
|
| 463 |
threads: [result.value.thread, ...current.threads.filter((row) => row.id !== result.value.thread.id)],
|
| 464 |
+
// The server's own copy of the question replaces the optimistic one, so a thread never holds
|
| 465 |
+
// two of the same turn and the id the rest of the page keys on is the durable one.
|
| 466 |
+
messages: [...withoutPending(current.messages), result.value.userMessage, result.value.message],
|
| 467 |
views: result.value.view ? [result.value.view, ...current.views.filter((row) => row.id !== result.value.view?.id)] : current.views,
|
| 468 |
citations: [...current.citations, ...result.value.citations],
|
| 469 |
}));
|
|
|
|
| 482 |
setConfirmThread("");
|
| 483 |
}, []);
|
| 484 |
|
| 485 |
+
/**
|
| 486 |
+
* The rating goes to the server and the SERVER'S copy comes back, rather than the page patching
|
| 487 |
+
* its own guess: the reason is normalised and cleared there, and a rating that only looked
|
| 488 |
+
* applied is exactly the shape this module keeps being bitten by.
|
| 489 |
+
*/
|
| 490 |
+
const rate = useCallback(async (id: string, rating: "up" | "down" | null, reason?: string) => {
|
| 491 |
+
const result = await rateMessage(id, rating, reason);
|
| 492 |
+
if (!result.ok) { setProblem(result.message); return; }
|
| 493 |
+
setIndex((current) => ({
|
| 494 |
+
...current,
|
| 495 |
+
messages: current.messages.map((row) => (row.id === id ? result.value : row)),
|
| 496 |
+
}));
|
| 497 |
}, []);
|
| 498 |
|
| 499 |
+
/** A built view opens WHERE THE READER ALREADY IS: the toggle flips, the list follows. */
|
| 500 |
+
const preview = useCallback((qid: string) => {
|
| 501 |
+
pickMode("query");
|
| 502 |
+
selectQuery(qid);
|
| 503 |
+
}, [pickMode, selectQuery]);
|
| 504 |
+
|
| 505 |
const empty = messages.length === 0;
|
| 506 |
const composer = (
|
| 507 |
<div className="as-composer">
|
|
|
|
| 525 |
<span className="as-pick-label">Model</span>
|
| 526 |
<select className="as-pick-select" value={model} aria-label="Model"
|
| 527 |
onChange={(event) => setModel(event.currentTarget.value)}>
|
| 528 |
+
{index.models.map((choice) => {
|
| 529 |
+
const blocked = modelBlocked(choice);
|
| 530 |
+
return (
|
| 531 |
+
<option key={choice} value={choice} disabled={!!blocked}
|
| 532 |
+
title={blocked || undefined}>
|
| 533 |
+
{blocked ? `${modelLabel(choice)} (not configured)` : modelLabel(choice)}
|
| 534 |
+
</option>
|
| 535 |
+
);
|
| 536 |
+
})}
|
| 537 |
</select>
|
| 538 |
</label>
|
| 539 |
{/* β ONLY WHEN THE ANSWER IS AMBIGUOUS. Every generated view names ONE database (R3);
|
|
|
|
| 595 |
<div className="as-chat" aria-label="AI assistant">
|
| 596 |
{/* Airtable's panel: one door to a new chat, then the history. */}
|
| 597 |
<aside className="as-history">
|
| 598 |
+
{/* C2: two values, directly ABOVE "+ New chat". It changes which LIST shows, not the route. */}
|
| 599 |
+
<div className="as-mode-toggle" role="tablist" aria-label="Chat or Query">
|
| 600 |
+
{(["chat", "query"] as const).map((value) => (
|
| 601 |
+
<button type="button" key={value} role="tab" aria-selected={mode === value}
|
| 602 |
+
className={"as-mode" + (mode === value ? " is-on" : "")}
|
| 603 |
+
onClick={() => pickMode(value)}>
|
| 604 |
+
{value === "chat" ? "Chat" : "Query"}
|
| 605 |
+
</button>
|
| 606 |
+
))}
|
| 607 |
+
</div>
|
| 608 |
+
{mode === "query" ? (
|
| 609 |
+
/* The SAME rail the standalone page renders; only its host changed, so the differences
|
| 610 |
+
are CSS in assistant.css and nothing here. */
|
| 611 |
+
<QueryRail groups={queryList} activeId={activeQuery} onSelect={selectQuery}
|
| 612 |
+
onDelete={(id) => void removeQuery(id)} onChanged={upsertQuery} />
|
| 613 |
+
) : (
|
| 614 |
+
<>
|
| 615 |
<button type="button" className="as-new" onClick={newChat}>
|
| 616 |
<svg viewBox="0 0 16 16" aria-hidden="true" width="14" height="14">
|
| 617 |
<path d="M8 3.5v9M3.5 8h9" />
|
|
|
|
| 641 |
<p className="as-thread-empty">No chats yet.</p>
|
| 642 |
) : null}
|
| 643 |
</nav>
|
| 644 |
+
</>
|
| 645 |
+
)}
|
| 646 |
</aside>
|
| 647 |
<main className="as-conversation">
|
| 648 |
+
{mode === "query" ? (
|
| 649 |
+
queryList.length ? (
|
| 650 |
+
<Suspense fallback={<p className="as-working" role="status">
|
| 651 |
+
<span className="lp-spin" aria-hidden="true" />
|
| 652 |
+
</p>}>
|
| 653 |
+
<QueryWorkspace granted={granted} hostedRail refreshToken={queryRefresh}
|
| 654 |
+
selectedId={activeQuery} />
|
| 655 |
+
</Suspense>
|
| 656 |
+
) : (
|
| 657 |
+
<div className="as-opening">
|
| 658 |
+
<h1 className="as-opening-h">No Query views yet</h1>
|
| 659 |
+
<p className="as-opening-p">
|
| 660 |
+
Ask a question in Chat. When the assistant builds a view, it is saved here.
|
| 661 |
+
</p>
|
| 662 |
+
</div>
|
| 663 |
+
)
|
| 664 |
+
) : empty ? (
|
| 665 |
<div className="as-opening">
|
| 666 |
<h1 className="as-opening-h">How can I help?</h1>
|
| 667 |
<p className="as-opening-p">
|
|
|
|
| 677 |
{messages.map((message) => (
|
| 678 |
<AssistantMessage key={message.id} message={message}
|
| 679 |
view={message.viewId ? viewById.get(message.viewId) : undefined}
|
| 680 |
+
citations={index.citations} onPreview={preview}
|
| 681 |
+
onRate={message.role === "assistant" && !message.id.startsWith("pending_")
|
| 682 |
+
? rate : undefined} />
|
| 683 |
))}
|
| 684 |
+
{/* R16's thinking animation is the app's ONE loading mark, not a second spinner
|
| 685 |
+
vocabulary: `.lp-spin` is the same mark every surface uses and C is repainting
|
| 686 |
+
it in the brand purple this wave. The label is for screen readers only, since
|
| 687 |
+
an aria-hidden spinner inside a status region announces nothing. */}
|
| 688 |
{busy ? (
|
| 689 |
+
<p className="as-working" role="status" aria-label="The assistant is thinking">
|
| 690 |
<span className="lp-spin" aria-hidden="true" />
|
| 691 |
</p>
|
| 692 |
) : null}
|
web/src/assistant/assistant.css
CHANGED
|
@@ -40,6 +40,60 @@
|
|
| 40 |
overflow: hidden;
|
| 41 |
}
|
| 42 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
.as-new {
|
| 44 |
flex: 0 0 auto;
|
| 45 |
display: flex;
|
|
@@ -285,6 +339,80 @@
|
|
| 285 |
.as-preview-name { color: var(--lp-ink); font-size: var(--lp-fs-xs); font-weight: 600; }
|
| 286 |
.as-preview-meta { color: var(--lp-blue-solid); font-size: var(--lp-fs-2xs); font-weight: 600; }
|
| 287 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
.as-sources { margin-top: 8px; }
|
| 289 |
|
| 290 |
.as-sources-btn {
|
|
|
|
| 40 |
overflow: hidden;
|
| 41 |
}
|
| 42 |
|
| 43 |
+
/* ββ R14 / C2: the Chat / Query toggle, directly above New chat βββββββββββββ */
|
| 44 |
+
|
| 45 |
+
.as-mode-toggle {
|
| 46 |
+
flex: 0 0 auto;
|
| 47 |
+
display: flex;
|
| 48 |
+
gap: 2px;
|
| 49 |
+
margin-bottom: 10px;
|
| 50 |
+
padding: 2px;
|
| 51 |
+
border-radius: var(--lp-r-md);
|
| 52 |
+
background: var(--lp-surface-2);
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
.as-mode {
|
| 56 |
+
flex: 1 1 0;
|
| 57 |
+
min-width: 0;
|
| 58 |
+
min-height: 26px;
|
| 59 |
+
padding: 4px 8px;
|
| 60 |
+
border: 0;
|
| 61 |
+
border-radius: 6px;
|
| 62 |
+
background: transparent;
|
| 63 |
+
color: var(--lp-muted);
|
| 64 |
+
font: inherit;
|
| 65 |
+
font-size: var(--lp-fs-2xs);
|
| 66 |
+
font-weight: 600;
|
| 67 |
+
cursor: pointer;
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
.as-mode:hover { color: var(--lp-ink); }
|
| 71 |
+
.as-mode.is-on { background: var(--lp-surface); color: var(--lp-ink); }
|
| 72 |
+
|
| 73 |
+
/* The Query list is the SAME `QueryRail` the standalone page renders β only its host changed.
|
| 74 |
+
These are the overrides a rail needs when it is nested inside a panel that is already one rail
|
| 75 |
+
wide: it must FILL that panel rather than claim a second `--lp-rail-w` beside it, and it must
|
| 76 |
+
not draw a second divider against the one `.as-history` already has.
|
| 77 |
+
`.qy-rail-ask` goes because it links to the Assistant, which is the surface it is now inside. */
|
| 78 |
+
.as-history .qy-rail {
|
| 79 |
+
flex: 1 1 auto;
|
| 80 |
+
width: auto;
|
| 81 |
+
min-width: 0;
|
| 82 |
+
height: auto;
|
| 83 |
+
min-height: 0;
|
| 84 |
+
padding: 0;
|
| 85 |
+
border-right: 0;
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
.as-history .qy-rail-ask { display: none; }
|
| 89 |
+
|
| 90 |
+
/* Query fills the conversation column while the toggle is on Query. */
|
| 91 |
+
.as-conversation > .shell-db-frame,
|
| 92 |
+
.as-conversation > .qy-empty {
|
| 93 |
+
flex: 1 1 auto;
|
| 94 |
+
min-height: 0;
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
.as-new {
|
| 98 |
flex: 0 0 auto;
|
| 99 |
display: flex;
|
|
|
|
| 339 |
.as-preview-name { color: var(--lp-ink); font-size: var(--lp-fs-xs); font-weight: 600; }
|
| 340 |
.as-preview-meta { color: var(--lp-blue-solid); font-size: var(--lp-fs-2xs); font-weight: 600; }
|
| 341 |
|
| 342 |
+
/* ββ R20: the thumbs, which the server reads back into the next turn βββββββββ */
|
| 343 |
+
|
| 344 |
+
.as-rate {
|
| 345 |
+
display: flex;
|
| 346 |
+
align-items: center;
|
| 347 |
+
gap: 4px;
|
| 348 |
+
margin-top: 10px;
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
.as-rate-btn {
|
| 352 |
+
width: 26px;
|
| 353 |
+
height: 26px;
|
| 354 |
+
display: inline-flex;
|
| 355 |
+
align-items: center;
|
| 356 |
+
justify-content: center;
|
| 357 |
+
border: 0;
|
| 358 |
+
border-radius: 6px;
|
| 359 |
+
background: transparent;
|
| 360 |
+
color: var(--lp-muted);
|
| 361 |
+
cursor: pointer;
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
.as-rate-btn svg {
|
| 365 |
+
fill: none;
|
| 366 |
+
stroke: currentColor;
|
| 367 |
+
stroke-width: 1.3;
|
| 368 |
+
stroke-linecap: round;
|
| 369 |
+
stroke-linejoin: round;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.as-rate-btn:hover { background: var(--lp-surface-2); color: var(--lp-ink); }
|
| 373 |
+
.as-rate-btn.is-on { background: var(--lp-blue-tint); color: var(--lp-blue-solid); }
|
| 374 |
+
|
| 375 |
+
.as-rate-why-said {
|
| 376 |
+
margin-left: 4px;
|
| 377 |
+
color: var(--lp-muted);
|
| 378 |
+
font-size: var(--lp-fs-2xs);
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.as-rate-why {
|
| 382 |
+
display: flex;
|
| 383 |
+
gap: 6px;
|
| 384 |
+
margin-top: 6px;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
.as-rate-input {
|
| 388 |
+
flex: 1 1 auto;
|
| 389 |
+
min-width: 0;
|
| 390 |
+
padding: 6px 10px;
|
| 391 |
+
border: 1px solid var(--lp-line);
|
| 392 |
+
border-radius: var(--lp-r-md);
|
| 393 |
+
background: var(--lp-surface);
|
| 394 |
+
color: var(--lp-ink);
|
| 395 |
+
font: inherit;
|
| 396 |
+
font-size: var(--lp-fs-2xs);
|
| 397 |
+
}
|
| 398 |
+
|
| 399 |
+
.as-rate-input:focus { outline: none; border-color: var(--lp-blue-solid); }
|
| 400 |
+
|
| 401 |
+
.as-rate-send {
|
| 402 |
+
flex: 0 0 auto;
|
| 403 |
+
padding: 6px 12px;
|
| 404 |
+
border: 1px solid var(--lp-line);
|
| 405 |
+
border-radius: var(--lp-r-md);
|
| 406 |
+
background: var(--lp-surface);
|
| 407 |
+
color: var(--lp-ink);
|
| 408 |
+
font: inherit;
|
| 409 |
+
font-size: var(--lp-fs-2xs);
|
| 410 |
+
font-weight: 600;
|
| 411 |
+
cursor: pointer;
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
.as-rate-send:hover { background: var(--lp-surface-2); }
|
| 415 |
+
|
| 416 |
.as-sources { margin-top: 8px; }
|
| 417 |
|
| 418 |
.as-sources-btn {
|
web/src/automation/AutomationBuilder.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/automation/AutomationChat.tsx
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// automation/AutomationChat.tsx β the Chat view of the Agent module (R17).
|
| 3 |
+
//
|
| 4 |
+
// Owner, ruling R17: *"The Agent module needs a Chat view beside the builder, where the user asks
|
| 5 |
+
// the AI to build the automation."*
|
| 6 |
+
//
|
| 7 |
+
// ββ WHAT THIS IS NOT: a second way to save an automation. `POST /automations/draft` writes
|
| 8 |
+
// NOTHING and says so on the wire (`saved: false`); accepting a draft is the ordinary
|
| 9 |
+
// `POST /automations` that the Canvas already uses. That is R3's *"indistinguishable from a
|
| 10 |
+
// hand-built one"* achieved by there being ONE write path, not two that look alike. If a future
|
| 11 |
+
// change makes this component write directly, that property is gone and nothing will report it.
|
| 12 |
+
//
|
| 13 |
+
// β AND `dropped` IS RENDERED, ALWAYS. The route runs the real save-door validator over the
|
| 14 |
+
// model's answer and diffs it, because `clean_actions` discards an unrecognised config key and
|
| 15 |
+
// still answers 200. Every entry is something the reader was SHOWN that will not survive the
|
| 16 |
+
// save. Hiding it would make the Canvas disagree with the transcript that produced it, which is
|
| 17 |
+
// the exact complaint D-75 was opened for.
|
| 18 |
+
// ---------------------------------------------------------------------------
|
| 19 |
+
import { useCallback, useRef, useState } from "react";
|
| 20 |
+
|
| 21 |
+
import type { AutomationDraft } from "./automationApi";
|
| 22 |
+
import { AutomationError, draftAutomation } from "./automationApi";
|
| 23 |
+
import "./automation.css";
|
| 24 |
+
|
| 25 |
+
/**
|
| 26 |
+
* One turn. `error` is a KIND of message rather than a separate banner, because a failed turn is
|
| 27 |
+
* part of the conversation a person is having: they will rephrase and try again, and a toast that
|
| 28 |
+
* has already faded cannot be re-read while they do.
|
| 29 |
+
*/
|
| 30 |
+
interface Turn {
|
| 31 |
+
who: "you" | "agent";
|
| 32 |
+
text: string;
|
| 33 |
+
error?: boolean;
|
| 34 |
+
dropped?: string[];
|
| 35 |
+
}
|
| 36 |
+
|
| 37 |
+
export interface AutomationChatProps {
|
| 38 |
+
/** Applied to the Canvas when the reader accepts. Returns once the write has been attempted. */
|
| 39 |
+
onAccept: (draft: AutomationDraft["draft"]) => Promise<void> | void;
|
| 40 |
+
/** The automation being edited, for the empty state's sentence. */
|
| 41 |
+
name?: string;
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
/** How many steps a draft may carry before the accept bar states the number rather than implying
|
| 45 |
+
* it. Not a limit β a limit here would be a second, quieter copy of the server's. */
|
| 46 |
+
const MANY_STEPS = 6;
|
| 47 |
+
|
| 48 |
+
export default function AutomationChat({ onAccept, name }: AutomationChatProps) {
|
| 49 |
+
const [turns, setTurns] = useState<Turn[]>([]);
|
| 50 |
+
const [text, setText] = useState("");
|
| 51 |
+
const [busy, setBusy] = useState(false);
|
| 52 |
+
const [pending, setPending] = useState<AutomationDraft["draft"] | null>(null);
|
| 53 |
+
const [applying, setApplying] = useState(false);
|
| 54 |
+
const logRef = useRef<HTMLDivElement | null>(null);
|
| 55 |
+
|
| 56 |
+
const scrollDown = useCallback(() => {
|
| 57 |
+
// The transcript grows at the bottom, so a reader who has just sent something expects to be
|
| 58 |
+
// there. `requestAnimationFrame` because the row is appended in the same commit.
|
| 59 |
+
requestAnimationFrame(() => {
|
| 60 |
+
const el = logRef.current;
|
| 61 |
+
if (el) el.scrollTop = el.scrollHeight;
|
| 62 |
+
});
|
| 63 |
+
}, []);
|
| 64 |
+
|
| 65 |
+
const ask = useCallback(async () => {
|
| 66 |
+
const prompt = text.trim();
|
| 67 |
+
if (!prompt || busy) return;
|
| 68 |
+
/*
|
| 69 |
+
* β THE USER'S LINE GOES UP FIRST AND THE COMPOSER CLEARS FIRST. Waiting for the server to
|
| 70 |
+
* echo it is what makes a chat feel broken: the reader presses enter, the text sits in the
|
| 71 |
+
* box, and they press it again. Same reasoning as R16's clause for the Assistant.
|
| 72 |
+
* β The optimistic row is never RECONCILED away on failure β it genuinely happened, they
|
| 73 |
+
* genuinely said it β so the failure arrives as the NEXT turn rather than by deleting theirs.
|
| 74 |
+
*/
|
| 75 |
+
setTurns((t) => [...t, { who: "you", text: prompt }]);
|
| 76 |
+
setText("");
|
| 77 |
+
setBusy(true);
|
| 78 |
+
scrollDown();
|
| 79 |
+
try {
|
| 80 |
+
const res = await draftAutomation(prompt);
|
| 81 |
+
const actions = res.draft?.actions || [];
|
| 82 |
+
const label = res.draft?.name ? `"${res.draft.name}"` : "a flow";
|
| 83 |
+
setTurns((t) => [...t, {
|
| 84 |
+
who: "agent",
|
| 85 |
+
text: actions.length
|
| 86 |
+
? `I drafted ${label} with ${actions.length} step${actions.length === 1 ? "" : "s"}. `
|
| 87 |
+
+ "Nothing is saved yet. Read it below and put it on the Canvas if it is right."
|
| 88 |
+
: `I could not turn that into steps. Try naming the database and what should happen.`,
|
| 89 |
+
// β BOTH CHANNELS, and they are different questions. `dropped` is what the SAVE-DOOR
|
| 90 |
+
// validator discarded from the model's answer; `notes` is what the ROUTE corrected or
|
| 91 |
+
// could not keep (a trigger this deployment does not have, steps past the draft cap).
|
| 92 |
+
// Rendering only one of them shows a person half of what changed under them.
|
| 93 |
+
// β `dropped` IS `{kind, keys}`, NOT A SENTENCE. Spreading it into a string list
|
| 94 |
+
// compiled and would have painted `[object Object]` on the one line that exists to say
|
| 95 |
+
// what changed under the reader. It is turned into words HERE, once.
|
| 96 |
+
...(res.dropped?.length || res.notes?.length
|
| 97 |
+
? {
|
| 98 |
+
dropped: [
|
| 99 |
+
...(res.dropped || []).map(
|
| 100 |
+
(d) => `on the ${d.kind} step: ${(d.keys || []).join(", ")}`
|
| 101 |
+
),
|
| 102 |
+
...(res.notes || []),
|
| 103 |
+
],
|
| 104 |
+
}
|
| 105 |
+
: {}),
|
| 106 |
+
}]);
|
| 107 |
+
setPending(actions.length ? res.draft : null);
|
| 108 |
+
} catch (e) {
|
| 109 |
+
/*
|
| 110 |
+
* β THE SERVER'S OWN SENTENCE, NEVER A GENERIC ONE. The route answers 400 with one plain
|
| 111 |
+
* sentence saying WHY it refused (no prompt, nothing draftable, a kind this deployment
|
| 112 |
+
* cannot run) and that sentence is the only useful thing on the screen. Replacing it with
|
| 113 |
+
* "something went wrong" would throw away the one part a person can act on.
|
| 114 |
+
*/
|
| 115 |
+
setTurns((t) => [...t, {
|
| 116 |
+
who: "agent",
|
| 117 |
+
error: true,
|
| 118 |
+
text: e instanceof AutomationError
|
| 119 |
+
? e.message
|
| 120 |
+
: "The agent service did not answer. Try again in a moment.",
|
| 121 |
+
}]);
|
| 122 |
+
setPending(null);
|
| 123 |
+
} finally {
|
| 124 |
+
setBusy(false);
|
| 125 |
+
scrollDown();
|
| 126 |
+
}
|
| 127 |
+
}, [text, busy, scrollDown]);
|
| 128 |
+
|
| 129 |
+
const accept = useCallback(async () => {
|
| 130 |
+
if (!pending || applying) return;
|
| 131 |
+
setApplying(true);
|
| 132 |
+
try {
|
| 133 |
+
await onAccept(pending);
|
| 134 |
+
setPending(null);
|
| 135 |
+
setTurns((t) => [...t, {
|
| 136 |
+
who: "agent",
|
| 137 |
+
text: "Put it on the Canvas. Edit it there like any other agent.",
|
| 138 |
+
}]);
|
| 139 |
+
} catch (e) {
|
| 140 |
+
setTurns((t) => [...t, {
|
| 141 |
+
who: "agent",
|
| 142 |
+
error: true,
|
| 143 |
+
text: e instanceof AutomationError ? e.message : "That draft could not be applied.",
|
| 144 |
+
}]);
|
| 145 |
+
} finally {
|
| 146 |
+
setApplying(false);
|
| 147 |
+
scrollDown();
|
| 148 |
+
}
|
| 149 |
+
}, [pending, applying, onAccept, scrollDown]);
|
| 150 |
+
|
| 151 |
+
const steps = pending?.actions?.length || 0;
|
| 152 |
+
|
| 153 |
+
return (
|
| 154 |
+
<div className="autoc-chat">
|
| 155 |
+
<div className="autoc-log" ref={logRef}>
|
| 156 |
+
{!turns.length ? (
|
| 157 |
+
<p className="autoc-empty">
|
| 158 |
+
Describe what {name ? `"${name}"` : "this agent"} should do and the assistant will
|
| 159 |
+
draft the steps. Name the database and what should happen, for example: when a new row
|
| 160 |
+
is added, look up its website and fill in the company size.
|
| 161 |
+
</p>
|
| 162 |
+
) : null}
|
| 163 |
+
{turns.map((t, i) => (
|
| 164 |
+
<div key={i} className="autoc-turn">
|
| 165 |
+
<div
|
| 166 |
+
className={
|
| 167 |
+
"autoc-msg " + (t.error ? "is-error" : t.who === "you" ? "is-you" : "is-agent")
|
| 168 |
+
}
|
| 169 |
+
>
|
| 170 |
+
{t.text}
|
| 171 |
+
</div>
|
| 172 |
+
{/* R6's second sentence, on screen: what the validator would not keep, named. */}
|
| 173 |
+
{t.dropped?.length ? (
|
| 174 |
+
<div className="autoc-dropped">
|
| 175 |
+
Some of what it asked for will not be kept:
|
| 176 |
+
<ul>
|
| 177 |
+
{t.dropped.map((d, j) => (
|
| 178 |
+
<li key={j}>{d}</li>
|
| 179 |
+
))}
|
| 180 |
+
</ul>
|
| 181 |
+
</div>
|
| 182 |
+
) : null}
|
| 183 |
+
</div>
|
| 184 |
+
))}
|
| 185 |
+
{busy ? (
|
| 186 |
+
<div className="autoc-pending" role="status">
|
| 187 |
+
{/* β `.lp-spin` is the ONE loading mark in this application and it is purple as of
|
| 188 |
+
this wave. A second spinner vocabulary in a new surface is how an app stops
|
| 189 |
+
having one. */}
|
| 190 |
+
<span className="lp-spin" aria-hidden="true" />
|
| 191 |
+
Drafting the steps
|
| 192 |
+
</div>
|
| 193 |
+
) : null}
|
| 194 |
+
</div>
|
| 195 |
+
|
| 196 |
+
{pending ? (
|
| 197 |
+
<div className="autoc-accept">
|
| 198 |
+
<span className="autoc-accept-note">
|
| 199 |
+
{steps} step{steps === 1 ? "" : "s"} drafted
|
| 200 |
+
{steps > MANY_STEPS ? ", which is a long flow. Read it before you accept." : "."}{" "}
|
| 201 |
+
Nothing is saved until you put it on the Canvas.
|
| 202 |
+
</span>
|
| 203 |
+
<button
|
| 204 |
+
type="button"
|
| 205 |
+
className="auto-btn"
|
| 206 |
+
disabled={applying}
|
| 207 |
+
onClick={() => setPending(null)}
|
| 208 |
+
>
|
| 209 |
+
Discard
|
| 210 |
+
</button>
|
| 211 |
+
<button
|
| 212 |
+
type="button"
|
| 213 |
+
className="auto-btn is-primary"
|
| 214 |
+
disabled={applying}
|
| 215 |
+
onClick={() => void accept()}
|
| 216 |
+
>
|
| 217 |
+
{applying ? "Adding" : "Put it on the Canvas"}
|
| 218 |
+
</button>
|
| 219 |
+
</div>
|
| 220 |
+
) : null}
|
| 221 |
+
|
| 222 |
+
<div className="autoc-compose">
|
| 223 |
+
<textarea
|
| 224 |
+
className="autoc-input"
|
| 225 |
+
rows={2}
|
| 226 |
+
value={text}
|
| 227 |
+
placeholder="Describe what this agent should do"
|
| 228 |
+
aria-label="Describe what this agent should do"
|
| 229 |
+
disabled={busy}
|
| 230 |
+
onChange={(e) => setText(e.target.value)}
|
| 231 |
+
onKeyDown={(e) => {
|
| 232 |
+
// Enter sends; shift+enter is a newline. The composer is two rows tall because these
|
| 233 |
+
// descriptions are sentences, not search terms.
|
| 234 |
+
if (e.key === "Enter" && !e.shiftKey) {
|
| 235 |
+
e.preventDefault();
|
| 236 |
+
void ask();
|
| 237 |
+
}
|
| 238 |
+
}}
|
| 239 |
+
/>
|
| 240 |
+
<button
|
| 241 |
+
type="button"
|
| 242 |
+
className="auto-btn is-primary"
|
| 243 |
+
disabled={busy || !text.trim()}
|
| 244 |
+
onClick={() => void ask()}
|
| 245 |
+
>
|
| 246 |
+
Send
|
| 247 |
+
</button>
|
| 248 |
+
</div>
|
| 249 |
+
</div>
|
| 250 |
+
);
|
| 251 |
+
}
|
web/src/automation/AutomationDetail.tsx
CHANGED
|
@@ -23,7 +23,18 @@
|
|
| 23 |
import { useCallback, useEffect, useState } from "react";
|
| 24 |
|
| 25 |
import AutomationBuilder from "./AutomationBuilder";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
import AutomationFind from "./AutomationFind";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
import AutomationTrigger from "./AutomationTrigger";
|
| 28 |
import type {
|
| 29 |
ActionCatalogRow,
|
|
@@ -338,6 +349,42 @@ export default function AutomationDetail({
|
|
| 338 |
* setting, and `aria-pressed` on each would tell a screen reader about two independent
|
| 339 |
* toggles that can both be off β a state this control cannot express.
|
| 340 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 341 |
const panelTabs = (
|
| 342 |
<div className="auto-paneltabs" role="radiogroup" aria-label="Right-hand panel">
|
| 343 |
{([["properties", "Properties"], ["runs", "Run history"]] as const).map(([k, label]) => (
|
|
@@ -556,7 +603,7 @@ export default function AutomationDetail({
|
|
| 556 |
const heldNote = (): string => {
|
| 557 |
const held = heldCount();
|
| 558 |
if (held < 1) return "";
|
| 559 |
-
return `Saved
|
| 560 |
+ `${held === 1 ? "it needs" : "they need"} a value.`;
|
| 561 |
};
|
| 562 |
|
|
@@ -815,7 +862,7 @@ export default function AutomationDetail({
|
|
| 815 |
setMessage(
|
| 816 |
held < 1
|
| 817 |
? "Started. The node dots follow it."
|
| 818 |
-
: `Started
|
| 819 |
+ `${held === 1 ? "needs" : "need"} a value.`
|
| 820 |
);
|
| 821 |
await onSaved(automation.id);
|
|
@@ -1127,7 +1174,9 @@ export default function AutomationDetail({
|
|
| 1127 |
</select>
|
| 1128 |
</div>
|
| 1129 |
<div className="auto-field">
|
| 1130 |
-
|
|
|
|
|
|
|
| 1131 |
<select
|
| 1132 |
id="auto-fieldkey"
|
| 1133 |
className="auto-input"
|
|
@@ -1306,9 +1355,7 @@ export default function AutomationDetail({
|
|
| 1306 |
automation's. See the dated amendment under C-DETAIL: the contract's premise was wrong
|
| 1307 |
on that point and the consequence (no name in the Board view) is booked, not patched.
|
| 1308 |
*/}
|
| 1309 |
-
<div className="auto-head-title">
|
| 1310 |
-
<span className="autox-view is-on">Builder</span>
|
| 1311 |
-
</div>
|
| 1312 |
<div className="auto-head-actions">
|
| 1313 |
{/*
|
| 1314 |
β W24-W1 β WHAT IT IS DOING, beside the button that started it. This is the whole of
|
|
@@ -1352,7 +1399,23 @@ export default function AutomationDetail({
|
|
| 1352 |
>
|
| 1353 |
{saving ? "Savingβ¦" : "Save"}
|
| 1354 |
</button>
|
| 1355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1356 |
Delete
|
| 1357 |
</button>
|
| 1358 |
</div>
|
|
@@ -1382,6 +1445,38 @@ export default function AutomationDetail({
|
|
| 1382 |
engine's own steps AND what the owner added, from data the rail already had) and there
|
| 1383 |
is no longer a state in which the reader gets it by accident.
|
| 1384 |
*/}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1385 |
<>
|
| 1386 |
<AutomationBuilder
|
| 1387 |
automation={automation}
|
|
@@ -1491,15 +1586,51 @@ export default function AutomationDetail({
|
|
| 1491 |
answers "how many" and the summary answers "roughly what"; neither
|
| 1492 |
could ever answer "why", which is the only question somebody opens this
|
| 1493 |
panel with. These are the vendor's own sentences, not our paraphrase.
|
| 1494 |
-
|
| 1495 |
-
|
| 1496 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1497 |
{r.notes?.length ? (
|
| 1498 |
-
|
| 1499 |
-
|
| 1500 |
-
|
| 1501 |
-
|
| 1502 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1503 |
) : null}
|
| 1504 |
{drillFor === r.ts && drill ? (
|
| 1505 |
<div className="auto-drill">
|
|
@@ -1539,6 +1670,7 @@ export default function AutomationDetail({
|
|
| 1539 |
</>
|
| 1540 |
</aside>
|
| 1541 |
</>
|
|
|
|
| 1542 |
</div>
|
| 1543 |
</div>
|
| 1544 |
);
|
|
|
|
| 23 |
import { useCallback, useEffect, useState } from "react";
|
| 24 |
|
| 25 |
import AutomationBuilder from "./AutomationBuilder";
|
| 26 |
+
// β WAVE 34 Β· W34-T41/T42 (R17) β the Chat view. A STATIC import, deliberately, unlike
|
| 27 |
+
// `AutomationSurface`'s `lazy()` of this file: that one exists because the editor is 88 KB behind
|
| 28 |
+
// a rail that must paint first, while this component is small and is reached by a tab click on a
|
| 29 |
+
// surface already loaded. A second lazy boundary here would buy a spinner, not a page.
|
| 30 |
+
import AutomationChat from "./AutomationChat";
|
| 31 |
import AutomationFind from "./AutomationFind";
|
| 32 |
+
// β IMPORTED HERE TOO, and not only by `AutomationChat`. This file's own run-log disclosure
|
| 33 |
+
// (`.autor-*`, W34-T45) lives in that sheet, and relying on a SIBLING component's import to pull
|
| 34 |
+
// it in would mean the rules arrive only while that sibling is in the bundle. Today it always is
|
| 35 |
+
// (a static import); the day somebody makes the chat lazy, the run log would silently lose its
|
| 36 |
+
// styling with nothing red anywhere. CSS imports are idempotent, so the cost is zero.
|
| 37 |
+
import "./automation.css";
|
| 38 |
import AutomationTrigger from "./AutomationTrigger";
|
| 39 |
import type {
|
| 40 |
ActionCatalogRow,
|
|
|
|
| 349 |
* setting, and `aria-pressed` on each would tell a screen reader about two independent
|
| 350 |
* toggles that can both be off β a state this control cannot express.
|
| 351 |
*/
|
| 352 |
+
/**
|
| 353 |
+
* ββ WAVE 34 Β· W34-T41 / R17 β CANVAS | CHAT, the surface's own view switch.
|
| 354 |
+
*
|
| 355 |
+
* Owner: *"The Agent module needs a Chat view beside the builder, where the user asks the AI to
|
| 356 |
+
* build the automation. Rename Builder to Canvas."*
|
| 357 |
+
*
|
| 358 |
+
* β THERE WAS NO SWITCH TO RENAME. `.auto-head-title` held a DEAD
|
| 359 |
+
* `<span className="autox-view is-on">Builder</span>` β no state, no handler, no second arm β
|
| 360 |
+
* left behind when `AutomationBoard.tsx` was deleted (its stub is still commented in
|
| 361 |
+
* `automation_engine.py`). The ticket's own `how:` calls it "a MODE inside AutomationSurface",
|
| 362 |
+
* which has not been true for two waves. So this is built, not renamed.
|
| 363 |
+
* β AND IT NEEDS NO NEW CSS. `.autox-views` was a complete rule with ZERO call sites and
|
| 364 |
+
* `.autox-view` (with `:hover`, `.is-on`, `:focus-visible`) had exactly one, the dead span β the
|
| 365 |
+
* strip was styled a wave before it was cancelled. Reusing it also means the two arms cannot
|
| 366 |
+
* drift from the rest of the module's chrome.
|
| 367 |
+
* β SAME RADIOGROUP REASONING as `panelTabs` below, for the same reason: two values of ONE
|
| 368 |
+
* setting, so `aria-pressed` on each would describe two toggles that can both be off.
|
| 369 |
+
*/
|
| 370 |
+
const [view, setView] = useState<"canvas" | "chat">("canvas");
|
| 371 |
+
const viewTabs = (
|
| 372 |
+
<div className="autox-views" role="radiogroup" aria-label="Editor view">
|
| 373 |
+
{([["canvas", "Canvas"], ["chat", "Chat"]] as const).map(([k, label]) => (
|
| 374 |
+
<button
|
| 375 |
+
key={k}
|
| 376 |
+
type="button"
|
| 377 |
+
role="radio"
|
| 378 |
+
aria-checked={view === k}
|
| 379 |
+
className={"autox-view" + (view === k ? " is-on" : "")}
|
| 380 |
+
onClick={() => setView(k)}
|
| 381 |
+
>
|
| 382 |
+
{label}
|
| 383 |
+
</button>
|
| 384 |
+
))}
|
| 385 |
+
</div>
|
| 386 |
+
);
|
| 387 |
+
|
| 388 |
const panelTabs = (
|
| 389 |
<div className="auto-paneltabs" role="radiogroup" aria-label="Right-hand panel">
|
| 390 |
{([["properties", "Properties"], ["runs", "Run history"]] as const).map(([k, label]) => (
|
|
|
|
| 603 |
const heldNote = (): string => {
|
| 604 |
const held = heldCount();
|
| 605 |
if (held < 1) return "";
|
| 606 |
+
return `Saved. ${held} condition${held === 1 ? " was" : "s were"} not saved yet, `
|
| 607 |
+ `${held === 1 ? "it needs" : "they need"} a value.`;
|
| 608 |
};
|
| 609 |
|
|
|
|
| 862 |
setMessage(
|
| 863 |
held < 1
|
| 864 |
? "Started. The node dots follow it."
|
| 865 |
+
: `Started. Running without ${held} condition${held === 1 ? "" : "s"} that still `
|
| 866 |
+ `${held === 1 ? "needs" : "need"} a value.`
|
| 867 |
);
|
| 868 |
await onSaved(automation.id);
|
|
|
|
| 1174 |
</select>
|
| 1175 |
</div>
|
| 1176 |
<div className="auto-field">
|
| 1177 |
+
{/* β WAVE 34 Β· R12 β "Automation column" became "Agent column". It names
|
| 1178 |
+
the column THIS one writes into, so it takes the singular. */}
|
| 1179 |
+
<label htmlFor="auto-fieldkey">Agent column</label>
|
| 1180 |
<select
|
| 1181 |
id="auto-fieldkey"
|
| 1182 |
className="auto-input"
|
|
|
|
| 1355 |
automation's. See the dated amendment under C-DETAIL: the contract's premise was wrong
|
| 1356 |
on that point and the consequence (no name in the Board view) is booked, not patched.
|
| 1357 |
*/}
|
| 1358 |
+
<div className="auto-head-title">{viewTabs}</div>
|
|
|
|
|
|
|
| 1359 |
<div className="auto-head-actions">
|
| 1360 |
{/*
|
| 1361 |
β W24-W1 β WHAT IT IS DOING, beside the button that started it. This is the whole of
|
|
|
|
| 1399 |
>
|
| 1400 |
{saving ? "Savingβ¦" : "Save"}
|
| 1401 |
</button>
|
| 1402 |
+
{/* ββ WAVE 34 Β· CONTRACTS C3 + C4 β A SYSTEM AGENT SAYS SO ON THE ITEM, NOT ON THE
|
| 1403 |
+
CLICK. The server refuses `409 system_agent` whatever this renders, so the wall is
|
| 1404 |
+
never the disabling β but leaving a live-looking Delete that answers with a telling
|
| 1405 |
+
off is the exact shape `InboxPage` records as a defect in its own words: "you pressed
|
| 1406 |
+
this and were told off" rather than "this is unavailable". The button carries the
|
| 1407 |
+
server's reason as its tooltip, so the explanation is in the same place as the
|
| 1408 |
+
refusal it explains. */}
|
| 1409 |
+
<button
|
| 1410 |
+
type="button"
|
| 1411 |
+
className="auto-btn is-danger"
|
| 1412 |
+
disabled={!!automation.system}
|
| 1413 |
+
{...(automation.system
|
| 1414 |
+
? { title: "This agent is part of the workspace setup. Open its column or its "
|
| 1415 |
+
+ "connector to change it." }
|
| 1416 |
+
: {})}
|
| 1417 |
+
onClick={() => void remove()}
|
| 1418 |
+
>
|
| 1419 |
Delete
|
| 1420 |
</button>
|
| 1421 |
</div>
|
|
|
|
| 1445 |
engine's own steps AND what the owner added, from data the rail already had) and there
|
| 1446 |
is no longer a state in which the reader gets it by accident.
|
| 1447 |
*/}
|
| 1448 |
+
{view === "chat" ? (
|
| 1449 |
+
/*
|
| 1450 |
+
* ββ WAVE 34 Β· W34-T42 / R17 β the Chat view, and it writes through the SAME door.
|
| 1451 |
+
*
|
| 1452 |
+
* β `onAccept` IS `writeDefinition`, the function the Canvas's own Save calls. It is not
|
| 1453 |
+
* "similar to" it: R3 asks for a drafted automation to be indistinguishable from a
|
| 1454 |
+
* hand-built one, and the only way to guarantee that is for there to be one write path.
|
| 1455 |
+
* `POST /automations/draft` deliberately saves nothing, so this is where a draft becomes
|
| 1456 |
+
* real, and it becomes real by the ordinary route.
|
| 1457 |
+
* β SWITCHING TABS UNMOUNTS THE BUILDER, and the cost is named rather than hidden: the
|
| 1458 |
+
* builder's own local state (which step card is selected, its scroll position) is lost on
|
| 1459 |
+
* the round trip. Everything a person has TYPED is safe β `name`, `touched`, the cron
|
| 1460 |
+
* text and every panel value are this component's state, held above both views, so a
|
| 1461 |
+
* half-edited name survives a visit to Chat and back. Keeping the builder mounted behind
|
| 1462 |
+
* `hidden` would preserve the selection too, at the price of two live editors of one
|
| 1463 |
+
* automation; the selection is the cheaper thing to lose.
|
| 1464 |
+
*/
|
| 1465 |
+
<AutomationChat
|
| 1466 |
+
name={automation.name}
|
| 1467 |
+
onAccept={async (draft) => {
|
| 1468 |
+
await writeDefinition({
|
| 1469 |
+
...(draft.name ? { name: draft.name } : {}),
|
| 1470 |
+
// β `draft.trigger` IS A BARE KEY STRING on this route, not `{key}`. Written the
|
| 1471 |
+
// other way it compiles, reads `undefined`, and every accepted flow lands with no
|
| 1472 |
+
// trigger and never fires, with nothing red anywhere. See the type's own note.
|
| 1473 |
+
...(draft.trigger ? { trigger: { key: draft.trigger } } : {}),
|
| 1474 |
+
flow: { actions: draft.actions || [] },
|
| 1475 |
+
});
|
| 1476 |
+
setView("canvas");
|
| 1477 |
+
}}
|
| 1478 |
+
/>
|
| 1479 |
+
) : (
|
| 1480 |
<>
|
| 1481 |
<AutomationBuilder
|
| 1482 |
automation={automation}
|
|
|
|
| 1586 |
answers "how many" and the summary answers "roughly what"; neither
|
| 1587 |
could ever answer "why", which is the only question somebody opens this
|
| 1588 |
panel with. These are the vendor's own sentences, not our paraphrase.
|
| 1589 |
+
|
| 1590 |
+
ββ WAVE 34 Β· W34-T45 / R21 β "Run logs are too wordy. Cut them down."
|
| 1591 |
+
MEASURED BEFORE CUTTING, because the ticket asks for that and because the
|
| 1592 |
+
obvious target was the wrong one: `summary` is capped at 400 chars and in
|
| 1593 |
+
practice reads "3 new, 2 updated", while `notes` is capped at 300 chars x
|
| 1594 |
+
25 entries = **up to 7,500 characters per run, rendered unconditionally,
|
| 1595 |
+
over MAX_RUNS = 20 runs**. The notes are the wordiness; the summary is
|
| 1596 |
+
not. Nothing was cut from the server.
|
| 1597 |
+
|
| 1598 |
+
β AND THE OLD COMMENT HERE WAS RIGHT, WHICH IS WHY THIS IS A SPLIT AND
|
| 1599 |
+
NOT A COLLAPSE. It read: *"a reason you have to go looking for is most of
|
| 1600 |
+
the way back to not having it"* β true of a run that FAILED or hit a cap,
|
| 1601 |
+
and the exact case R6's standing rule protects (a limit that cannot be
|
| 1602 |
+
removed must be REPORTED). It is not true of the twentieth consecutive
|
| 1603 |
+
successful run, whose notes nobody reads and which is what makes the
|
| 1604 |
+
panel a wall of text. So: still unconditional when it MATTERS, one click
|
| 1605 |
+
away when it does not.
|
| 1606 |
+
|
| 1607 |
+
β THE PREDICATE IS THE CODEBASE'S OWN LIMIT SIGNAL, NOT A TEXT HEURISTIC.
|
| 1608 |
+
`counts.capped` is already what the row above paints `is-warn` for, so
|
| 1609 |
+
"did this run hit a limit" has one answer in one place. Sniffing the note
|
| 1610 |
+
STRINGS for words like "limit" would be a second, quieter definition that
|
| 1611 |
+
drifts the first time a vendor rewords a sentence. */}
|
| 1612 |
{r.notes?.length ? (
|
| 1613 |
+
!r.ok || !!r.counts?.capped ? (
|
| 1614 |
+
<ul className="auto-run-notes">
|
| 1615 |
+
{r.notes.map((n, i) => (
|
| 1616 |
+
<li key={i}>{n}</li>
|
| 1617 |
+
))}
|
| 1618 |
+
</ul>
|
| 1619 |
+
) : (
|
| 1620 |
+
/* Native `<details>`: keyboard-reachable and screen-reader-announced for
|
| 1621 |
+
free, and the count is IN the summary so the disclosure says how much
|
| 1622 |
+
is behind it rather than being an anonymous chevron. */
|
| 1623 |
+
<details className="autor-notes">
|
| 1624 |
+
<summary className="autor-notes-toggle">
|
| 1625 |
+
{r.notes.length} note{r.notes.length === 1 ? "" : "s"}
|
| 1626 |
+
</summary>
|
| 1627 |
+
<ul className="auto-run-notes">
|
| 1628 |
+
{r.notes.map((n, i) => (
|
| 1629 |
+
<li key={i}>{n}</li>
|
| 1630 |
+
))}
|
| 1631 |
+
</ul>
|
| 1632 |
+
</details>
|
| 1633 |
+
)
|
| 1634 |
) : null}
|
| 1635 |
{drillFor === r.ts && drill ? (
|
| 1636 |
<div className="auto-drill">
|
|
|
|
| 1670 |
</>
|
| 1671 |
</aside>
|
| 1672 |
</>
|
| 1673 |
+
)}
|
| 1674 |
</div>
|
| 1675 |
</div>
|
| 1676 |
);
|
web/src/automation/AutomationFind.tsx
CHANGED
|
@@ -589,7 +589,7 @@ export default function AutomationFind({
|
|
| 589 |
title={
|
| 590 |
cats.length
|
| 591 |
? "Values this workspace has actually seen, most-seen first. Adds to the list."
|
| 592 |
-
: "No category values have been seen in your data yet
|
| 593 |
}
|
| 594 |
value=""
|
| 595 |
disabled={!cats.length}
|
|
|
|
| 589 |
title={
|
| 590 |
cats.length
|
| 591 |
? "Values this workspace has actually seen, most-seen first. Adds to the list."
|
| 592 |
+
: "No category values have been seen in your data yet. Type one."
|
| 593 |
}
|
| 594 |
value=""
|
| 595 |
disabled={!cats.length}
|
web/src/automation/AutomationSurface.tsx
CHANGED
|
@@ -1,806 +1,814 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// automation/AutomationSurface.tsx β the Automation surface (contract C-AUTONAV).
|
| 3 |
-
//
|
| 4 |
-
// β THE CONTRACT THIS FILE GUARANTEES, verbatim: this module path, a DEFAULT
|
| 5 |
-
// export, and NO PROPS. The shell mounts `<AutomationSurface />` and hands it
|
| 6 |
-
// nothing; it fetches its own data from `/api/v1/automations`. That is the whole
|
| 7 |
-
// interface, and it is stated here because the mount line lives in a file this
|
| 8 |
-
// session does not own β a named export would cost a cross-session round trip.
|
| 9 |
-
//
|
| 10 |
-
// "Automation replaces the Views rail" (owner) is implemented the way the wave
|
| 11 |
-
// scout established: the secondary rail is NOT a shell concept β each surface
|
| 12 |
-
// owns its own. So this renders a sibling of `.cg-views` built from the same
|
| 13 |
-
// `--lp-rail-w` tokens and the same fold behaviour, and the Views rail is
|
| 14 |
-
// untouched.
|
| 15 |
-
// ---------------------------------------------------------------------------
|
| 16 |
-
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
| 17 |
-
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
|
| 18 |
-
|
| 19 |
-
import type { AutomationOpenDetail } from "../apiContract";
|
| 20 |
-
import { AUTOMATION_OPEN_EVENT } from "../apiContract";
|
| 21 |
-
// β WAVE 26 Β· ITEM 18 / R14 + C7 β THE LOOPABLE LOOP MARK, CONSUMED, NEVER REDRAWN.
|
| 22 |
-
// The owner's reason is the mark's own geometry: *"these automations are basically loops."*
|
| 23 |
-
// C7 says consume the existing icon component and do not inline a new SVG path, and
|
| 24 |
-
// `shell/Brand.tsx`'s header says why in more detail than a rule could: the mark's one source of
|
| 25 |
-
// truth is generated, it has already been hand-redrawn once, and the copy silently painted LAST
|
| 26 |
-
// WAVE'S BRAND while a comment asserted parity. So this imports the component that POINTS at the
|
| 27 |
-
// generated artifact. [[loopable-nav-logo-toggle]] β one element paints the mark.
|
| 28 |
-
import { Mark } from "../shell/Brand";
|
| 29 |
-
import type { Automation, AutomationList } from "./automationApi";
|
| 30 |
-
import {
|
| 31 |
-
AutomationError,
|
| 32 |
-
cachedAutomations,
|
| 33 |
-
createAutomation,
|
| 34 |
-
listAutomations,
|
| 35 |
-
liveStepOf,
|
| 36 |
-
patchAutomation,
|
| 37 |
-
rememberAutomation,
|
| 38 |
-
} from "./automationApi";
|
| 39 |
-
|
| 40 |
-
/**
|
| 41 |
-
* ββ WAVE 30 Β· T21 (owner items 4 + 5) β THE EDITOR STOPS SHIPPING INSIDE THE LIST.
|
| 42 |
-
*
|
| 43 |
-
* β THIS `lazy()` IS THE TICKET. `AutomationDetail` was a STATIC import, and it statically pulls
|
| 44 |
-
* `AutomationBuilder` (which in turn pulls `CondBuilder`, `PresetPlan` and `TriggerPicker`),
|
| 45 |
-
* `AutomationFind` and `AutomationTrigger` β so the whole editor was linked into the one chunk a
|
| 46 |
-
* person must download before the RAIL can paint a list of names. MEASURED at 88,771 B, against
|
| 47 |
-
* `HomePage` at 4,814 and `ConnectorsPage` at 3,551. That is the "Automation takes forever to
|
| 48 |
-
* load" complaint, and it had survived two waves because nothing asserted a number.
|
| 49 |
-
*
|
| 50 |
-
* β AND IT NEEDS ITS OWN `<Suspense>`, NOT `Shell.tsx`'s. `Lazily` already wraps this whole
|
| 51 |
-
* surface, but a Suspense boundary catches ANY suspending descendant β so without an inner
|
| 52 |
-
* boundary, opening an automation would suspend the boundary that owns the RAIL and blank the
|
| 53 |
-
* list you just clicked in. The inner one keeps the rail painted while the editor streams in.
|
| 54 |
-
* `fallback={null}` is deliberate and is `Lazily`'s own choice, not a shortcut: the work area is
|
| 55 |
-
* simply not there for the moment the chunk is in flight, which is what every other route in this
|
| 56 |
-
* product already does. β A skeleton here would be a NEW loading screen in the same wave that
|
| 57 |
-
* exists to remove one (T22).
|
| 58 |
-
* β Default export, checked: `Shell.tsx:53` records that `lazy()` over a NAMED export fails at
|
| 59 |
-
* runtime, on click, and only for that one surface β `AutomationDetail` exports default.
|
| 60 |
-
*/
|
| 61 |
-
const AutomationDetail = lazy(() => import("./AutomationDetail"));
|
| 62 |
-
|
| 63 |
-
/**
|
| 64 |
-
* The run poll's cadence (item 6). Named constants because they are a MEASURED trade-off, not a
|
| 65 |
-
* taste: `POLL_MAX_MS` is the longest a finished run can still look live, and it is the only
|
| 66 |
-
* cost the backoff has.
|
| 67 |
-
*/
|
| 68 |
-
const POLL_MIN_MS = 2500;
|
| 69 |
-
const POLL_MAX_MS = 8000;
|
| 70 |
-
|
| 71 |
-
/*
|
| 72 |
-
* β WAVE 26 Β· ITEM 18 / R14 β `stateTitle` STOOD HERE AND IS DELETED WITH THE DOT IT DESCRIBED.
|
| 73 |
-
*
|
| 74 |
-
* It composed the dot's tooltip (
|
| 75 |
-
* exactly one reader and no reason to outlive it. Deleting the render and keeping the composer is
|
| 76 |
-
* how a file accumulates functions that look live and are not β and `noUnusedLocals` would have
|
| 77 |
-
* caught this one, which is not a reason to lean on it: the NEXT such function might still have a
|
| 78 |
-
* second caller and compile fine.
|
| 79 |
-
*
|
| 80 |
-
* β WHY THE TOOLTIP WAS NOT MOVED ONTO THE MARK INSTEAD. R14 is "same mark on every automation;
|
| 81 |
-
* no status colour". Hanging "Last run failed" off a mark that is deliberately status-free just
|
| 82 |
-
* moves the status channel into `title`/`aria-label`, where it is worse: invisible to the eye,
|
| 83 |
-
* announced to a screen reader, and contradicting the visual. The run state has a home one click
|
| 84 |
-
* away β the Builder's own "Last run succeeded / failed" lines, which R13 explicitly keeps.
|
| 85 |
-
*
|
| 86 |
-
* β `stateOf` ITSELF IS UNTOUCHED in `automationApi.ts`. `Shell.tsx:64/1095` imports it and
|
| 87 |
-
* `liveStepOf` calls it β deleting the export to tidy up this file would break a fence I do not own.
|
| 88 |
-
*/
|
| 89 |
-
|
| 90 |
-
/**
|
| 91 |
-
* The rail's second line β ONE fact, not two.
|
| 92 |
-
*
|
| 93 |
-
* β It carried "Next 2026-08-04 06:00 Β· last 2026-08-04 00:20", and the rail is `--lp-rail-w`
|
| 94 |
-
* wide, so it rendered as "Next 2026-08-04 06:00 Β· lβ¦" β a second line whose only complete word
|
| 95 |
-
* was "Next". Two facts that both truncate are worth less than one that fits. The forward-looking
|
| 96 |
-
* one wins when a schedule exists (it is the question the rail answers: does this run itself?),
|
| 97 |
-
* the last run otherwise. The full picture is one click away in the editor's run history, and the
|
| 98 |
-
* row's `title` already says both.
|
| 99 |
-
*
|
| 100 |
-
* Caught by READING the screenshot: the DOM was correct and the CSS ellipsis was doing exactly
|
| 101 |
-
* its job. [[ui-invisible-to-assertions]] β judge the pixels even when everything is green.
|
| 102 |
-
*/
|
| 103 |
-
function railSubtitle(a: Automation): string {
|
| 104 |
-
/*
|
| 105 |
-
* β WAVE 24 (owner item 6, "Run once now is laggy / looks stuck") β WHILE IT IS RUNNING,
|
| 106 |
-
* THE ONE FACT WORTH THE LINE IS WHAT IT IS DOING.
|
| 107 |
-
*
|
| 108 |
-
* MEASURED, not guessed (`scratchpad/perf_automations.py`, mailbox D-5): the engine has been
|
| 109 |
-
* publishing a live step the whole time (`status.step`, `routes_automation.py:50-51`) and NO
|
| 110 |
-
* SURFACE HAS EVER RENDERED IT. So a `discover_instagram` run parked in its legitimate
|
| 111 |
-
* `BD_FILTER_WAIT = 120 s` vendor wait and a genuinely hung thread were pixel-identical: a
|
| 112 |
-
* pulsing blue dot and a subtitle still reciting the schedule. "Looks stuck" was not a
|
| 113 |
-
* performance problem β the poll costs 23 ms β it was the product declining to say.
|
| 114 |
-
*
|
| 115 |
-
* It takes the line rather than joining it, for the reason this function's note already
|
| 116 |
-
* gives: the rail is `--lp-rail-w` wide and two facts that both truncate are worth less than
|
| 117 |
-
* one that fits. The schedule is still one click away and the dot's `title` carries both.
|
| 118 |
-
*/
|
| 119 |
-
const step = liveStepOf(a);
|
| 120 |
-
if (step) return step;
|
| 121 |
-
if (a.schedule?.enabled) return a.nextRunAt ? `Next ${a.nextRunAt}` : "Scheduled";
|
| 122 |
-
/*
|
| 123 |
-
* β WAVE 26 Β· ITEM 19 / R13 β `Last run 2026-08-06 13:58` IS DELETED FROM THIS LINE.
|
| 124 |
-
*
|
| 125 |
-
* β SCOPE, because R13 draws a line that is easy to over-read: the ruling names THIS secondary
|
| 126 |
-
* line under the automation's name. The Builder's "Last run succeeded / failed" RESULT lines
|
| 127 |
-
* (`AutomationBuilder.tsx:1676`, `:1679`) are a different surface and are explicitly NOT in
|
| 128 |
-
* scope β they are the answer to "Run once now", so deleting them would leave a button with no
|
| 129 |
-
* outcome.
|
| 130 |
-
*
|
| 131 |
-
* "Manual only" STAYS, and the distinction is the ruling's own: a dated run record is a
|
| 132 |
-
* changing FACT ABOUT THE PAST, while "this one has no schedule" is a stable property of the
|
| 133 |
-
* automation β the same question `Next β¦`/`Scheduled` answers for its siblings. Dropping it too
|
| 134 |
-
* would leave a manual automation with a blank second line and nothing saying why.
|
| 135 |
-
*/
|
| 136 |
-
return "Manual only";
|
| 137 |
-
}
|
| 138 |
-
|
| 139 |
-
/*
|
| 140 |
-
* β W23-W5's LISTENER IS AT MODULE SCOPE, AND THAT IS THE POINT β not a stylistic choice.
|
| 141 |
-
*
|
| 142 |
-
* The frame's click-through sets `window.location.hash = "#/automation"` and signals on the
|
| 143 |
-
* VERY NEXT LINE (Shell.tsx:1647-1650, and its comment is right that the order matters). But a
|
| 144 |
-
* hash write does not mount anything synchronously: `hashchange` is delivered as a task, the
|
| 145 |
-
* router state then updates, React renders, and only THEN does a component effect subscribe.
|
| 146 |
-
* A listener registered in `useEffect` therefore misses every click that arrives from another
|
| 147 |
-
* page β which is every click, since a reader looking at Alerts is by definition not already
|
| 148 |
-
* on this surface. The event would dispatch into nothing and every gate would stay green:
|
| 149 |
-
* exactly the wave-20 item-25 failure the contract's own note describes, reproduced one layer
|
| 150 |
-
* down.
|
| 151 |
-
*
|
| 152 |
-
* This module is imported statically by the shell, so this listener exists from app start. It
|
| 153 |
-
* LATCHES the request; the component consumes the latch when it mounts and hears live events
|
| 154 |
-
* while it is mounted. A request nobody claims is dropped on the next one β the latch is a
|
| 155 |
-
* one-slot mailbox, never a queue.
|
| 156 |
-
*/
|
| 157 |
-
let pendingOpen: AutomationOpenDetail | null = null;
|
| 158 |
-
const openSubscribers = new Set<(detail: AutomationOpenDetail) => void>();
|
| 159 |
-
|
| 160 |
-
if (typeof window !== "undefined") {
|
| 161 |
-
window.addEventListener(AUTOMATION_OPEN_EVENT, (event) => {
|
| 162 |
-
const detail = (event as CustomEvent<AutomationOpenDetail>).detail;
|
| 163 |
-
if (!detail?.autoId) return;
|
| 164 |
-
if (openSubscribers.size) {
|
| 165 |
-
for (const notify of openSubscribers) notify(detail);
|
| 166 |
-
return;
|
| 167 |
-
}
|
| 168 |
-
pendingOpen = detail;
|
| 169 |
-
});
|
| 170 |
-
}
|
| 171 |
-
|
| 172 |
-
/*
|
| 173 |
-
* β THE `AUTOMATION_CREATE_EVENT` LISTENER STOOD HERE AND IS DELETED WITH ITS SIGNALLER
|
| 174 |
-
* (wave 25 item 5a, ruling R8) β latch, subscriber set and all.
|
| 175 |
-
*
|
| 176 |
-
* Wave 24 built it to close the opposite defect: the event was declared, signalled from
|
| 177 |
-
* `Shell.tsx`, and consumed NOWHERE, so all three "Automated database" doors navigated to
|
| 178 |
-
* `#/automation` and then did nothing, in production, with every gate green. R8 now deletes those
|
| 179 |
-
* three doors β creating a database and pointing an automation at it are two acts β and they were
|
| 180 |
-
* the event's only signaller.
|
| 181 |
-
*
|
| 182 |
-
* β SO THIS SIDE GOES TOO, IN THE SAME CHANGE. A listener with no signaller is the same defect
|
| 183 |
-
* read from the other end: it compiles, it costs nothing at runtime, and it reads to the next
|
| 184 |
-
* person as a live channel. The rule that catches both is now DERIVED rather than remembered β
|
| 185 |
-
* `verify_automation_ui.py` enumerates every `AUTOMATION_*_EVENT` in `apiContract.ts` and demands
|
| 186 |
-
* a signal site AND a listener site for each.
|
| 187 |
-
*
|
| 188 |
-
* β NOTHING ABOUT CREATING AN AUTOMATION IS LOST. `createAndOpen` is untouched and still has two
|
| 189 |
-
* doors, both on this surface: the rail's button and the empty state's. What is gone is the claim
|
| 190 |
-
* that a database can be created BY asking for an automation.
|
| 191 |
-
*/
|
| 192 |
-
|
| 193 |
-
/**
|
| 194 |
-
* A default name for a brand-new automation, and it has to be one the server ACCEPTS.
|
| 195 |
-
*
|
| 196 |
-
* MEASURED against the live route rather than assumed (`scratchpad/probe_names.py`): a create
|
| 197 |
-
* with no name is a 400 (`name the automation`), and duplicate names are ALLOWED. So the name
|
| 198 |
-
* cannot be omitted, and a fixed literal would stack "New automation" three deep in a rail that
|
| 199 |
-
* sorts by name with nothing to tell the rows apart. Numbering from the existing list is a
|
| 200 |
-
* client-side convenience over a server that does not care: if two tabs race, the loser gets a
|
| 201 |
-
* duplicate name, which is legal, visible and renameable β never an error the user has to read.
|
| 202 |
-
*/
|
| 203 |
-
function nextAutomationName(existing: Automation[]): string {
|
| 204 |
-
const taken = new Set(existing.map((a) => (a.name || "").trim().toLowerCase()));
|
| 205 |
-
for (let n = existing.length + 1; ; n += 1) {
|
| 206 |
-
|
| 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 |
-
*
|
| 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 |
-
|
| 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 |
-
//
|
| 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 |
-
|
| 513 |
-
|
| 514 |
-
|
| 515 |
-
|
| 516 |
-
|
| 517 |
-
const
|
| 518 |
-
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
| 523 |
-
|
| 524 |
-
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
}
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
| 536 |
-
|
| 537 |
-
|
| 538 |
-
|
| 539 |
-
event.
|
| 540 |
-
const
|
| 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 |
-
|
| 577 |
-
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
| 581 |
-
|
| 582 |
-
|
| 583 |
-
|
| 584 |
-
|
| 585 |
-
|
| 586 |
-
|
| 587 |
-
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
|
| 598 |
-
|
| 599 |
-
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
|
| 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 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
|
| 672 |
-
|
| 673 |
-
|
| 674 |
-
|
| 675 |
-
|
| 676 |
-
|
| 677 |
-
|
| 678 |
-
|
| 679 |
-
|
| 680 |
-
|
| 681 |
-
|
| 682 |
-
|
| 683 |
-
|
| 684 |
-
|
| 685 |
-
|
| 686 |
-
|
| 687 |
-
|
| 688 |
-
|
| 689 |
-
|
| 690 |
-
|
| 691 |
-
|
| 692 |
-
|
| 693 |
-
|
| 694 |
-
|
| 695 |
-
|
| 696 |
-
|
| 697 |
-
|
| 698 |
-
|
| 699 |
-
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
/
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
|
| 709 |
-
|
| 710 |
-
|
| 711 |
-
//
|
| 712 |
-
//
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
/
|
| 716 |
-
|
| 717 |
-
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
*
|
| 727 |
-
*
|
| 728 |
-
*
|
| 729 |
-
*
|
| 730 |
-
*
|
| 731 |
-
*
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
*
|
| 735 |
-
*
|
| 736 |
-
*
|
| 737 |
-
*
|
| 738 |
-
* the
|
| 739 |
-
*
|
| 740 |
-
*
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
|
| 746 |
-
|
| 747 |
-
|
| 748 |
-
|
| 749 |
-
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
|
| 753 |
-
|
| 754 |
-
|
| 755 |
-
|
| 756 |
-
|
| 757 |
-
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
*
|
| 773 |
-
*
|
| 774 |
-
*
|
| 775 |
-
*
|
| 776 |
-
*
|
| 777 |
-
*
|
| 778 |
-
*
|
| 779 |
-
|
| 780 |
-
|
| 781 |
-
|
| 782 |
-
|
| 783 |
-
|
| 784 |
-
|
| 785 |
-
|
| 786 |
-
|
| 787 |
-
|
| 788 |
-
|
| 789 |
-
|
| 790 |
-
|
| 791 |
-
|
| 792 |
-
|
| 793 |
-
|
| 794 |
-
|
| 795 |
-
|
| 796 |
-
|
| 797 |
-
|
| 798 |
-
>
|
| 799 |
-
|
| 800 |
-
</
|
| 801 |
-
|
| 802 |
-
|
| 803 |
-
|
| 804 |
-
|
| 805 |
-
|
| 806 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// automation/AutomationSurface.tsx β the Automation surface (contract C-AUTONAV).
|
| 3 |
+
//
|
| 4 |
+
// β THE CONTRACT THIS FILE GUARANTEES, verbatim: this module path, a DEFAULT
|
| 5 |
+
// export, and NO PROPS. The shell mounts `<AutomationSurface />` and hands it
|
| 6 |
+
// nothing; it fetches its own data from `/api/v1/automations`. That is the whole
|
| 7 |
+
// interface, and it is stated here because the mount line lives in a file this
|
| 8 |
+
// session does not own β a named export would cost a cross-session round trip.
|
| 9 |
+
//
|
| 10 |
+
// "Automation replaces the Views rail" (owner) is implemented the way the wave
|
| 11 |
+
// scout established: the secondary rail is NOT a shell concept β each surface
|
| 12 |
+
// owns its own. So this renders a sibling of `.cg-views` built from the same
|
| 13 |
+
// `--lp-rail-w` tokens and the same fold behaviour, and the Views rail is
|
| 14 |
+
// untouched.
|
| 15 |
+
// ---------------------------------------------------------------------------
|
| 16 |
+
import type { KeyboardEvent as ReactKeyboardEvent } from "react";
|
| 17 |
+
import { Suspense, lazy, useCallback, useEffect, useRef, useState } from "react";
|
| 18 |
+
|
| 19 |
+
import type { AutomationOpenDetail } from "../apiContract";
|
| 20 |
+
import { AUTOMATION_OPEN_EVENT } from "../apiContract";
|
| 21 |
+
// β WAVE 26 Β· ITEM 18 / R14 + C7 β THE LOOPABLE LOOP MARK, CONSUMED, NEVER REDRAWN.
|
| 22 |
+
// The owner's reason is the mark's own geometry: *"these automations are basically loops."*
|
| 23 |
+
// C7 says consume the existing icon component and do not inline a new SVG path, and
|
| 24 |
+
// `shell/Brand.tsx`'s header says why in more detail than a rule could: the mark's one source of
|
| 25 |
+
// truth is generated, it has already been hand-redrawn once, and the copy silently painted LAST
|
| 26 |
+
// WAVE'S BRAND while a comment asserted parity. So this imports the component that POINTS at the
|
| 27 |
+
// generated artifact. [[loopable-nav-logo-toggle]] β one element paints the mark.
|
| 28 |
+
import { Mark } from "../shell/Brand";
|
| 29 |
+
import type { Automation, AutomationList } from "./automationApi";
|
| 30 |
+
import {
|
| 31 |
+
AutomationError,
|
| 32 |
+
cachedAutomations,
|
| 33 |
+
createAutomation,
|
| 34 |
+
listAutomations,
|
| 35 |
+
liveStepOf,
|
| 36 |
+
patchAutomation,
|
| 37 |
+
rememberAutomation,
|
| 38 |
+
} from "./automationApi";
|
| 39 |
+
|
| 40 |
+
/**
|
| 41 |
+
* ββ WAVE 30 Β· T21 (owner items 4 + 5) β THE EDITOR STOPS SHIPPING INSIDE THE LIST.
|
| 42 |
+
*
|
| 43 |
+
* β THIS `lazy()` IS THE TICKET. `AutomationDetail` was a STATIC import, and it statically pulls
|
| 44 |
+
* `AutomationBuilder` (which in turn pulls `CondBuilder`, `PresetPlan` and `TriggerPicker`),
|
| 45 |
+
* `AutomationFind` and `AutomationTrigger` β so the whole editor was linked into the one chunk a
|
| 46 |
+
* person must download before the RAIL can paint a list of names. MEASURED at 88,771 B, against
|
| 47 |
+
* `HomePage` at 4,814 and `ConnectorsPage` at 3,551. That is the "Automation takes forever to
|
| 48 |
+
* load" complaint, and it had survived two waves because nothing asserted a number.
|
| 49 |
+
*
|
| 50 |
+
* β AND IT NEEDS ITS OWN `<Suspense>`, NOT `Shell.tsx`'s. `Lazily` already wraps this whole
|
| 51 |
+
* surface, but a Suspense boundary catches ANY suspending descendant β so without an inner
|
| 52 |
+
* boundary, opening an automation would suspend the boundary that owns the RAIL and blank the
|
| 53 |
+
* list you just clicked in. The inner one keeps the rail painted while the editor streams in.
|
| 54 |
+
* `fallback={null}` is deliberate and is `Lazily`'s own choice, not a shortcut: the work area is
|
| 55 |
+
* simply not there for the moment the chunk is in flight, which is what every other route in this
|
| 56 |
+
* product already does. β A skeleton here would be a NEW loading screen in the same wave that
|
| 57 |
+
* exists to remove one (T22).
|
| 58 |
+
* β Default export, checked: `Shell.tsx:53` records that `lazy()` over a NAMED export fails at
|
| 59 |
+
* runtime, on click, and only for that one surface β `AutomationDetail` exports default.
|
| 60 |
+
*/
|
| 61 |
+
const AutomationDetail = lazy(() => import("./AutomationDetail"));
|
| 62 |
+
|
| 63 |
+
/**
|
| 64 |
+
* The run poll's cadence (item 6). Named constants because they are a MEASURED trade-off, not a
|
| 65 |
+
* taste: `POLL_MAX_MS` is the longest a finished run can still look live, and it is the only
|
| 66 |
+
* cost the backoff has.
|
| 67 |
+
*/
|
| 68 |
+
const POLL_MIN_MS = 2500;
|
| 69 |
+
const POLL_MAX_MS = 8000;
|
| 70 |
+
|
| 71 |
+
/*
|
| 72 |
+
* β WAVE 26 Β· ITEM 18 / R14 β `stateTitle` STOOD HERE AND IS DELETED WITH THE DOT IT DESCRIBED.
|
| 73 |
+
*
|
| 74 |
+
* It composed the dot's tooltip (the running step, or the last run's failure summary), so it had
|
| 75 |
+
* exactly one reader and no reason to outlive it. Deleting the render and keeping the composer is
|
| 76 |
+
* how a file accumulates functions that look live and are not β and `noUnusedLocals` would have
|
| 77 |
+
* caught this one, which is not a reason to lean on it: the NEXT such function might still have a
|
| 78 |
+
* second caller and compile fine.
|
| 79 |
+
*
|
| 80 |
+
* β WHY THE TOOLTIP WAS NOT MOVED ONTO THE MARK INSTEAD. R14 is "same mark on every automation;
|
| 81 |
+
* no status colour". Hanging "Last run failed" off a mark that is deliberately status-free just
|
| 82 |
+
* moves the status channel into `title`/`aria-label`, where it is worse: invisible to the eye,
|
| 83 |
+
* announced to a screen reader, and contradicting the visual. The run state has a home one click
|
| 84 |
+
* away β the Builder's own "Last run succeeded / failed" lines, which R13 explicitly keeps.
|
| 85 |
+
*
|
| 86 |
+
* β `stateOf` ITSELF IS UNTOUCHED in `automationApi.ts`. `Shell.tsx:64/1095` imports it and
|
| 87 |
+
* `liveStepOf` calls it β deleting the export to tidy up this file would break a fence I do not own.
|
| 88 |
+
*/
|
| 89 |
+
|
| 90 |
+
/**
|
| 91 |
+
* The rail's second line β ONE fact, not two.
|
| 92 |
+
*
|
| 93 |
+
* β It carried "Next 2026-08-04 06:00 Β· last 2026-08-04 00:20", and the rail is `--lp-rail-w`
|
| 94 |
+
* wide, so it rendered as "Next 2026-08-04 06:00 Β· lβ¦" β a second line whose only complete word
|
| 95 |
+
* was "Next". Two facts that both truncate are worth less than one that fits. The forward-looking
|
| 96 |
+
* one wins when a schedule exists (it is the question the rail answers: does this run itself?),
|
| 97 |
+
* the last run otherwise. The full picture is one click away in the editor's run history, and the
|
| 98 |
+
* row's `title` already says both.
|
| 99 |
+
*
|
| 100 |
+
* Caught by READING the screenshot: the DOM was correct and the CSS ellipsis was doing exactly
|
| 101 |
+
* its job. [[ui-invisible-to-assertions]] β judge the pixels even when everything is green.
|
| 102 |
+
*/
|
| 103 |
+
function railSubtitle(a: Automation): string {
|
| 104 |
+
/*
|
| 105 |
+
* β WAVE 24 (owner item 6, "Run once now is laggy / looks stuck") β WHILE IT IS RUNNING,
|
| 106 |
+
* THE ONE FACT WORTH THE LINE IS WHAT IT IS DOING.
|
| 107 |
+
*
|
| 108 |
+
* MEASURED, not guessed (`scratchpad/perf_automations.py`, mailbox D-5): the engine has been
|
| 109 |
+
* publishing a live step the whole time (`status.step`, `routes_automation.py:50-51`) and NO
|
| 110 |
+
* SURFACE HAS EVER RENDERED IT. So a `discover_instagram` run parked in its legitimate
|
| 111 |
+
* `BD_FILTER_WAIT = 120 s` vendor wait and a genuinely hung thread were pixel-identical: a
|
| 112 |
+
* pulsing blue dot and a subtitle still reciting the schedule. "Looks stuck" was not a
|
| 113 |
+
* performance problem β the poll costs 23 ms β it was the product declining to say.
|
| 114 |
+
*
|
| 115 |
+
* It takes the line rather than joining it, for the reason this function's note already
|
| 116 |
+
* gives: the rail is `--lp-rail-w` wide and two facts that both truncate are worth less than
|
| 117 |
+
* one that fits. The schedule is still one click away and the dot's `title` carries both.
|
| 118 |
+
*/
|
| 119 |
+
const step = liveStepOf(a);
|
| 120 |
+
if (step) return step;
|
| 121 |
+
if (a.schedule?.enabled) return a.nextRunAt ? `Next ${a.nextRunAt}` : "Scheduled";
|
| 122 |
+
/*
|
| 123 |
+
* β WAVE 26 Β· ITEM 19 / R13 β `Last run 2026-08-06 13:58` IS DELETED FROM THIS LINE.
|
| 124 |
+
*
|
| 125 |
+
* β SCOPE, because R13 draws a line that is easy to over-read: the ruling names THIS secondary
|
| 126 |
+
* line under the automation's name. The Builder's "Last run succeeded / failed" RESULT lines
|
| 127 |
+
* (`AutomationBuilder.tsx:1676`, `:1679`) are a different surface and are explicitly NOT in
|
| 128 |
+
* scope β they are the answer to "Run once now", so deleting them would leave a button with no
|
| 129 |
+
* outcome.
|
| 130 |
+
*
|
| 131 |
+
* "Manual only" STAYS, and the distinction is the ruling's own: a dated run record is a
|
| 132 |
+
* changing FACT ABOUT THE PAST, while "this one has no schedule" is a stable property of the
|
| 133 |
+
* automation β the same question `Next β¦`/`Scheduled` answers for its siblings. Dropping it too
|
| 134 |
+
* would leave a manual automation with a blank second line and nothing saying why.
|
| 135 |
+
*/
|
| 136 |
+
return "Manual only";
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
/*
|
| 140 |
+
* β W23-W5's LISTENER IS AT MODULE SCOPE, AND THAT IS THE POINT β not a stylistic choice.
|
| 141 |
+
*
|
| 142 |
+
* The frame's click-through sets `window.location.hash = "#/automation"` and signals on the
|
| 143 |
+
* VERY NEXT LINE (Shell.tsx:1647-1650, and its comment is right that the order matters). But a
|
| 144 |
+
* hash write does not mount anything synchronously: `hashchange` is delivered as a task, the
|
| 145 |
+
* router state then updates, React renders, and only THEN does a component effect subscribe.
|
| 146 |
+
* A listener registered in `useEffect` therefore misses every click that arrives from another
|
| 147 |
+
* page β which is every click, since a reader looking at Alerts is by definition not already
|
| 148 |
+
* on this surface. The event would dispatch into nothing and every gate would stay green:
|
| 149 |
+
* exactly the wave-20 item-25 failure the contract's own note describes, reproduced one layer
|
| 150 |
+
* down.
|
| 151 |
+
*
|
| 152 |
+
* This module is imported statically by the shell, so this listener exists from app start. It
|
| 153 |
+
* LATCHES the request; the component consumes the latch when it mounts and hears live events
|
| 154 |
+
* while it is mounted. A request nobody claims is dropped on the next one β the latch is a
|
| 155 |
+
* one-slot mailbox, never a queue.
|
| 156 |
+
*/
|
| 157 |
+
let pendingOpen: AutomationOpenDetail | null = null;
|
| 158 |
+
const openSubscribers = new Set<(detail: AutomationOpenDetail) => void>();
|
| 159 |
+
|
| 160 |
+
if (typeof window !== "undefined") {
|
| 161 |
+
window.addEventListener(AUTOMATION_OPEN_EVENT, (event) => {
|
| 162 |
+
const detail = (event as CustomEvent<AutomationOpenDetail>).detail;
|
| 163 |
+
if (!detail?.autoId) return;
|
| 164 |
+
if (openSubscribers.size) {
|
| 165 |
+
for (const notify of openSubscribers) notify(detail);
|
| 166 |
+
return;
|
| 167 |
+
}
|
| 168 |
+
pendingOpen = detail;
|
| 169 |
+
});
|
| 170 |
+
}
|
| 171 |
+
|
| 172 |
+
/*
|
| 173 |
+
* β THE `AUTOMATION_CREATE_EVENT` LISTENER STOOD HERE AND IS DELETED WITH ITS SIGNALLER
|
| 174 |
+
* (wave 25 item 5a, ruling R8) β latch, subscriber set and all.
|
| 175 |
+
*
|
| 176 |
+
* Wave 24 built it to close the opposite defect: the event was declared, signalled from
|
| 177 |
+
* `Shell.tsx`, and consumed NOWHERE, so all three "Automated database" doors navigated to
|
| 178 |
+
* `#/automation` and then did nothing, in production, with every gate green. R8 now deletes those
|
| 179 |
+
* three doors β creating a database and pointing an automation at it are two acts β and they were
|
| 180 |
+
* the event's only signaller.
|
| 181 |
+
*
|
| 182 |
+
* β SO THIS SIDE GOES TOO, IN THE SAME CHANGE. A listener with no signaller is the same defect
|
| 183 |
+
* read from the other end: it compiles, it costs nothing at runtime, and it reads to the next
|
| 184 |
+
* person as a live channel. The rule that catches both is now DERIVED rather than remembered β
|
| 185 |
+
* `verify_automation_ui.py` enumerates every `AUTOMATION_*_EVENT` in `apiContract.ts` and demands
|
| 186 |
+
* a signal site AND a listener site for each.
|
| 187 |
+
*
|
| 188 |
+
* β NOTHING ABOUT CREATING AN AUTOMATION IS LOST. `createAndOpen` is untouched and still has two
|
| 189 |
+
* doors, both on this surface: the rail's button and the empty state's. What is gone is the claim
|
| 190 |
+
* that a database can be created BY asking for an automation.
|
| 191 |
+
*/
|
| 192 |
+
|
| 193 |
+
/**
|
| 194 |
+
* A default name for a brand-new automation, and it has to be one the server ACCEPTS.
|
| 195 |
+
*
|
| 196 |
+
* MEASURED against the live route rather than assumed (`scratchpad/probe_names.py`): a create
|
| 197 |
+
* with no name is a 400 (`name the automation`), and duplicate names are ALLOWED. So the name
|
| 198 |
+
* cannot be omitted, and a fixed literal would stack "New automation" three deep in a rail that
|
| 199 |
+
* sorts by name with nothing to tell the rows apart. Numbering from the existing list is a
|
| 200 |
+
* client-side convenience over a server that does not care: if two tabs race, the loser gets a
|
| 201 |
+
* duplicate name, which is legal, visible and renameable β never an error the user has to read.
|
| 202 |
+
*/
|
| 203 |
+
function nextAutomationName(existing: Automation[]): string {
|
| 204 |
+
const taken = new Set(existing.map((a) => (a.name || "").trim().toLowerCase()));
|
| 205 |
+
for (let n = existing.length + 1; ; n += 1) {
|
| 206 |
+
// β WAVE 34 Β· R12 β "Agent N", not "Automation N". β THIS ONE IS STORED, so it is the only
|
| 207 |
+
// string in the rename that leaves a visible seam: automations created before today keep
|
| 208 |
+
// "Automation 3" as their NAME until somebody renames them. That is correct and deliberate β
|
| 209 |
+
// a name is a person's own text, and rewriting stored names to match a caption change would
|
| 210 |
+
// edit user data to tidy a word. The seam is in the rail for a while and then gone.
|
| 211 |
+
const candidate = `Agent ${n}`;
|
| 212 |
+
if (!taken.has(candidate.toLowerCase())) return candidate;
|
| 213 |
+
}
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
+
export default function AutomationSurface() {
|
| 217 |
+
/* β T22 β SEEDED FROM THE CLIENT MEMO, so a revisit inside the freshness window never passes
|
| 218 |
+
through `data === null` and therefore never paints the skeleton. The lazy initialiser runs
|
| 219 |
+
once, on mount, BEFORE the first paint β `useState(cachedAutomations())` would call it on
|
| 220 |
+
every render instead, which is the same value at needless cost. A cold start still returns
|
| 221 |
+
null and still gets the skeleton: it is made rare, not removed (see `automationApi.ts`). */
|
| 222 |
+
const [data, setData] = useState<AutomationList | null>(() => cachedAutomations());
|
| 223 |
+
const [error, setError] = useState("");
|
| 224 |
+
const [activeId, setActiveId] = useState<string>("");
|
| 225 |
+
const [railShut, setRailShut] = useState(false);
|
| 226 |
+
const [busy, setBusy] = useState("");
|
| 227 |
+
/**
|
| 228 |
+
* β WAVE 24 / C-CREATE β a create is now a ROUND TRIP, not a local wizard, so the door has to
|
| 229 |
+
* say it is busy. Without this a second click while the POST is in flight makes a second
|
| 230 |
+
* automation, and the server allows duplicate names, so the user gets two rows that look
|
| 231 |
+
* identical and no error to explain either of them.
|
| 232 |
+
*/
|
| 233 |
+
const [creatingNow, setCreatingNow] = useState(false);
|
| 234 |
+
/** An open request waiting for the list to arrive (see the resolver below). */
|
| 235 |
+
const [openRequest, setOpenRequest] = useState<AutomationOpenDetail | null>(null);
|
| 236 |
+
/* β `createRequest` LEFT WITH THE EVENT THAT SET IT (wave 25, R8) β see the tombstone above. */
|
| 237 |
+
const listRef = useRef<HTMLDivElement | null>(null);
|
| 238 |
+
|
| 239 |
+
/**
|
| 240 |
+
* β C14 LEG 2 β THE ID THE USER LAST ASKED FOR, and it is a ref because it has to be
|
| 241 |
+
* written SYNCHRONOUSLY, inside the click, before any promise that was already in flight
|
| 242 |
+
* can resolve. State would not do: a resolution racing React's commit would read the
|
| 243 |
+
* previous value, which is the exact window this guard exists to close.
|
| 244 |
+
*/
|
| 245 |
+
const wantedId = useRef("");
|
| 246 |
+
|
| 247 |
+
/**
|
| 248 |
+
* β THE ONLY PLACE THE RAIL'S SELECTION MOVES ON PURPOSE. Every deliberate change of
|
| 249 |
+
* automation β a rail click, a create, a delete β goes through here, so "what the user
|
| 250 |
+
* asked for" and "what is on screen" are written in one statement and cannot drift apart.
|
| 251 |
+
* A resolution that wants to steer the rail compares itself against `wantedId` instead.
|
| 252 |
+
*/
|
| 253 |
+
const select = useCallback((id: string, _stage = "") => {
|
| 254 |
+
wantedId.current = id;
|
| 255 |
+
setActiveId(id);
|
| 256 |
+
}, []);
|
| 257 |
+
|
| 258 |
+
/**
|
| 259 |
+
* ββ W33 (C-18, from lane C) β WHICH PANE AN INBOX ITEM ASKED FOR, and it was DROPPED here.
|
| 260 |
+
*
|
| 261 |
+
* β THE PAYLOAD WAS DECLARED, TRANSPORTED, AND IGNORED AT THE LAST HOP.
|
| 262 |
+
* `routes_alerts.py` sets `tab: "runs"` on a notification's target, `inboxModel.routeForTarget`
|
| 263 |
+
* forwards it and `Shell.tsx` signals `{autoId, tab}` β and this file read `openRequest.tab`
|
| 264 |
+
* NOWHERE. So a person clicking *"12 new creators found"* to see the RUN landed on Properties,
|
| 265 |
+
* which is [[flag-shipped-without-its-writer]] with the writer present and the READER missing.
|
| 266 |
+
* β NOT FIXED BY DELETING `tab` FROM THE CONTRACT, which was the other option C offered: the
|
| 267 |
+
* field is the only thing that carries "you clicked a run, so show runs", and an inbox that
|
| 268 |
+
* lands you on the wrong pane is the complaint, not the field.
|
| 269 |
+
* β IT IS A ONE-SHOT REQUEST, NOT A CONTROLLED VALUE. Forcing the pane on every render would
|
| 270 |
+
* take the pane control away from the person the moment after they used it β so this is
|
| 271 |
+
* consumed by `AutomationDetail` as an INITIAL value keyed on the automation, and cleared here
|
| 272 |
+
* with the request that carried it.
|
| 273 |
+
*/
|
| 274 |
+
const [openTab, setOpenTab] = useState<"properties" | "runs" | "">("");
|
| 275 |
+
|
| 276 |
+
/**
|
| 277 |
+
* β C14 LEG 3 (the paint half) β A STALE READ MAY NOT REPAINT.
|
| 278 |
+
*
|
| 279 |
+
* Every call takes the next generation; only the newest one is allowed to `setData`. The
|
| 280 |
+
* defect this closes is not cosmetic: a poll issued BEFORE a delete resolves AFTER it, and
|
| 281 |
+
* a list that still contains the deleted automation puts a row back in the rail that the
|
| 282 |
+
* user has just watched disappear. Same for a toggle, and same for a create.
|
| 283 |
+
*
|
| 284 |
+
* β THE RETURN VALUE IS NOT GUARDED, deliberately. The caller asked a question and gets
|
| 285 |
+
* its own answer β coupling the two would mean a create whose `load()` was overtaken by a
|
| 286 |
+
* poll silently failed to select the automation it had just made.
|
| 287 |
+
*/
|
| 288 |
+
const gen = useRef(0);
|
| 289 |
+
|
| 290 |
+
const load = useCallback(async (abort?: AbortSignal) => {
|
| 291 |
+
const mine = ++gen.current;
|
| 292 |
+
try {
|
| 293 |
+
const next = await listAutomations(abort);
|
| 294 |
+
if (mine === gen.current) {
|
| 295 |
+
setData(next);
|
| 296 |
+
setError("");
|
| 297 |
+
}
|
| 298 |
+
return next;
|
| 299 |
+
} catch (e) {
|
| 300 |
+
if ((e as Error)?.name === "AbortError") return null;
|
| 301 |
+
if (mine === gen.current)
|
| 302 |
+
setError(
|
| 303 |
+
e instanceof AutomationError
|
| 304 |
+
? e.message
|
| 305 |
+
: "The automation service did not answer."
|
| 306 |
+
);
|
| 307 |
+
return null;
|
| 308 |
+
}
|
| 309 |
+
}, []);
|
| 310 |
+
|
| 311 |
+
/**
|
| 312 |
+
* ββ WAVE 31 Β· T31 β one freshly-written automation replaces its own row, with no round trip.
|
| 313 |
+
*
|
| 314 |
+
* β IT TAKES THE GENERATION, and that is not decoration. `load`'s guard exists so a stale READ
|
| 315 |
+
* cannot repaint; a WRITE response is by definition newer than any read already in flight, so it
|
| 316 |
+
* must win the same race rather than sit outside it. Without the bump, a `GET /automations`
|
| 317 |
+
* issued before the save could resolve after the merge and paint the pre-save row back β the
|
| 318 |
+
* exact ghost `gen` was introduced for, arriving through the new door.
|
| 319 |
+
*
|
| 320 |
+
* β Returns FALSE when there is nothing to merge into (a cold surface has no memo), and the
|
| 321 |
+
* caller then does a real load. Painting a one-row list would be worse than a round trip.
|
| 322 |
+
*/
|
| 323 |
+
const mergeSaved = useCallback((fresh: Automation) => {
|
| 324 |
+
const next = rememberAutomation(fresh);
|
| 325 |
+
if (!next) return false;
|
| 326 |
+
gen.current += 1;
|
| 327 |
+
setData(next);
|
| 328 |
+
setError("");
|
| 329 |
+
return true;
|
| 330 |
+
}, []);
|
| 331 |
+
|
| 332 |
+
useEffect(() => {
|
| 333 |
+
const ac = new AbortController();
|
| 334 |
+
void load(ac.signal);
|
| 335 |
+
return () => ac.abort();
|
| 336 |
+
}, [load]);
|
| 337 |
+
|
| 338 |
+
/**
|
| 339 |
+
* ββ WAVE 24 / C-CREATE (a) β CREATE AND OPEN. This REPLACED the three-step wizard.
|
| 340 |
+
*
|
| 341 |
+
* R6 is what makes it possible: `plain` is a real kind with no machine graph nodes, and it is
|
| 342 |
+
* what `POST /automations` stores when the body names none. So "new automation" stopped being
|
| 343 |
+
* a question ("which of three kinds?" β two of which can no longer be created at all) and
|
| 344 |
+
* became what it says: a new automation, open, on its trigger picker. Choosing a SOURCE is now
|
| 345 |
+
* picking the `ig_profile_match` trigger, which is where that choice belongs.
|
| 346 |
+
*
|
| 347 |
+
* β THE GUARD IS A REF, not the `creatingNow` state, and this file already carries the scar
|
| 348 |
+
* that explains why (C14 leg 2): a state read inside a click closure is the value from the
|
| 349 |
+
* last render, so two fast clicks both see `false` and both POST. Duplicate names are legal
|
| 350 |
+
* server-side, so the user would get two identical rows and no error. The ref is written
|
| 351 |
+
* synchronously, inside the click, before anything can await.
|
| 352 |
+
*/
|
| 353 |
+
const creatingRef = useRef(false);
|
| 354 |
+
const createAndOpen = useCallback(async () => {
|
| 355 |
+
if (creatingRef.current) return;
|
| 356 |
+
creatingRef.current = true;
|
| 357 |
+
setCreatingNow(true);
|
| 358 |
+
try {
|
| 359 |
+
const res = await createAutomation({
|
| 360 |
+
name: nextAutomationName(data?.automations || []),
|
| 361 |
+
});
|
| 362 |
+
const id = res?.automation?.id || "";
|
| 363 |
+
// The create flow is the one caller allowed to name a different id (C14 leg 2) β the
|
| 364 |
+
// automation did not exist when the click happened, so there is nothing to race with.
|
| 365 |
+
const next = await load();
|
| 366 |
+
if (id && next) select(id);
|
| 367 |
+
} catch (e) {
|
| 368 |
+
setError(
|
| 369 |
+
e instanceof AutomationError ? e.message : "The automation was not created."
|
| 370 |
+
);
|
| 371 |
+
} finally {
|
| 372 |
+
creatingRef.current = false;
|
| 373 |
+
setCreatingNow(false);
|
| 374 |
+
}
|
| 375 |
+
}, [data, load, select]);
|
| 376 |
+
|
| 377 |
+
// A run is a background thread on the server, so the surface has to ASK whether it
|
| 378 |
+
// finished. Polling only while something is actually running keeps an idle surface
|
| 379 |
+
// silent β a fixed interval would be a request every few seconds forever, for a page
|
| 380 |
+
// whose contents change a handful of times a day.
|
| 381 |
+
//
|
| 382 |
+
// β C14 LEG 3 (the abort half). It used to call `load()` bare β no signal, nothing to
|
| 383 |
+
// cancel β so a request the interval had already issued kept going after the effect that
|
| 384 |
+
// owned it was gone. One controller per effect run, aborted with the interval, means a poll
|
| 385 |
+
// cannot outlive the condition that justified it. The generation guard inside `load` covers
|
| 386 |
+
// the rest: a response that survives the abort still cannot repaint over a newer one.
|
| 387 |
+
//
|
| 388 |
+
// β WAVE 24 (item 6, C-PERF) β THE INTERVAL BECAME A BACKOFF, and the measurement is why it is
|
| 389 |
+
// a SMALL change rather than the big one the contract's hypothesis 2 asked for.
|
| 390 |
+
//
|
| 391 |
+
// MEASURED (`scratchpad/perf_automations.py`, mailbox D-5): this poll costs ~23 ms and 24 KB
|
| 392 |
+
// for a ten-automation tenant, and the engine work the hypothesis blamed β a `graph()` rebuild
|
| 393 |
+
// per automation β is **41 Β΅s each**, i.e. under 4% of the request. The payload was never the
|
| 394 |
+
// problem, so nothing here gets a cheaper endpoint.
|
| 395 |
+
//
|
| 396 |
+
// What the numbers DO indict is the aggregate: a discovery run legitimately blocks up to
|
| 397 |
+
// `BD_FILTER_WAIT` = 120 s, and two independent 2.5 s polls across this file and the detail
|
| 398 |
+
// spend ~96 requests and ~2.3 MB over that window β in the same single process the run itself
|
| 399 |
+
// is a thread in. So the delay grows 2.5 β 5 β 8 s and stops there.
|
| 400 |
+
//
|
| 401 |
+
// β NOT "to learn nothing", and the distinction is the whole justification. That WAS true when
|
| 402 |
+
// this file rendered no live step: the poll's only observable effect was a dot that had already
|
| 403 |
+
// been pulsing for two minutes. It stopped being true in this same change β `liveStepOf` now
|
| 404 |
+
// paints `status.step`, so a poll carries the one fact worth having. The backoff is therefore
|
| 405 |
+
// NOT "stop asking a pointless question"; it is FEWER ROUND TRIPS FOR THE SAME INFORMATION, on
|
| 406 |
+
// a step text that changes every few seconds at most, not every 2.5.
|
| 407 |
+
// The cap is deliberately low: it bounds how long a FINISHED run can still look live, which is
|
| 408 |
+
// the only thing a backoff can make worse, and 8 s of that is worth ~β
fewer requests.
|
| 409 |
+
//
|
| 410 |
+
// β THE KEY IS THE RUNNING SET, NOT A BOOLEAN, and that is what makes the reset correct: the
|
| 411 |
+
// effect re-runs β and the delay drops back to 2.5 s β the moment a run starts or finishes, so
|
| 412 |
+
// a user who clicks Run now gets the fast cadence again instead of inheriting the tail of the
|
| 413 |
+
// previous run's backoff. A bare `anyRunning` boolean cannot see the second run start.
|
| 414 |
+
// A step text changing does NOT re-key it, so the interval never thrashes.
|
| 415 |
+
//
|
| 416 |
+
// C14 LEG 3 (the abort half) is unchanged and still load-bearing: one controller per effect
|
| 417 |
+
// run, aborted with the timer, so a poll cannot outlive the condition that justified it. The
|
| 418 |
+
// generation guard inside `load` covers the rest.
|
| 419 |
+
const runningKey = (data?.automations || [])
|
| 420 |
+
.filter((a) => a.running)
|
| 421 |
+
.map((a) => a.id)
|
| 422 |
+
.sort()
|
| 423 |
+
.join(",");
|
| 424 |
+
useEffect(() => {
|
| 425 |
+
if (!runningKey) return undefined;
|
| 426 |
+
const ac = new AbortController();
|
| 427 |
+
let delay = POLL_MIN_MS;
|
| 428 |
+
let timer = 0;
|
| 429 |
+
const tick = () => {
|
| 430 |
+
void load(ac.signal);
|
| 431 |
+
delay = Math.min(delay * 2, POLL_MAX_MS);
|
| 432 |
+
timer = window.setTimeout(tick, delay);
|
| 433 |
+
};
|
| 434 |
+
timer = window.setTimeout(tick, delay);
|
| 435 |
+
return () => {
|
| 436 |
+
window.clearTimeout(timer);
|
| 437 |
+
ac.abort();
|
| 438 |
+
};
|
| 439 |
+
}, [runningKey, load]);
|
| 440 |
+
|
| 441 |
+
// ββ W23-W5, the surface's half: hear the request, then answer it when we CAN ββββββββββ
|
| 442 |
+
useEffect(() => {
|
| 443 |
+
const notify = (detail: AutomationOpenDetail) => setOpenRequest(detail);
|
| 444 |
+
openSubscribers.add(notify);
|
| 445 |
+
if (pendingOpen) {
|
| 446 |
+
const latched = pendingOpen;
|
| 447 |
+
pendingOpen = null;
|
| 448 |
+
setOpenRequest(latched);
|
| 449 |
+
}
|
| 450 |
+
return () => {
|
| 451 |
+
openSubscribers.delete(notify);
|
| 452 |
+
};
|
| 453 |
+
}, []);
|
| 454 |
+
|
| 455 |
+
/* β THE C-CREATE(b) SUBSCRIBE EFFECT AND ITS RESOLVER STOOD HERE (wave 25, R8). They heard the
|
| 456 |
+
create event and, once the list had arrived, called `createAndOpen` β the "wait for the list
|
| 457 |
+
or every automation is named Automation 1" note lives on in `nextAutomationName`, which is
|
| 458 |
+
still numbered off `existing`. With no signaller there is nothing to hear. */
|
| 459 |
+
|
| 460 |
+
/**
|
| 461 |
+
* β THE REQUEST OUTLIVES THE FETCH, and it has to. The reader clicks a notification from
|
| 462 |
+
* another page, so this surface is mounting WITH AN EMPTY LIST β "select it if it is in the
|
| 463 |
+
* list" would drop every real click and keep only the one case where the reader was already
|
| 464 |
+
* here. So the request is held until `data` exists, and only then answered.
|
| 465 |
+
*
|
| 466 |
+
* An automation the reader can no longer open does NOTHING (the contract's own words): the
|
| 467 |
+
* request is cleared either way, so a stale id cannot sit here re-firing against every
|
| 468 |
+
* subsequent list.
|
| 469 |
+
*/
|
| 470 |
+
useEffect(() => {
|
| 471 |
+
if (!openRequest || !data) return;
|
| 472 |
+
const found = (data.automations || []).some((a) => a.id === openRequest.autoId);
|
| 473 |
+
if (found) {
|
| 474 |
+
select(openRequest.autoId, openRequest.stageId || "");
|
| 475 |
+
// C-18: the pane the notification asked for, read at the one moment it means something.
|
| 476 |
+
const want = String((openRequest as { tab?: string }).tab || "");
|
| 477 |
+
setOpenTab(want === "runs" || want === "properties" ? want : "");
|
| 478 |
+
}
|
| 479 |
+
setOpenRequest(null);
|
| 480 |
+
}, [openRequest, data, select]);
|
| 481 |
+
|
| 482 |
+
const items = data?.automations || [];
|
| 483 |
+
const active = items.find((a) => a.id === activeId) || null;
|
| 484 |
+
|
| 485 |
+
/**
|
| 486 |
+
* ββ WAVE 32 Β· T43 (owner item 2) β TURNING AN AUTOMATION ON OR OFF STOPS TAKING FOREVER.
|
| 487 |
+
*
|
| 488 |
+
* Owner: turning an automation on or off "takes forever". β THE TICKET'S HYPOTHESIS WAS THE
|
| 489 |
+
* SERVER AND IT IS NOT β MEASURED. `engine.patch` for a schedule-only body takes ONE
|
| 490 |
+
* `automations` bucket read, ZERO `user_tables` reads and ~1 ms against a 2.8 MB fixture, and
|
| 491 |
+
* `GET /automations` is one read too since W30-T12. The cost was never the document shape
|
| 492 |
+
* (D-179/D-175's family): it was that this handler paid **two sequential round trips** for one
|
| 493 |
+
* click, and painted nothing until BOTH had landed. The second one re-downloaded the entire
|
| 494 |
+
* rail payload β 88,771 B measured, see the note at the top of this file β to learn one boolean
|
| 495 |
+
* the first response had already returned.
|
| 496 |
+
*
|
| 497 |
+
* β AND THE FIX ALREADY EXISTED IN THIS FILE, POINTED AT A DIFFERENT BUTTON. `mergeSaved` is
|
| 498 |
+
* W31-T31, built for the detail pane's saves, with its own note about the write response being
|
| 499 |
+
* newer than any read in flight. The rail's own switch never got it β a fix applied to one of
|
| 500 |
+
* two twins, which is this wave's recurring shape.
|
| 501 |
+
*
|
| 502 |
+
* Three states, in order, and the middle one is what the owner actually asked for:
|
| 503 |
+
* 1. FLIP IT NOW. The switch is the user's instruction, not a question; painting it after a
|
| 504 |
+
* network round trip is what "takes forever" describes even when the round trip is fast.
|
| 505 |
+
* 2. adopt the server's own answer when it lands, which carries the derived fields an
|
| 506 |
+
* optimistic row cannot invent (`nextRunAt`, the status line).
|
| 507 |
+
* 3. put the OLD row back if the write failed, beside the error β an optimistic switch that
|
| 508 |
+
* stays on after a refusal is a lie, and a worse one than a slow switch.
|
| 509 |
+
*
|
| 510 |
+
* β `mergeSaved` TAKES THE GENERATION, which is what stops [[refetch-eats-its-own-write]] here:
|
| 511 |
+
* a `GET /automations` issued before the click can still resolve after it, and without the bump
|
| 512 |
+
* it would paint the pre-toggle row back over both the optimistic flip and the confirmation.
|
| 513 |
+
* β `busy` is no longer what makes the switch look right β it stays only to keep a second click
|
| 514 |
+
* from racing the first.
|
| 515 |
+
*/
|
| 516 |
+
const toggleEnabled = async (a: Automation) => {
|
| 517 |
+
const flipped: Automation = {
|
| 518 |
+
...a,
|
| 519 |
+
schedule: { ...a.schedule, enabled: !a.schedule.enabled },
|
| 520 |
+
};
|
| 521 |
+
setBusy(a.id);
|
| 522 |
+
const painted = mergeSaved(flipped);
|
| 523 |
+
try {
|
| 524 |
+
const { automation: fresh } = await patchAutomation(a.id, {
|
| 525 |
+
schedule: { cron: a.schedule.cron, enabled: !a.schedule.enabled },
|
| 526 |
+
});
|
| 527 |
+
// A cold surface has no memo to merge into (`mergeSaved` answers false), and a one-row
|
| 528 |
+
// repaint would be worse than a round trip β the same rule `onSaved` follows.
|
| 529 |
+
if (!fresh || !mergeSaved(fresh)) await load();
|
| 530 |
+
} catch (e) {
|
| 531 |
+
if (painted) mergeSaved(a);
|
| 532 |
+
setError(e instanceof AutomationError ? e.message : "That change was not saved.");
|
| 533 |
+
} finally {
|
| 534 |
+
setBusy("");
|
| 535 |
+
}
|
| 536 |
+
};
|
| 537 |
+
|
| 538 |
+
const onRailKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
|
| 539 |
+
if (event.key !== "ArrowDown" && event.key !== "ArrowUp") return;
|
| 540 |
+
const rows = Array.from(
|
| 541 |
+
listRef.current?.querySelectorAll<HTMLButtonElement>(".auto-row-main") || []
|
| 542 |
+
);
|
| 543 |
+
if (!rows.length) return;
|
| 544 |
+
event.preventDefault();
|
| 545 |
+
const at = rows.indexOf(document.activeElement as HTMLButtonElement);
|
| 546 |
+
const next = event.key === "ArrowDown" ? (at + 1) % rows.length
|
| 547 |
+
: (at - 1 + rows.length) % rows.length;
|
| 548 |
+
rows[next < 0 ? 0 : next]?.focus();
|
| 549 |
+
};
|
| 550 |
+
|
| 551 |
+
return (
|
| 552 |
+
<div className="auto-surface">
|
| 553 |
+
<aside
|
| 554 |
+
className={"auto-rail" + (railShut ? " is-collapsed" : "")}
|
| 555 |
+
/* β WAVE 34 Β· R12 β an aria-label IS user-facing copy. A screen reader announces this
|
| 556 |
+
landmark by name, so leaving it "Automations" would have the module answer to two
|
| 557 |
+
different words depending on how you read the page. */
|
| 558 |
+
aria-label="Agents"
|
| 559 |
+
>
|
| 560 |
+
{/* The same three-bars fold control the Views rail carries β the two rails are
|
| 561 |
+
one idea, so they must not fold with two different affordances. */}
|
| 562 |
+
<div className="auto-rail-top">
|
| 563 |
+
<button
|
| 564 |
+
type="button"
|
| 565 |
+
className="cg-rail-toggle"
|
| 566 |
+
aria-label={railShut ? "Expand automations" : "Minimize automations"}
|
| 567 |
+
aria-expanded={!railShut}
|
| 568 |
+
title={railShut ? "Expand automations" : "Minimize automations"}
|
| 569 |
+
onClick={() => setRailShut((v) => !v)}
|
| 570 |
+
>
|
| 571 |
+
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| 572 |
+
<path
|
| 573 |
+
d="M2.5 4.4h11M2.5 8h11M2.5 11.6h11"
|
| 574 |
+
stroke="currentColor"
|
| 575 |
+
strokeWidth="1.35"
|
| 576 |
+
strokeLinecap="round"
|
| 577 |
+
/>
|
| 578 |
+
</svg>
|
| 579 |
+
</button>
|
| 580 |
+
</div>
|
| 581 |
+
|
| 582 |
+
<div className="auto-create">
|
| 583 |
+
<button
|
| 584 |
+
type="button"
|
| 585 |
+
className="auto-create-btn"
|
| 586 |
+
disabled={creatingNow}
|
| 587 |
+
onClick={() => void createAndOpen()}
|
| 588 |
+
>
|
| 589 |
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" aria-hidden="true">
|
| 590 |
+
<path
|
| 591 |
+
d="M8 3.4v9.2M3.4 8h9.2"
|
| 592 |
+
stroke="currentColor"
|
| 593 |
+
strokeWidth="1.5"
|
| 594 |
+
strokeLinecap="round"
|
| 595 |
+
/>
|
| 596 |
+
</svg>
|
| 597 |
+
New agent
|
| 598 |
+
</button>
|
| 599 |
+
</div>
|
| 600 |
+
|
| 601 |
+
<div className="auto-list" ref={listRef} onKeyDown={onRailKey}>
|
| 602 |
+
{items.map((a) => {
|
| 603 |
+
return (
|
| 604 |
+
<div
|
| 605 |
+
key={a.id}
|
| 606 |
+
className={"auto-row" + (a.id === activeId ? " is-active" : "")}
|
| 607 |
+
>
|
| 608 |
+
<button
|
| 609 |
+
type="button"
|
| 610 |
+
className="auto-row-main"
|
| 611 |
+
onClick={() => select(a.id)}
|
| 612 |
+
>
|
| 613 |
+
{/* β ITEM 18 / R14 β THE LOOP MARK, WHERE THE STATUS DOT WAS. Same mark on
|
| 614 |
+
every row, no status colour: the owner's point is that an automation IS a
|
| 615 |
+
loop, not that this one is green. The `.auto-loopmark` wrapper is what sizes
|
| 616 |
+
it β `.lp-mark` is shell-owned CSS and outside this session's region, so the
|
| 617 |
+
box is mine and the mark is theirs. */}
|
| 618 |
+
<span className="auto-loopmark">
|
| 619 |
+
<Mark size={15} />
|
| 620 |
+
</span>
|
| 621 |
+
<span className="auto-row-text">
|
| 622 |
+
<span className="auto-row-name">{a.name}</span>
|
| 623 |
+
<span className="auto-row-desc">{railSubtitle(a)}</span>
|
| 624 |
+
</span>
|
| 625 |
+
</button>
|
| 626 |
+
<button
|
| 627 |
+
type="button"
|
| 628 |
+
className={"auto-row-switch" + (a.schedule?.enabled ? " is-on" : "")}
|
| 629 |
+
disabled={busy === a.id}
|
| 630 |
+
aria-pressed={!!a.schedule?.enabled}
|
| 631 |
+
title={
|
| 632 |
+
a.schedule?.enabled
|
| 633 |
+
? `Scheduled: ${a.schedule.cron}. Click to pause.`
|
| 634 |
+
: "Not scheduled. Click to enable."
|
| 635 |
+
}
|
| 636 |
+
onClick={() => void toggleEnabled(a)}
|
| 637 |
+
>
|
| 638 |
+
<span className="auto-row-switch-knob" />
|
| 639 |
+
</button>
|
| 640 |
+
</div>
|
| 641 |
+
);
|
| 642 |
+
})}
|
| 643 |
+
{/* ONE LINE (R13). It used to describe the kinds β "one reads a public web pageβ¦
|
| 644 |
+
the other fills an automation columnβ¦" β which was two sentences, wrong by the
|
| 645 |
+
time a third kind shipped, and printed inside a `--lp-rail-w` column. What the
|
| 646 |
+
kinds are belongs to the create form, which is one click away and lists all of
|
| 647 |
+
them from the server. */}
|
| 648 |
+
{/* β WAVE 29 (W29-T01) β A LOADING RAIL IS NOT AN EMPTY ONE. `data` is null until the
|
| 649 |
+
first `listAutomations()` resolves, and this branch read only `items.length`, so
|
| 650 |
+
every visit to Automation asserted "No automations yet." to a person who has
|
| 651 |
+
several β then replaced it with their list. Half of owner item 5's "takes a while
|
| 652 |
+
to appear" is that sentence: the wait is real, but being told you own nothing is
|
| 653 |
+
what makes it read as broken rather than slow. `data === null` is the one state
|
| 654 |
+
that means NOBODY HAS LOOKED, and it gets skeleton rows, not a claim. */}
|
| 655 |
+
{data === null && !error ? (
|
| 656 |
+
<div className="auto-rail-loading" aria-hidden="true">
|
| 657 |
+
<span className="auto-rail-skel" />
|
| 658 |
+
<span className="auto-rail-skel" />
|
| 659 |
+
<span className="auto-rail-skel" />
|
| 660 |
+
</div>
|
| 661 |
+
) : null}
|
| 662 |
+
{data !== null && !items.length && !error ? (
|
| 663 |
+
<p className="auto-rail-empty">No agents yet.</p>
|
| 664 |
+
) : null}
|
| 665 |
+
</div>
|
| 666 |
+
</aside>
|
| 667 |
+
|
| 668 |
+
<section className="auto-main">
|
| 669 |
+
{error ? (
|
| 670 |
+
<div className="auto-banner is-error" role="alert">
|
| 671 |
+
{error}
|
| 672 |
+
</div>
|
| 673 |
+
) : null}
|
| 674 |
+
{data && !data.storeAvailable ? (
|
| 675 |
+
<div className="auto-banner is-warn" role="status">
|
| 676 |
+
The tenant store is unavailable, so nothing can be saved right now.
|
| 677 |
+
</div>
|
| 678 |
+
) : null}
|
| 679 |
+
|
| 680 |
+
{/*
|
| 681 |
+
β THE `creating` BRANCH IS GONE (wave 24, C-CREATE a / owner items 3 + 4).
|
| 682 |
+
`AutomationCreate` used to render here β a three-step wizard whose first step asked
|
| 683 |
+
which DATABASE to write into (an existing one, a new one, or one the automation itself
|
| 684 |
+
would create) and whose second asked which of three KINDS.
|
| 685 |
+
R6 retires that question at the root: two of the three kinds can no longer be created
|
| 686 |
+
at all, and the third is now reached by picking a trigger. A new automation is created
|
| 687 |
+
the moment it is asked for (`createAndOpen`) and opens on its own Builder, so there is
|
| 688 |
+
no intermediate face left to render and no `creating` state to hold.
|
| 689 |
+
β WAVE 25 (R8) retires the LAST of that vocabulary: the third answer was the
|
| 690 |
+
"automated database", and it is gone from every door in the product. This button is now
|
| 691 |
+
one of exactly two ways to make an automation, and both are on this surface.
|
| 692 |
+
The import went with it β it is what would break `npx tsc -b` for the WHOLE client the
|
| 693 |
+
moment session B deletes the file, which is why this deletion is sequenced first.
|
| 694 |
+
*/}
|
| 695 |
+
{/* T21: the editor's own Suspense boundary β see the `lazy()` above for why it cannot be
|
| 696 |
+
`Shell.tsx`'s. `fallback={null}`, matching `Lazily`. */}
|
| 697 |
+
{active ? (
|
| 698 |
+
<Suspense fallback={null}>
|
| 699 |
+
<AutomationDetail
|
| 700 |
+
key={active.id}
|
| 701 |
+
automation={active}
|
| 702 |
+
/* C-18: which pane the Inbox asked for, or "" for the ordinary open. Consumed ONCE,
|
| 703 |
+
as an initial value β see `openTab`'s note on why it is not controlled. */
|
| 704 |
+
openTab={openTab}
|
| 705 |
+
/* β `kinds={data?.kinds || []}` LEFT HERE WITH THE PROP IT FED (wave 25, D-57) β
|
| 706 |
+
both halves in one change, because either alone is a `tsc` error. */
|
| 707 |
+
cronPresets={data?.cronPresets || []}
|
| 708 |
+
paidReady={!!data?.paidReady}
|
| 709 |
+
discover={data?.discover}
|
| 710 |
+
// The trigger vocabulary (C3). Forwarded as-is β absent stays absent, so the
|
| 711 |
+
// trigger face can tell "the server offered nothing" from "the server offered
|
| 712 |
+
// an empty list" rather than collapsing both into a picker with no options.
|
| 713 |
+
triggers={data?.triggers}
|
| 714 |
+
// C4's action menu + the builder's ceilings. Forwarded as-is for the same reason
|
| 715 |
+
// `triggers` is: absent must stay absent, so the builder can tell "the server
|
| 716 |
+
// offered nothing" from "the server offered an empty list".
|
| 717 |
+
catalog={data?.actionsCatalog}
|
| 718 |
+
vocab={data?.flow}
|
| 719 |
+
// `?? null` and never `|| {enabled:false}`: an absent tick bit is "the server did
|
| 720 |
+
// not say", which Step 1 prints as its own sentence. Defaulting it here would
|
| 721 |
+
// turn a missing field into a claim about production (C6 amendment #1).
|
| 722 |
+
tick={data?.tick ?? null}
|
| 723 |
+
/*
|
| 724 |
+
* β W24-W1 (item 6) β WHAT THIS RUN IS DOING, on the one surface that survives in
|
| 725 |
+
* BOTH views. REQUIRED on the far side deliberately: an optional prop that nobody
|
| 726 |
+
* passes degrades to "the feature does not exist", which is indistinguishable from
|
| 727 |
+
* "it was never built" β and this whole item exists because a live step text rode
|
| 728 |
+
* the wire for three waves with nothing rendering it.
|
| 729 |
+
* `liveStepOf` returns the step ONLY while the automation is running; a stale step
|
| 730 |
+
* from a finished run is a worse answer than none.
|
| 731 |
+
*/
|
| 732 |
+
liveStep={liveStepOf(active)}
|
| 733 |
+
/*
|
| 734 |
+
* β C14 LEG 2 β THE STALE-ID WRITE-BACK, and this is the ghost's second cause.
|
| 735 |
+
*
|
| 736 |
+
* The detail calls `onSaved(automation.id)` from six places (save, the trigger
|
| 737 |
+
* picker, a schedule change, a node switch, a card move, a run). Each closure
|
| 738 |
+
* captures the automation it was mounted for, so a save that resolves AFTER the
|
| 739 |
+
* user has clicked a different row used to call `setActiveId(the OLD id)` β the
|
| 740 |
+
* rail jumped back, the detail remounted, and what the user saw was the previous
|
| 741 |
+
* automation reappearing over the one they had just opened. It looked like a
|
| 742 |
+
* rendering bug; it was a resolution steering the selection.
|
| 743 |
+
*
|
| 744 |
+
* A resolution may no longer steer anything. It reloads the list β that part was
|
| 745 |
+
* always right β and it re-asserts the selection ONLY when its id is still the one
|
| 746 |
+
* the user asked for, which makes the write a no-op in the good case and nothing
|
| 747 |
+
* at all in the bad one.
|
| 748 |
+
*/
|
| 749 |
+
onSaved={async (id, fresh) => {
|
| 750 |
+
// ββ WAVE 31 Β· T31 β CORRECT THE ONE ROW WE WERE JUST TOLD ABOUT.
|
| 751 |
+
// `patchAutomation` and `toggleNode` return the updated definition; re-reading the
|
| 752 |
+
// whole list to learn it is the second of the two round trips "Savingβ¦" used to
|
| 753 |
+
// span. `mergeSaved` keeps the memo and the component in step (see
|
| 754 |
+
// `automationApi.rememberAutomation`) and takes the generation, because a write
|
| 755 |
+
// response is newer than any read still in flight β the same rule `load` follows,
|
| 756 |
+
// pointed the other way.
|
| 757 |
+
if (fresh && mergeSaved(fresh)) {
|
| 758 |
+
if (id && id === wantedId.current) select(id);
|
| 759 |
+
return;
|
| 760 |
+
}
|
| 761 |
+
const next = await load();
|
| 762 |
+
if (id && next && id === wantedId.current) select(id);
|
| 763 |
+
}}
|
| 764 |
+
onDeleted={async () => {
|
| 765 |
+
select("");
|
| 766 |
+
await load();
|
| 767 |
+
}}
|
| 768 |
+
/>
|
| 769 |
+
</Suspense>
|
| 770 |
+
) : (
|
| 771 |
+
/*
|
| 772 |
+
* THE EMPTY STATE IS ONE LINE AND A BUTTON (owner ruling R13 β "never
|
| 773 |
+
* over-explain", now a DESIGN.md law).
|
| 774 |
+
*
|
| 775 |
+
* It was a heading, a three-sentence paragraph and a 130-word bulleted list
|
| 776 |
+
* describing all three kinds. Every word of it was true and none of it was READ:
|
| 777 |
+
* an empty state is passed through, not studied, and the person looking at it has
|
| 778 |
+
* already decided to make an automation. The kinds are described where the choice
|
| 779 |
+
* is actually made β the create form lists them FROM THE SERVER, so that copy also
|
| 780 |
+
* cannot go stale the way this list had (it described two kinds after a third
|
| 781 |
+
* shipped).
|
| 782 |
+
*
|
| 783 |
+
* The button is here rather than only in the rail because this pane is where the
|
| 784 |
+
* eye is; a create affordance the user has to go find is the same defect as the
|
| 785 |
+
* paragraph, spent differently.
|
| 786 |
+
*/
|
| 787 |
+
<div className="autob-empty">
|
| 788 |
+
{/*
|
| 789 |
+
β NO TITLE HERE ANY MORE (C13, owner item 2). This pane carried an `h1`
|
| 790 |
+
reading "Automations" at 20px/600 β a THIRD title treatment on a page that
|
| 791 |
+
also had the header's editable 16px/700 input, against every database page's
|
| 792 |
+
single 16px/650 `shell-db-name`. The shell now wraps this branch in the same
|
| 793 |
+
`shell-db-frame` + `DbHead` a database gets (wiring W23-W1), so the page's name
|
| 794 |
+
is drawn once, by the one component that draws every other page's name. A
|
| 795 |
+
stand-in restyled to match would have been a second copy of the same fact,
|
| 796 |
+
free to drift the day the header moves.
|
| 797 |
+
*/}
|
| 798 |
+
<p className="autob-empty-line">
|
| 799 |
+
A job this workspace runs for you, on demand or on a schedule.
|
| 800 |
+
</p>
|
| 801 |
+
<button
|
| 802 |
+
type="button"
|
| 803 |
+
className="auto-btn is-primary"
|
| 804 |
+
disabled={creatingNow}
|
| 805 |
+
onClick={() => void createAndOpen()}
|
| 806 |
+
>
|
| 807 |
+
New agent
|
| 808 |
+
</button>
|
| 809 |
+
</div>
|
| 810 |
+
)}
|
| 811 |
+
</section>
|
| 812 |
+
</div>
|
| 813 |
+
);
|
| 814 |
+
}
|
web/src/automation/AutomationTrigger.tsx
CHANGED
|
@@ -98,8 +98,8 @@ function needsLabel(needs: string): string {
|
|
| 98 |
* declared-but-not-built ("coming soon", R2) which is waiting on us.
|
| 99 |
*/
|
| 100 |
function optionLabel(t: TriggerOption): string {
|
| 101 |
-
if (t.planned) return `${t.label}
|
| 102 |
-
return t.ready === false ? `${t.label}
|
| 103 |
}
|
| 104 |
|
| 105 |
/**
|
|
@@ -119,13 +119,13 @@ function tickNote(tick: TickState | null): { text: string; tone: string } {
|
|
| 119 |
// fire" as though it had been measured.
|
| 120 |
if (!tick || typeof tick.enabled !== "boolean") {
|
| 121 |
return {
|
| 122 |
-
text: "Whether this deployment runs schedules is not reported here yet
|
| 123 |
tone: "is-unknown",
|
| 124 |
};
|
| 125 |
}
|
| 126 |
if (!tick.enabled) {
|
| 127 |
return {
|
| 128 |
-
text: "Schedules won't fire on this deployment
|
| 129 |
tone: "is-off",
|
| 130 |
};
|
| 131 |
}
|
|
@@ -365,7 +365,7 @@ export default function AutomationTrigger({
|
|
| 365 |
<div className="autob-needs">
|
| 366 |
<span className="autob-needs-text">
|
| 367 |
{connected
|
| 368 |
-
? "Connected
|
| 369 |
: "This trigger is not set up yet."}
|
| 370 |
</span>
|
| 371 |
{!connected && startUrl ? (
|
|
@@ -392,7 +392,7 @@ export default function AutomationTrigger({
|
|
| 392 |
? tick && tick.enabled === false
|
| 393 |
? `It would run next at ${nextRunAt}, once a scheduler is running.`
|
| 394 |
: `Next run ${nextRunAt}.`
|
| 395 |
-
: "Schedules start from the moment they are switched on
|
| 396 |
: current === "manual"
|
| 397 |
? "It runs when you press Run now, and nothing else starts it."
|
| 398 |
: /*
|
|
|
|
| 98 |
* declared-but-not-built ("coming soon", R2) which is waiting on us.
|
| 99 |
*/
|
| 100 |
function optionLabel(t: TriggerOption): string {
|
| 101 |
+
if (t.planned) return `${t.label}. Coming soon`;
|
| 102 |
+
return t.ready === false ? `${t.label}. Needs setting up` : t.label;
|
| 103 |
}
|
| 104 |
|
| 105 |
/**
|
|
|
|
| 119 |
// fire" as though it had been measured.
|
| 120 |
if (!tick || typeof tick.enabled !== "boolean") {
|
| 121 |
return {
|
| 122 |
+
text: "Whether this deployment runs schedules is not reported here yet. Press Run now if you need it now.",
|
| 123 |
tone: "is-unknown",
|
| 124 |
};
|
| 125 |
}
|
| 126 |
if (!tick.enabled) {
|
| 127 |
return {
|
| 128 |
+
text: "Schedules won't fire on this deployment. Ask your admin to switch the scheduler on. Run now still works.",
|
| 129 |
tone: "is-off",
|
| 130 |
};
|
| 131 |
}
|
|
|
|
| 365 |
<div className="autob-needs">
|
| 366 |
<span className="autob-needs-text">
|
| 367 |
{connected
|
| 368 |
+
? "Connected. This trigger is still being switched on for this deployment."
|
| 369 |
: "This trigger is not set up yet."}
|
| 370 |
</span>
|
| 371 |
{!connected && startUrl ? (
|
|
|
|
| 392 |
? tick && tick.enabled === false
|
| 393 |
? `It would run next at ${nextRunAt}, once a scheduler is running.`
|
| 394 |
: `Next run ${nextRunAt}.`
|
| 395 |
+
: "Schedules start from the moment they are switched on. Turning on a daily job after today's time does not fire it today."
|
| 396 |
: current === "manual"
|
| 397 |
? "It runs when you press Run now, and nothing else starts it."
|
| 398 |
: /*
|
web/src/automation/automation.css
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/* ---------------------------------------------------------------------------
|
| 2 |
+
automation/automation.css β the Agent module's OWN stylesheet.
|
| 3 |
+
|
| 4 |
+
ββ WAVE 34 Β· W34-T41 β THIS FILE IS NEW, AND ITS EXISTENCE IS THE POINT.
|
| 5 |
+
|
| 6 |
+
Until today this module had NO scoped CSS at all: every `auto-*`, `autob-*` and `autox-*` rule
|
| 7 |
+
lived in the shared global `src/index.css`, which the wave PRD assigns to session C. That made
|
| 8 |
+
any new rule here a cross-fence ask, for styling nobody else can see β the PRD's own remedy is
|
| 9 |
+
"put NEW rules in your own module stylesheet (`automation/*.css`)", and this is that file.
|
| 10 |
+
|
| 11 |
+
β WHAT DOES NOT BELONG HERE: an edit to an existing `auto-*` rule. Those still live in
|
| 12 |
+
`index.css` and are still C's. This file holds rules that did not exist before, so the two can
|
| 13 |
+
never disagree about one selector.
|
| 14 |
+
|
| 15 |
+
β EVERY COLOUR IS A TOKEN. No literal hex, for the reason the palette block in `index.css`
|
| 16 |
+
records: a hand-copied brand value silently paints last wave's brand while a comment asserts
|
| 17 |
+
parity. Light and dark both come free that way.
|
| 18 |
+
--------------------------------------------------------------------------- */
|
| 19 |
+
|
| 20 |
+
/* ββ the Chat view (R17) ββββββββββββββββββββββββββββββββββββββββββββββββββββ */
|
| 21 |
+
|
| 22 |
+
/* Mirrors `.autox-flow`'s box so switching Canvas <-> Chat does not move the frame around it:
|
| 23 |
+
same flex growth, same min-height:0 (without which a scrolling child of a flex parent grows
|
| 24 |
+
the parent instead of scrolling), same scroll ownership. */
|
| 25 |
+
.autoc-chat {
|
| 26 |
+
flex: 1 1 auto;
|
| 27 |
+
min-width: 0;
|
| 28 |
+
min-height: 0;
|
| 29 |
+
display: flex;
|
| 30 |
+
flex-direction: column;
|
| 31 |
+
}
|
| 32 |
+
|
| 33 |
+
.autoc-log {
|
| 34 |
+
flex: 1 1 auto;
|
| 35 |
+
min-height: 0;
|
| 36 |
+
overflow-y: auto;
|
| 37 |
+
padding: 20px 24px 12px;
|
| 38 |
+
display: flex;
|
| 39 |
+
flex-direction: column;
|
| 40 |
+
gap: 14px;
|
| 41 |
+
}
|
| 42 |
+
|
| 43 |
+
/* The empty state is a SENTENCE, not an illustration: it says what to type, which is the only
|
| 44 |
+
thing a reader needs from a surface they have never used. DESIGN.md's "never over-explain". */
|
| 45 |
+
.autoc-empty {
|
| 46 |
+
margin: 0;
|
| 47 |
+
color: var(--lp-muted);
|
| 48 |
+
font-size: var(--lp-fs-sm);
|
| 49 |
+
max-width: 52ch;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
.autoc-msg {
|
| 53 |
+
max-width: 60ch;
|
| 54 |
+
padding: 10px 14px;
|
| 55 |
+
border-radius: var(--lp-r-md);
|
| 56 |
+
font-size: var(--lp-fs-sm);
|
| 57 |
+
line-height: 1.5;
|
| 58 |
+
white-space: pre-wrap;
|
| 59 |
+
overflow-wrap: anywhere;
|
| 60 |
+
}
|
| 61 |
+
.autoc-msg.is-you {
|
| 62 |
+
align-self: flex-end;
|
| 63 |
+
background: var(--lp-surface-2);
|
| 64 |
+
color: var(--lp-ink);
|
| 65 |
+
}
|
| 66 |
+
.autoc-msg.is-agent {
|
| 67 |
+
align-self: flex-start;
|
| 68 |
+
border: 1px solid var(--lp-line);
|
| 69 |
+
color: var(--lp-ink);
|
| 70 |
+
}
|
| 71 |
+
/* β A FAILED TURN IS A MESSAGE, NOT A TOAST. A toast is gone before a reader has finished the
|
| 72 |
+
sentence, and this surface's failures are things you act on (rephrase, pick a database).
|
| 73 |
+
β `--lp-red-deep` / `--lp-red-tint`, CHECKED AGAINST THE PALETTE, not typed from memory: the
|
| 74 |
+
first draft of this rule used `--lp-danger` and `--lp-danger-line`, and NEITHER EXISTS. A ghost
|
| 75 |
+
custom property does not fail loudly β it falls back or inherits β so the error message would
|
| 76 |
+
have painted in ordinary ink and looked like an answer. Same defect T14 caught on
|
| 77 |
+
`--lp-ink-muted` in the same wave; the palette is the only source worth trusting. */
|
| 78 |
+
.autoc-msg.is-error {
|
| 79 |
+
align-self: flex-start;
|
| 80 |
+
border: 1px solid var(--lp-red);
|
| 81 |
+
background: var(--lp-red-tint);
|
| 82 |
+
color: var(--lp-red-deep);
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
/* What the model asked for and the validator would not keep. β It renders as ORDINARY content,
|
| 86 |
+
not as a warning ornament: R6's standing rule is that a limit must be REPORTED, and a report
|
| 87 |
+
nobody reads because it looks like chrome is the same as no report. */
|
| 88 |
+
.autoc-dropped {
|
| 89 |
+
align-self: flex-start;
|
| 90 |
+
max-width: 60ch;
|
| 91 |
+
margin: 0;
|
| 92 |
+
padding: 8px 12px;
|
| 93 |
+
border-left: 2px solid var(--lp-line);
|
| 94 |
+
color: var(--lp-muted);
|
| 95 |
+
font-size: var(--lp-fs-xs);
|
| 96 |
+
line-height: 1.5;
|
| 97 |
+
}
|
| 98 |
+
.autoc-dropped ul { margin: 4px 0 0; padding-left: 18px; }
|
| 99 |
+
|
| 100 |
+
.autoc-compose {
|
| 101 |
+
flex: 0 0 auto;
|
| 102 |
+
display: flex;
|
| 103 |
+
gap: 8px;
|
| 104 |
+
align-items: flex-end;
|
| 105 |
+
padding: 12px 24px 20px;
|
| 106 |
+
border-top: 1px solid var(--lp-line);
|
| 107 |
+
}
|
| 108 |
+
.autoc-input {
|
| 109 |
+
flex: 1 1 auto;
|
| 110 |
+
min-width: 0;
|
| 111 |
+
resize: none;
|
| 112 |
+
font: inherit;
|
| 113 |
+
font-size: var(--lp-fs-sm);
|
| 114 |
+
line-height: 1.5;
|
| 115 |
+
padding: 8px 10px;
|
| 116 |
+
border: 1px solid var(--lp-line);
|
| 117 |
+
border-radius: var(--lp-r-md);
|
| 118 |
+
background: var(--lp-surface);
|
| 119 |
+
color: var(--lp-ink);
|
| 120 |
+
}
|
| 121 |
+
.autoc-input:focus-visible {
|
| 122 |
+
outline: 2px solid var(--lp-blue-deep);
|
| 123 |
+
outline-offset: 1px;
|
| 124 |
+
}
|
| 125 |
+
|
| 126 |
+
/* The pending row. β It uses `.lp-spin`, the ONE loading mark app-wide (purple as of this wave's
|
| 127 |
+
T24) β a second spinner vocabulary in a new surface is how an app stops having one. */
|
| 128 |
+
.autoc-pending {
|
| 129 |
+
display: flex;
|
| 130 |
+
align-items: center;
|
| 131 |
+
gap: 8px;
|
| 132 |
+
align-self: flex-start;
|
| 133 |
+
color: var(--lp-muted);
|
| 134 |
+
font-size: var(--lp-fs-sm);
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
/* The accept bar. It is deliberately NOT inside the transcript: accepting is an act on the
|
| 138 |
+
AUTOMATION, not a reply in the conversation, and putting it in the log would make it scroll
|
| 139 |
+
away from the thing it acts on. */
|
| 140 |
+
.autoc-accept {
|
| 141 |
+
flex: 0 0 auto;
|
| 142 |
+
display: flex;
|
| 143 |
+
align-items: center;
|
| 144 |
+
gap: 10px;
|
| 145 |
+
padding: 10px 24px;
|
| 146 |
+
border-top: 1px solid var(--lp-line);
|
| 147 |
+
background: var(--lp-surface-2);
|
| 148 |
+
font-size: var(--lp-fs-sm);
|
| 149 |
+
}
|
| 150 |
+
.autoc-accept-note { color: var(--lp-muted); margin-right: auto; }
|
| 151 |
+
|
| 152 |
+
/* ββ the run log's note disclosure (R21 / W34-T45) ββββββββββββββββββββββββββ */
|
| 153 |
+
|
| 154 |
+
/* β WAVE 34 Β· W34-T45. `notes` is up to 300 chars x 25 entries per run and was rendered
|
| 155 |
+
unconditionally over 20 runs; that is the "too wordy" the owner reported. It is still
|
| 156 |
+
unconditional for a run that FAILED or hit a cap (R6: a limit must be REPORTED) and behind this
|
| 157 |
+
disclosure otherwise.
|
| 158 |
+
β The list INSIDE stays `.auto-run-notes`, which is C's existing rule in `index.css`. Only the
|
| 159 |
+
wrapper is new, so the notes themselves cannot start looking different depending on which
|
| 160 |
+
branch rendered them. */
|
| 161 |
+
.autor-notes {
|
| 162 |
+
margin: 2px 0 0;
|
| 163 |
+
}
|
| 164 |
+
.autor-notes-toggle {
|
| 165 |
+
cursor: pointer;
|
| 166 |
+
color: var(--lp-muted);
|
| 167 |
+
font-size: var(--lp-fs-2xs);
|
| 168 |
+
/* The default triangle is the affordance; no custom marker, because a disclosure that does not
|
| 169 |
+
look like one is how a person decides there is nothing behind it. */
|
| 170 |
+
list-style: revert;
|
| 171 |
+
}
|
| 172 |
+
.autor-notes-toggle:hover { color: var(--lp-ink); }
|
| 173 |
+
.autor-notes-toggle:focus-visible {
|
| 174 |
+
outline: 2px solid var(--lp-blue-deep);
|
| 175 |
+
outline-offset: 1px;
|
| 176 |
+
}
|
web/src/automation/automationApi.ts
CHANGED
|
@@ -42,7 +42,18 @@ export type AutomationKind =
|
|
| 42 |
| "field_instagram"
|
| 43 |
| "discover_instagram"
|
| 44 |
| "discover_tiktok"
|
| 45 |
-
| "plain"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
export type RunState = "idle" | "running" | "ok" | "error" | "partial";
|
| 47 |
/*
|
| 48 |
* β WAVE 28 Β· R5 β `CaptureTier` ("anonymous" | "brightdata") IS DELETED. It was the client's
|
|
@@ -131,6 +142,20 @@ export interface Automation {
|
|
| 131 |
createdBy?: string;
|
| 132 |
nextRunAt?: string;
|
| 133 |
running?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
/**
|
| 135 |
* β WAVE 26 Β· ITEM 22 / D-70 / R12 β IS A PAID SEARCH ALREADY OUTSTANDING AT THE PROVIDER?
|
| 136 |
*
|
|
@@ -471,6 +496,18 @@ export interface ActionCatalogRow {
|
|
| 471 |
group: string;
|
| 472 |
ready: boolean;
|
| 473 |
detail: string;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 474 |
/**
|
| 475 |
* β WAVE 24 / C-ACT β THE SERVER DECIDES THE MENU'S GROUP ORDER, and the client sorts by
|
| 476 |
* this number and **never by a literal list of group names**. A hard-coded
|
|
@@ -976,6 +1013,66 @@ export function createAutomation(body: unknown): Promise<{ automation: Automatio
|
|
| 976 |
return send("/automations", { method: "POST", body: JSON.stringify(body) });
|
| 977 |
}
|
| 978 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
export function patchAutomation(
|
| 980 |
id: string,
|
| 981 |
body: unknown
|
|
@@ -1153,7 +1250,7 @@ export function runBlock(a: Automation): { blocked: boolean; label: string; why:
|
|
| 1153 |
label: "Waiting for results",
|
| 1154 |
why:
|
| 1155 |
"The search is already running at the provider and has been paid for. It collects on " +
|
| 1156 |
-
"its own
|
| 1157 |
};
|
| 1158 |
}
|
| 1159 |
return { blocked: false, label: "", why: "" };
|
|
@@ -1176,7 +1273,7 @@ export const COUNT_LABELS: [string, string][] = [
|
|
| 1176 |
// property of the data and usually benign; "not written β the database is
|
| 1177 |
// full" is a property of the system and never is. One chip for both is how a
|
| 1178 |
// time series stops growing without anybody being told.
|
| 1179 |
-
["capped", "Not written
|
| 1180 |
["source_rows", "Rows read"],
|
| 1181 |
["profiles", "Profiles"],
|
| 1182 |
["ok", "Read in full"],
|
|
|
|
| 42 |
| "field_instagram"
|
| 43 |
| "discover_instagram"
|
| 44 |
| "discover_tiktok"
|
| 45 |
+
| "plain"
|
| 46 |
+
/**
|
| 47 |
+
* ββ WAVE 34 Β· CONTRACT C3 (R13) β an AI enrichment COLUMN, surfaced here as an agent.
|
| 48 |
+
*
|
| 49 |
+
* β NO AUTOMATION IS EVER STORED WITH THIS KIND. It arrives only on the SYNTHETIC rows
|
| 50 |
+
* `routes_automation._field_agent_rows` derives from field definitions, so it is absent from
|
| 51 |
+
* the server's `KINDS`, uncreatable by construction, and a `POST /automations` naming it is
|
| 52 |
+
* refused by the ordinary save door. It is in this union because the wire carries it and the
|
| 53 |
+
* rail renders it β the comment above this type records what it costs when the wire carries a
|
| 54 |
+
* value the caller cannot name.
|
| 55 |
+
*/
|
| 56 |
+
| "field_agent";
|
| 57 |
export type RunState = "idle" | "running" | "ok" | "error" | "partial";
|
| 58 |
/*
|
| 59 |
* β WAVE 28 Β· R5 β `CaptureTier` ("anonymous" | "brightdata") IS DELETED. It was the client's
|
|
|
|
| 142 |
createdBy?: string;
|
| 143 |
nextRunAt?: string;
|
| 144 |
running?: boolean;
|
| 145 |
+
/**
|
| 146 |
+
* ββ WAVE 34 Β· CONTRACTS C3 + C4 β WHY THIS AGENT CANNOT BE DELETED, as a slug.
|
| 147 |
+
*
|
| 148 |
+
* Absent on an ordinary agent. `"field_agent"` is an AI enrichment column surfaced here (R13);
|
| 149 |
+
* `"odoo_sync"` is the connector's own schedule (R22). β A STRING, NOT A BOOLEAN: the two
|
| 150 |
+
* contracts disagreed about that (ask `E-13`), and a slug is what lets the delete refusal NAME
|
| 151 |
+
* the reason and send the reader to the right place. Truthiness still answers "is this a system
|
| 152 |
+
* agent", so a caller that only needs that can keep asking it that way.
|
| 153 |
+
*
|
| 154 |
+
* β IT IS NOT A PERMISSION. The server refuses the delete with `409 system_agent` whatever the
|
| 155 |
+
* client renders; this field exists so the UI can explain BEFORE the click rather than after,
|
| 156 |
+
* and hiding a control is never the wall.
|
| 157 |
+
*/
|
| 158 |
+
system?: string;
|
| 159 |
/**
|
| 160 |
* β WAVE 26 Β· ITEM 22 / D-70 / R12 β IS A PAID SEARCH ALREADY OUTSTANDING AT THE PROVIDER?
|
| 161 |
*
|
|
|
|
| 496 |
group: string;
|
| 497 |
ready: boolean;
|
| 498 |
detail: string;
|
| 499 |
+
/**
|
| 500 |
+
* ββ WAVE 34 Β· W34-T44 / R18 β MAY THE PICKER OFFER IT? Distinct from `ready`, and the
|
| 501 |
+
* difference is who the row is FOR. `ready:false` is a row a person SHOULD see and cannot use
|
| 502 |
+
* yet (it answers "can this product do X"); `menu:false` is a row a person should never be
|
| 503 |
+
* offered but the builder must still be able to READ, because a step stored before the rule
|
| 504 |
+
* changed still has to render a name instead of its raw kind token.
|
| 505 |
+
* R18 collapsed six web actions into one; the other five carry `menu:false` and stay in the
|
| 506 |
+
* catalog because the surviving action composes its own steps out of them server-side.
|
| 507 |
+
* β OPTIONAL FOR ONE REASON ONLY: a server that predates this wave stamps nothing, and an
|
| 508 |
+
* absent value must mean OFFERED. `groupActions` tests `=== false` for exactly that.
|
| 509 |
+
*/
|
| 510 |
+
menu?: boolean;
|
| 511 |
/**
|
| 512 |
* β WAVE 24 / C-ACT β THE SERVER DECIDES THE MENU'S GROUP ORDER, and the client sorts by
|
| 513 |
* this number and **never by a literal list of group names**. A hard-coded
|
|
|
|
| 1013 |
return send("/automations", { method: "POST", body: JSON.stringify(body) });
|
| 1014 |
}
|
| 1015 |
|
| 1016 |
+
/**
|
| 1017 |
+
* ββ WAVE 34 Β· W34-T42 / R17 β a plain-English description becomes a DRAFT flow.
|
| 1018 |
+
*
|
| 1019 |
+
* β THE DOOR IS NOT NEW; THE CALLER IS. `POST /api/v1/automations/draft` shipped in wave 33 and
|
| 1020 |
+
* `D-269` books it as having had NO front-end caller ever since β a whole, correct, unreachable
|
| 1021 |
+
* feature, which is this repo's most-repeated failure shape. This function is that caller.
|
| 1022 |
+
*
|
| 1023 |
+
* β `saved: false` IS LOAD-BEARING AND MUST NOT BE OPTIMISED AWAY. The route deliberately writes
|
| 1024 |
+
* NOTHING: accepting a draft is the ordinary `createAutomation`, so a drafted flow and a
|
| 1025 |
+
* hand-built one are the same object by construction rather than by resemblance. A caller that
|
| 1026 |
+
* treated the response as "created" would be inventing a second write path.
|
| 1027 |
+
*
|
| 1028 |
+
* β `dropped` IS THE HONEST HALF AND IT IS NOT AN ERROR LIST. The route runs the REAL save-door
|
| 1029 |
+
* validator over the model's output and diffs it, because `clean_actions` silently discards a
|
| 1030 |
+
* config key it does not recognise and still answers 200. Anything in `dropped` is something the
|
| 1031 |
+
* person was shown that will NOT be there afterwards; rendering it is R6's second sentence.
|
| 1032 |
+
*/
|
| 1033 |
+
export interface AutomationDraft {
|
| 1034 |
+
draft: {
|
| 1035 |
+
name?: string;
|
| 1036 |
+
/**
|
| 1037 |
+
* β A STRING, NOT `{key}`, AND THE FIRST DRAFT OF THIS TYPE GOT IT WRONG. The route returns
|
| 1038 |
+
* `"trigger": _trig or "manual"` β a bare trigger KEY β while a stored automation's own
|
| 1039 |
+
* `trigger` is an object. Typed as `{key?: string}` it compiled, `draft.trigger?.key` read
|
| 1040 |
+
* `undefined` on every draft, and every accepted flow would have silently landed with no
|
| 1041 |
+
* trigger at all. Nothing would have been red: the save succeeds, the flow is valid, and it
|
| 1042 |
+
* simply never fires. Read off the route's own `return`, not from the sibling shape.
|
| 1043 |
+
*/
|
| 1044 |
+
trigger?: string;
|
| 1045 |
+
table?: string;
|
| 1046 |
+
actions?: { id?: string; kind?: string; config?: Record<string, unknown>; why?: string;
|
| 1047 |
+
needs?: string[]; unknownField?: string }[];
|
| 1048 |
+
};
|
| 1049 |
+
provider?: string;
|
| 1050 |
+
/**
|
| 1051 |
+
* What the real save-door validator discarded from the model's answer (D-75's channel).
|
| 1052 |
+
*
|
| 1053 |
+
* β OBJECTS, NOT SENTENCES, AND TYPING IT `string[]` COMPILED. The route builds
|
| 1054 |
+
* `{kind, keys}` per step whose config the validator changed. Rendered as a string each row
|
| 1055 |
+
* would have painted `[object Object]` β a report about what silently changed, itself silently
|
| 1056 |
+
* wrong. Read off the route's `dropped.append(...)`, not from the shape of `notes` beside it.
|
| 1057 |
+
*/
|
| 1058 |
+
dropped?: { kind: string; keys: string[] }[];
|
| 1059 |
+
/**
|
| 1060 |
+
* The route's OTHER honest channel, and it is not the same as `dropped`: a trigger this
|
| 1061 |
+
* deployment does not have (corrected to manual, and said), or steps beyond
|
| 1062 |
+
* `MAX_DRAFT_ACTIONS` that were not kept. Both are things the reader must be told, and both
|
| 1063 |
+
* arrive here rather than in `dropped`.
|
| 1064 |
+
*/
|
| 1065 |
+
notes?: string[];
|
| 1066 |
+
saved?: boolean;
|
| 1067 |
+
}
|
| 1068 |
+
|
| 1069 |
+
export function draftAutomation(prompt: string): Promise<AutomationDraft> {
|
| 1070 |
+
return send("/automations/draft", {
|
| 1071 |
+
method: "POST",
|
| 1072 |
+
body: JSON.stringify({ prompt }),
|
| 1073 |
+
});
|
| 1074 |
+
}
|
| 1075 |
+
|
| 1076 |
export function patchAutomation(
|
| 1077 |
id: string,
|
| 1078 |
body: unknown
|
|
|
|
| 1250 |
label: "Waiting for results",
|
| 1251 |
why:
|
| 1252 |
"The search is already running at the provider and has been paid for. It collects on " +
|
| 1253 |
+
"its own. Starting another would buy a second search.",
|
| 1254 |
};
|
| 1255 |
}
|
| 1256 |
return { blocked: false, label: "", why: "" };
|
|
|
|
| 1273 |
// property of the data and usually benign; "not written β the database is
|
| 1274 |
// full" is a property of the system and never is. One chip for both is how a
|
| 1275 |
// time series stops growing without anybody being told.
|
| 1276 |
+
["capped", "Not written. The database is full"],
|
| 1277 |
["source_rows", "Rows read"],
|
| 1278 |
["profiles", "Profiles"],
|
| 1279 |
["ok", "Read in full"],
|
web/src/automation/steps.ts
CHANGED
|
@@ -367,11 +367,21 @@ export interface ActionGroup {
|
|
| 367 |
* printed caption ("Web action", "Database", "Connected", "Advanced logic") β the server sends
|
| 368 |
* no separate `groupLabel` for actions β so this returns `key` and the caller prints it. Adding
|
| 369 |
* a `label` member that merely copied `key` would invent a second name for one string.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 370 |
*/
|
| 371 |
export function groupActions(rows: ActionCatalogRow[]): ActionGroup[] {
|
| 372 |
const out: ActionGroup[] = [];
|
| 373 |
const byKey = new Map<string, ActionGroup>();
|
| 374 |
for (const c of rows) {
|
|
|
|
| 375 |
const key = c.group || "";
|
| 376 |
let g = byKey.get(key);
|
| 377 |
if (!g) {
|
|
|
|
| 367 |
* printed caption ("Web action", "Database", "Connected", "Advanced logic") β the server sends
|
| 368 |
* no separate `groupLabel` for actions β so this returns `key` and the caller prints it. Adding
|
| 369 |
* a `label` member that merely copied `key` would invent a second name for one string.
|
| 370 |
+
*
|
| 371 |
+
* ββ WAVE 34 Β· W34-T44 / R18 β IT IS THE MENU, SO IT HONOURS `menu`. The server ships rows the
|
| 372 |
+
* picker must not OFFER but the builder must still be able to READ: R18 collapsed six web actions
|
| 373 |
+
* into one, and the other five stay in the catalog because `_ai_agent_plan` composes the surviving
|
| 374 |
+
* action's steps out of them. This is the ONE place the distinction is applied, because this is
|
| 375 |
+
* the one function whose output is a menu β the label lookup in `AutomationBuilder` walks the
|
| 376 |
+
* unfiltered catalog on purpose, so a step stored last month still renders a name and not a token.
|
| 377 |
+
* β `!== false` and not truthiness: the server stamps a real boolean on every row, and an older
|
| 378 |
+
* server that stamps nothing must keep offering its rows rather than showing an empty picker.
|
| 379 |
*/
|
| 380 |
export function groupActions(rows: ActionCatalogRow[]): ActionGroup[] {
|
| 381 |
const out: ActionGroup[] = [];
|
| 382 |
const byKey = new Map<string, ActionGroup>();
|
| 383 |
for (const c of rows) {
|
| 384 |
+
if (c.menu === false) continue;
|
| 385 |
const key = c.group || "";
|
| 386 |
let g = byKey.get(key);
|
| 387 |
if (!g) {
|
web/src/customer-grid/ColumnMenu.tsx
CHANGED
|
@@ -84,6 +84,18 @@ interface FieldConfigExtra {
|
|
| 84 |
* it: `_clean_field` accepts a code column without one and it renders as plain text, so this
|
| 85 |
* is an ordinary edit rather than a create requirement. */
|
| 86 |
code?: { language: string };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
}
|
| 88 |
|
| 89 |
interface ColumnMenuProps {
|
|
@@ -230,6 +242,11 @@ interface ColumnMenuProps {
|
|
| 230 |
* the control is not rendered at all, so a base field cannot even be offered the action.
|
| 231 |
*/
|
| 232 |
onDelete?: () => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
/** Wave-5 item 1 β Duplicate field. Supplied for creatable strata only (base Odoo fields
|
| 234 |
* offer no Duplicate β cloning the source of truth into an editable copy is out of scope). */
|
| 235 |
onDuplicate?: () => void;
|
|
@@ -932,7 +949,7 @@ const AUTOMATION_KINDS: { key: string; label: string; hint: string }[] = [
|
|
| 932 |
key: "instagram_profile",
|
| 933 |
label: "Instagram profile",
|
| 934 |
hint: "Reads what is anonymously public about the profile in the chosen URL column, and "
|
| 935 |
-
+ "writes the result here. Runs from the Automation surface
|
| 936 |
},
|
| 937 |
];
|
| 938 |
|
|
@@ -1012,6 +1029,42 @@ function ScopeControl({
|
|
| 1012 |
* cannot drift: a FORMULA gets its source editor with live validation and a reference picker;
|
| 1013 |
* a RATING gets its star count (the contract's 2..10).
|
| 1014 |
*/
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1015 |
function ExtraTypeEditor({
|
| 1016 |
kind,
|
| 1017 |
fields,
|
|
@@ -1022,6 +1075,8 @@ function ExtraTypeEditor({
|
|
| 1022 |
onMaxValue,
|
| 1023 |
codeLanguage,
|
| 1024 |
onCodeLanguage,
|
|
|
|
|
|
|
| 1025 |
idPrefix,
|
| 1026 |
showFormulaHelp = true,
|
| 1027 |
automationKind = "instagram_profile",
|
|
@@ -1073,6 +1128,12 @@ function ExtraTypeEditor({
|
|
| 1073 |
* which draft it is editing. */
|
| 1074 |
codeLanguage: string;
|
| 1075 |
onCodeLanguage: (v: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1076 |
idPrefix: string;
|
| 1077 |
showFormulaHelp?: boolean;
|
| 1078 |
/** C5-AUTOFIELD β the automation column's config drafts. Optional so the call sites that
|
|
@@ -1232,6 +1293,140 @@ function ExtraTypeEditor({
|
|
| 1232 |
</label>
|
| 1233 |
);
|
| 1234 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1235 |
if (kind === "created_time") {
|
| 1236 |
return (
|
| 1237 |
<div className="cg-field-hint">
|
|
@@ -1661,7 +1856,7 @@ function ExtraTypeEditor({
|
|
| 1661 |
<RollupConditionList
|
| 1662 |
idPrefix={`${idPrefix}-rollup-cond`}
|
| 1663 |
title="Then keep only"
|
| 1664 |
-
hint={"Applied AFTER the window above, over exactly the records it kept
|
| 1665 |
+ "what a βΟ from the meanβ threshold is measured against."}
|
| 1666 |
conditions={rollupConditions}
|
| 1667 |
onConditions={(v) => onRollupConditions?.(v)}
|
|
@@ -1698,7 +1893,7 @@ function ExtraTypeEditor({
|
|
| 1698 |
</label>
|
| 1699 |
<div className="cg-field-hint">
|
| 1700 |
{rollupMode === "source"
|
| 1701 |
-
? "Answered straight from the source
|
| 1702 |
: "Folds the records a link column reaches, which must already exist in this workspace."}
|
| 1703 |
</div>
|
| 1704 |
{rollupMode === "source" ? sourceEditor : linkEditor}
|
|
@@ -1740,6 +1935,7 @@ export default function ColumnMenu({
|
|
| 1740 |
onUnpin,
|
| 1741 |
onPeriod,
|
| 1742 |
onDelete,
|
|
|
|
| 1743 |
onDuplicate,
|
| 1744 |
onPermissions,
|
| 1745 |
onFormat,
|
|
@@ -1776,6 +1972,31 @@ export default function ColumnMenu({
|
|
| 1776 |
/** β Wave-27 item 13 (R13) β the create form's code LANGUAGE. Seeded to `plain`, which is
|
| 1777 |
* also what an absent bag means, so creating without touching this stores nothing extra. */
|
| 1778 |
const [codeLanguage, setCodeLanguage] = useState<string>("plain");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1779 |
/** C5-AUTOFIELD β the create form's automation config. */
|
| 1780 |
const [automationKind, setAutomationKind] = useState(AUTOMATION_KINDS[0].key);
|
| 1781 |
const [automationUrlField, setAutomationUrlField] = useState(
|
|
@@ -1987,6 +2208,10 @@ export default function ColumnMenu({
|
|
| 1987 |
// creating one without this gate produces a column that is named, configured and simply
|
| 1988 |
// absent on the next read. The same "permanently blank column" argument as the two rules
|
| 1989 |
// above, one step worse β the column does not exist at all.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1990 |
(kind !== "link" || linkTable !== "") &&
|
| 1991 |
(kind !== "rollup" || (rollupMode === "source"
|
| 1992 |
// β 2026-08-09 β the read-through bag. ALL FOUR keys are required by `_clean_rollup`
|
|
@@ -2015,6 +2240,26 @@ export default function ColumnMenu({
|
|
| 2015 |
// "switch this column back to Plain text" a change that saves. Omitting it instead would
|
| 2016 |
// inherit the previous language (the patch path's omit-means-keep rule).
|
| 2017 |
if (t === "code") return { code: { language: lang } };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2018 |
if (t === "automation")
|
| 2019 |
return {
|
| 2020 |
automation: {
|
|
@@ -2114,7 +2359,20 @@ export default function ColumnMenu({
|
|
| 2114 |
swapToNewType != null &&
|
| 2115 |
swapLabel.trim() !== "" &&
|
| 2116 |
(!needsOptions(swapToNewType) || swapNewOptions.length > 0) &&
|
| 2117 |
-
(swapToNewType !== "formula" || swapFormulaCheck.ok)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2118 |
|
| 2119 |
const createAndSwap = () => {
|
| 2120 |
if (!swapToNewType || !canCreateSwap) return;
|
|
@@ -2219,6 +2477,11 @@ export default function ColumnMenu({
|
|
| 2219 |
editLinkSingle !== (field.link?.single === true));
|
| 2220 |
const canSaveEdit =
|
| 2221 |
!editBlocked &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2222 |
(editingRelational
|
| 2223 |
? (editRollupValid && (editRelationalTouched || editNameDirty) &&
|
| 2224 |
(field.type !== "link" || editLinkTable !== ""))
|
|
@@ -2282,6 +2545,27 @@ export default function ColumnMenu({
|
|
| 2282 |
},
|
| 2283 |
}
|
| 2284 |
: {}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2285 |
...(editNameDirty ? { label: cleanRename } : {}),
|
| 2286 |
}
|
| 2287 |
);
|
|
@@ -2487,6 +2771,8 @@ export default function ColumnMenu({
|
|
| 2487 |
</>
|
| 2488 |
)}
|
| 2489 |
<ExtraTypeEditor
|
|
|
|
|
|
|
| 2490 |
kind={kind}
|
| 2491 |
fields={fields}
|
| 2492 |
formulaText={formulaText}
|
|
@@ -2657,6 +2943,8 @@ export default function ColumnMenu({
|
|
| 2657 |
{field.type === "rollup" ? "Rollup" : "Linked records"}
|
| 2658 |
</span>
|
| 2659 |
<ExtraTypeEditor
|
|
|
|
|
|
|
| 2660 |
kind={field.type}
|
| 2661 |
fields={fields}
|
| 2662 |
linkTargets={linkTargets}
|
|
@@ -2729,6 +3017,8 @@ export default function ColumnMenu({
|
|
| 2729 |
<div className="cg-type-block">
|
| 2730 |
<span className="cg-type-title">Automation</span>
|
| 2731 |
<ExtraTypeEditor
|
|
|
|
|
|
|
| 2732 |
kind="automation"
|
| 2733 |
fields={fields}
|
| 2734 |
formulaText={editFormulaText}
|
|
@@ -2878,6 +3168,8 @@ export default function ColumnMenu({
|
|
| 2878 |
)}
|
| 2879 |
{!onRetype && field.type === "formula" && onFormula && (
|
| 2880 |
<ExtraTypeEditor
|
|
|
|
|
|
|
| 2881 |
kind="formula"
|
| 2882 |
fields={fields}
|
| 2883 |
formulaText={editFormulaText}
|
|
@@ -2978,6 +3270,8 @@ export default function ColumnMenu({
|
|
| 2978 |
/>
|
| 2979 |
</label>
|
| 2980 |
<ExtraTypeEditor
|
|
|
|
|
|
|
| 2981 |
kind={swapToNewType}
|
| 2982 |
fields={fields}
|
| 2983 |
formulaText={swapFormulaText}
|
|
@@ -3501,6 +3795,40 @@ export default function ColumnMenu({
|
|
| 3501 |
>
|
| 3502 |
<MenuLabel icon="hide" text="Hide field" />
|
| 3503 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3504 |
{!schemaLocked && onDelete && (
|
| 3505 |
<button
|
| 3506 |
type="button"
|
|
@@ -3523,11 +3851,11 @@ export default function ColumnMenu({
|
|
| 3523 |
field.measure
|
| 3524 |
? "Removes this formula column from the workspace."
|
| 3525 |
: field.automation?.preset === true
|
| 3526 |
-
? "Part of this database's built-in schema
|
| 3527 |
"next run. Hide it to keep it off the grid."
|
| 3528 |
: field.type === "rollup" || field.type === "link" ||
|
| 3529 |
field.type === "formula"
|
| 3530 |
-
? "Removes this column. No values are lost
|
| 3531 |
: "Removes this field AND its values on every row."
|
| 3532 |
}
|
| 3533 |
onClick={() => (confirmDelete ? onDelete() : setConfirmDelete(true))}
|
|
|
|
| 84 |
* it: `_clean_field` accepts a code column without one and it renders as plain text, so this
|
| 85 |
* is an ordinary edit rather than a create requirement. */
|
| 86 |
code?: { language: string };
|
| 87 |
+
/** ββ Wave-34 (owner ruling R13) β an AI ENRICHMENT column's config. REQUIRED, like `link`
|
| 88 |
+
* and `rollup` and unlike `code`: `_clean_field` returns None for an `ai_enrich` column with
|
| 89 |
+
* no bag, because a prompt is the only thing that can ever produce a value here. Shaped
|
| 90 |
+
* exactly as `core.user_tables._clean_ai_enrich` stores it, so the panel and the store cannot
|
| 91 |
+
* disagree about a key name. */
|
| 92 |
+
aiEnrich?: {
|
| 93 |
+
prompt: string;
|
| 94 |
+
model?: string;
|
| 95 |
+
maxTokens: number;
|
| 96 |
+
trigger: { mode: string; cron?: string };
|
| 97 |
+
overwrite: string;
|
| 98 |
+
};
|
| 99 |
}
|
| 100 |
|
| 101 |
interface ColumnMenuProps {
|
|
|
|
| 242 |
* the control is not rendered at all, so a base field cannot even be offered the action.
|
| 243 |
*/
|
| 244 |
onDelete?: () => void;
|
| 245 |
+
/** ββ Wave-34 (R13) β run this AI enrichment column over many rows.
|
| 246 |
+
* `scope` is `"blank"` (only cells still empty) or `"always"` (every row the AI
|
| 247 |
+
* wrote). OPTIONAL for `onDelete`'s reason: a host that has not wired the door shows
|
| 248 |
+
* no row, rather than a row that does nothing. */
|
| 249 |
+
onEnrich?: (scope: "blank" | "always") => void;
|
| 250 |
/** Wave-5 item 1 β Duplicate field. Supplied for creatable strata only (base Odoo fields
|
| 251 |
* offer no Duplicate β cloning the source of truth into an editable copy is out of scope). */
|
| 252 |
onDuplicate?: () => void;
|
|
|
|
| 949 |
key: "instagram_profile",
|
| 950 |
label: "Instagram profile",
|
| 951 |
hint: "Reads what is anonymously public about the profile in the chosen URL column, and "
|
| 952 |
+
+ "writes the result here. Runs from the Automation surface, on demand or on a schedule.",
|
| 953 |
},
|
| 954 |
];
|
| 955 |
|
|
|
|
| 1029 |
* cannot drift: a FORMULA gets its source editor with live validation and a reference picker;
|
| 1030 |
* a RATING gets its star count (the contract's 2..10).
|
| 1031 |
*/
|
| 1032 |
+
/**
|
| 1033 |
+
* ββ Wave-34 (R13) β the AI enrichment draft, as the panel holds it.
|
| 1034 |
+
*
|
| 1035 |
+
* FLAT, while the wire bag nests `trigger`. That is deliberate: a form is edited one control at a
|
| 1036 |
+
* time and a nested draft makes every setter a two-level spread, which is where a patcher drops a
|
| 1037 |
+
* sibling key. `extraFor` does the one nesting at the moment of sending.
|
| 1038 |
+
*/
|
| 1039 |
+
interface AiEnrichDraft {
|
| 1040 |
+
prompt: string;
|
| 1041 |
+
model: string;
|
| 1042 |
+
maxTokens: number;
|
| 1043 |
+
mode: string;
|
| 1044 |
+
cron: string;
|
| 1045 |
+
overwrite: string;
|
| 1046 |
+
}
|
| 1047 |
+
|
| 1048 |
+
/** The three moments an enrichment column can run. Mirrors `AI_ENRICH_TRIGGER_MODES`. */
|
| 1049 |
+
const AI_ENRICH_MODES: ReadonlyArray<{ value: string; label: string }> = [
|
| 1050 |
+
{ value: "manual", label: "Only when I ask" },
|
| 1051 |
+
{ value: "on_change", label: "When a referenced column changes" },
|
| 1052 |
+
{ value: "schedule", label: "On a schedule" },
|
| 1053 |
+
];
|
| 1054 |
+
|
| 1055 |
+
/**
|
| 1056 |
+
* What an AUTOMATIC run may replace. β There is no "overwrite anything" option and there must
|
| 1057 |
+
* not be: a cell somebody typed into is never overwritten by an agent, whatever is chosen here,
|
| 1058 |
+
* so "Every row" means every row THE AI WROTE. The labels say that rather than leaving a user to
|
| 1059 |
+
* discover it, because a control that quietly means less than it says is worse than a narrower
|
| 1060 |
+
* control (`core.user_tables.ai_enrich_human_authored` is the law it is describing).
|
| 1061 |
+
*/
|
| 1062 |
+
const AI_ENRICH_OVERWRITE: ReadonlyArray<{ value: string; label: string }> = [
|
| 1063 |
+
{ value: "blank", label: "Only rows that are still empty" },
|
| 1064 |
+
{ value: "stale", label: "Empty rows, and AI answers whose inputs have changed" },
|
| 1065 |
+
{ value: "always", label: "Every row the AI wrote, every run" },
|
| 1066 |
+
];
|
| 1067 |
+
|
| 1068 |
function ExtraTypeEditor({
|
| 1069 |
kind,
|
| 1070 |
fields,
|
|
|
|
| 1075 |
onMaxValue,
|
| 1076 |
codeLanguage,
|
| 1077 |
onCodeLanguage,
|
| 1078 |
+
aiEnrich,
|
| 1079 |
+
onAiEnrich,
|
| 1080 |
idPrefix,
|
| 1081 |
showFormulaHelp = true,
|
| 1082 |
automationKind = "instagram_profile",
|
|
|
|
| 1128 |
* which draft it is editing. */
|
| 1129 |
codeLanguage: string;
|
| 1130 |
onCodeLanguage: (v: string) => void;
|
| 1131 |
+
/** ββ Wave-34 (R13) β the AI enrichment draft. REQUIRED for `codeLanguage`'s exact reason and
|
| 1132 |
+
* one step stronger: this pane renders the ONLY control that can make an `ai_enrich` column
|
| 1133 |
+
* creatable at all, so an unwired mount would render a prompt box that saves nothing and a
|
| 1134 |
+
* Create button that `canCreate` then blocks forever with no visible cause. */
|
| 1135 |
+
aiEnrich: AiEnrichDraft;
|
| 1136 |
+
onAiEnrich: (patch: Partial<AiEnrichDraft>) => void;
|
| 1137 |
idPrefix: string;
|
| 1138 |
showFormulaHelp?: boolean;
|
| 1139 |
/** C5-AUTOFIELD β the automation column's config drafts. Optional so the call sites that
|
|
|
|
| 1293 |
</label>
|
| 1294 |
);
|
| 1295 |
}
|
| 1296 |
+
if (kind === "ai_enrich") {
|
| 1297 |
+
// ββ WAVE-34 (owner ruling R13) β *"a prompt per row that populates text. Detailed
|
| 1298 |
+
// configuration in the field's own config, including a token-usage limit."*
|
| 1299 |
+
//
|
| 1300 |
+
// β THE PROMPT IS FIRST AND IT IS THE ONLY REQUIRED CONTROL, because it is the only one
|
| 1301 |
+
// without which the column can never hold anything (`_clean_field` refuses the field). The
|
| 1302 |
+
// other four all have a working default, so a user who types one sentence and presses Create
|
| 1303 |
+
// gets a column that runs.
|
| 1304 |
+
// β Every control below is an EXISTING one (`cg-input`, `cg-select`, `cg-field-hint`,
|
| 1305 |
+
// `<label><span>β¦`): `index.css` belongs to another lane this wave, so this panel is built
|
| 1306 |
+
// from the vocabulary already in the file rather than from a rule I cannot add.
|
| 1307 |
+
const refs = Array.from(
|
| 1308 |
+
new Set((aiEnrich.prompt.match(/\{([a-zA-Z0-9_]{1,60})\}/g) ?? []).map((m) => m.slice(1, -1)))
|
| 1309 |
+
);
|
| 1310 |
+
const known = new Set(fields.map((f) => f.key));
|
| 1311 |
+
const unknown = refs.filter((r) => !known.has(r));
|
| 1312 |
+
return (
|
| 1313 |
+
<>
|
| 1314 |
+
<label>
|
| 1315 |
+
<span>Prompt</span>
|
| 1316 |
+
<textarea
|
| 1317 |
+
id={`${idPrefix}-ai-prompt`}
|
| 1318 |
+
className="cg-input"
|
| 1319 |
+
rows={3}
|
| 1320 |
+
value={aiEnrich.prompt}
|
| 1321 |
+
placeholder="Summarise what {name} sells, in one line."
|
| 1322 |
+
aria-label="Enrichment prompt"
|
| 1323 |
+
onChange={(event) => onAiEnrich({ prompt: event.target.value })}
|
| 1324 |
+
/>
|
| 1325 |
+
{/* β THE UNRESOLVED-TOKEN WARNING IS NOT DECORATION. A `{token}` naming no column is
|
| 1326 |
+
sent verbatim to the model, which then answers a different question on every row and
|
| 1327 |
+
returns text that looks exactly like a correct answer. The server reports it per row
|
| 1328 |
+
at run time; saying it here costs one run nobody has paid for yet. */}
|
| 1329 |
+
{unknown.length > 0 ? (
|
| 1330 |
+
<span className="cg-field-hint">
|
| 1331 |
+
{unknown.map((u) => `{${u}}`).join(", ")} is not a column on this database. Wrap a
|
| 1332 |
+
column key in braces to put its value into the prompt.
|
| 1333 |
+
</span>
|
| 1334 |
+
) : (
|
| 1335 |
+
<span className="cg-field-hint">
|
| 1336 |
+
Put a column key in braces to use its value, the way a formula does. Each row gets
|
| 1337 |
+
its own answer.
|
| 1338 |
+
</span>
|
| 1339 |
+
)}
|
| 1340 |
+
</label>
|
| 1341 |
+
<label>
|
| 1342 |
+
<span>When it runs</span>
|
| 1343 |
+
<select
|
| 1344 |
+
id={`${idPrefix}-ai-mode`}
|
| 1345 |
+
className="cg-select"
|
| 1346 |
+
value={aiEnrich.mode}
|
| 1347 |
+
aria-label="When the enrichment runs"
|
| 1348 |
+
onChange={(event) => onAiEnrich({ mode: event.target.value })}
|
| 1349 |
+
>
|
| 1350 |
+
{AI_ENRICH_MODES.map((m) => (
|
| 1351 |
+
<option key={m.value} value={m.value}>
|
| 1352 |
+
{m.label}
|
| 1353 |
+
</option>
|
| 1354 |
+
))}
|
| 1355 |
+
</select>
|
| 1356 |
+
</label>
|
| 1357 |
+
{/* A cadence box only where a cadence is read. A schedule stored under "Only when I ask"
|
| 1358 |
+
is a setting the user can see and nothing will ever act on, which is the shape D-246
|
| 1359 |
+
wears on the Odoo sync, so the server drops it and this never offers it. */}
|
| 1360 |
+
{aiEnrich.mode === "schedule" ? (
|
| 1361 |
+
<label>
|
| 1362 |
+
<span>Schedule</span>
|
| 1363 |
+
<input
|
| 1364 |
+
id={`${idPrefix}-ai-cron`}
|
| 1365 |
+
className="cg-input"
|
| 1366 |
+
value={aiEnrich.cron}
|
| 1367 |
+
placeholder="0 6 * * *"
|
| 1368 |
+
aria-label="Enrichment schedule"
|
| 1369 |
+
onChange={(event) => onAiEnrich({ cron: event.target.value })}
|
| 1370 |
+
/>
|
| 1371 |
+
<span className="cg-field-hint">Minute, hour, day, month, weekday.</span>
|
| 1372 |
+
</label>
|
| 1373 |
+
) : null}
|
| 1374 |
+
<label>
|
| 1375 |
+
<span>Which rows</span>
|
| 1376 |
+
<select
|
| 1377 |
+
id={`${idPrefix}-ai-overwrite`}
|
| 1378 |
+
className="cg-select"
|
| 1379 |
+
value={aiEnrich.overwrite}
|
| 1380 |
+
aria-label="Which rows a run may fill"
|
| 1381 |
+
onChange={(event) => onAiEnrich({ overwrite: event.target.value })}
|
| 1382 |
+
>
|
| 1383 |
+
{AI_ENRICH_OVERWRITE.map((o) => (
|
| 1384 |
+
<option key={o.value} value={o.value}>
|
| 1385 |
+
{o.label}
|
| 1386 |
+
</option>
|
| 1387 |
+
))}
|
| 1388 |
+
</select>
|
| 1389 |
+
<span className="cg-field-hint">
|
| 1390 |
+
A cell you have typed into is never overwritten by a run, whichever of these is chosen.
|
| 1391 |
+
</span>
|
| 1392 |
+
</label>
|
| 1393 |
+
<label>
|
| 1394 |
+
<span>Token limit per row</span>
|
| 1395 |
+
<input
|
| 1396 |
+
id={`${idPrefix}-ai-tokens`}
|
| 1397 |
+
type="number"
|
| 1398 |
+
className="cg-input"
|
| 1399 |
+
min={16}
|
| 1400 |
+
max={4000}
|
| 1401 |
+
value={aiEnrich.maxTokens}
|
| 1402 |
+
aria-label="Token limit per row"
|
| 1403 |
+
onChange={(event) => onAiEnrich({ maxTokens: Number(event.target.value) || 300 })}
|
| 1404 |
+
/>
|
| 1405 |
+
{/* R6's standing rule, at the control rather than in a support answer: a run that hits
|
| 1406 |
+
its ceiling STOPS and says how far it got, so this number is the whole budget's
|
| 1407 |
+
basis and not a soft target. */}
|
| 1408 |
+
<span className="cg-field-hint">
|
| 1409 |
+
Also sets the whole run's budget. A run that reaches it stops and reports how many
|
| 1410 |
+
rows it filled.
|
| 1411 |
+
</span>
|
| 1412 |
+
</label>
|
| 1413 |
+
<label>
|
| 1414 |
+
<span>Model</span>
|
| 1415 |
+
<input
|
| 1416 |
+
id={`${idPrefix}-ai-model`}
|
| 1417 |
+
className="cg-input"
|
| 1418 |
+
value={aiEnrich.model}
|
| 1419 |
+
placeholder="Leave empty for the default"
|
| 1420 |
+
aria-label="Enrichment model"
|
| 1421 |
+
onChange={(event) => onAiEnrich({ model: event.target.value })}
|
| 1422 |
+
/>
|
| 1423 |
+
<span className="cg-field-hint">
|
| 1424 |
+
Empty uses the cheapest provider configured on this deployment.
|
| 1425 |
+
</span>
|
| 1426 |
+
</label>
|
| 1427 |
+
</>
|
| 1428 |
+
);
|
| 1429 |
+
}
|
| 1430 |
if (kind === "created_time") {
|
| 1431 |
return (
|
| 1432 |
<div className="cg-field-hint">
|
|
|
|
| 1856 |
<RollupConditionList
|
| 1857 |
idPrefix={`${idPrefix}-rollup-cond`}
|
| 1858 |
title="Then keep only"
|
| 1859 |
+
hint={"Applied AFTER the window above, over exactly the records it kept, which is "
|
| 1860 |
+ "what a βΟ from the meanβ threshold is measured against."}
|
| 1861 |
conditions={rollupConditions}
|
| 1862 |
onConditions={(v) => onRollupConditions?.(v)}
|
|
|
|
| 1893 |
</label>
|
| 1894 |
<div className="cg-field-hint">
|
| 1895 |
{rollupMode === "source"
|
| 1896 |
+
? "Answered straight from the source. It can total hundreds of thousands of rows because none of them are copied into this database."
|
| 1897 |
: "Folds the records a link column reaches, which must already exist in this workspace."}
|
| 1898 |
</div>
|
| 1899 |
{rollupMode === "source" ? sourceEditor : linkEditor}
|
|
|
|
| 1935 |
onUnpin,
|
| 1936 |
onPeriod,
|
| 1937 |
onDelete,
|
| 1938 |
+
onEnrich,
|
| 1939 |
onDuplicate,
|
| 1940 |
onPermissions,
|
| 1941 |
onFormat,
|
|
|
|
| 1972 |
/** β Wave-27 item 13 (R13) β the create form's code LANGUAGE. Seeded to `plain`, which is
|
| 1973 |
* also what an absent bag means, so creating without touching this stores nothing extra. */
|
| 1974 |
const [codeLanguage, setCodeLanguage] = useState<string>("plain");
|
| 1975 |
+
/**
|
| 1976 |
+
* ββ Wave-34 (owner ruling R13) β the create form's AI ENRICHMENT config.
|
| 1977 |
+
*
|
| 1978 |
+
* β ONE BAG AND ONE PATCHER, not six flat props. `link`/`rollup`/`automation` above are each a
|
| 1979 |
+
* scatter of `xState`/`onXState` pairs, and the scouted consequence is concrete: the swap form
|
| 1980 |
+
* mounts `ExtraTypeEditor` WITHOUT those setters, so choosing one of those kinds there renders
|
| 1981 |
+
* a panel whose controls are wired to `undefined`. A single bag cannot be half-passed, so this
|
| 1982 |
+
* kind cannot arrive at that door inert.
|
| 1983 |
+
* β The defaults ARE the server's defaults (`AI_ENRICH_DEFAULT_TOKENS = 300`, `mode: manual`,
|
| 1984 |
+
* `overwrite: blank`), so creating without touching anything but the prompt stores exactly what
|
| 1985 |
+
* the panel showed.
|
| 1986 |
+
*/
|
| 1987 |
+
const [aiEnrich, setAiEnrich] = useState<AiEnrichDraft>(() => ({
|
| 1988 |
+
prompt: "",
|
| 1989 |
+
model: "",
|
| 1990 |
+
maxTokens: 300,
|
| 1991 |
+
mode: "manual",
|
| 1992 |
+
cron: "",
|
| 1993 |
+
overwrite: "blank",
|
| 1994 |
+
}));
|
| 1995 |
+
/** β FUNCTIONAL UPDATE, not `setAiEnrich({...aiEnrich, ...patch})`. Two controls in this panel
|
| 1996 |
+
* can change in one tick (picking `schedule` reveals the cron box, which the user then types
|
| 1997 |
+
* into), and a spread over a captured `aiEnrich` would drop whichever landed first. */
|
| 1998 |
+
const patchAiEnrich = (patch: Partial<AiEnrichDraft>) =>
|
| 1999 |
+
setAiEnrich((prev) => ({ ...prev, ...patch }));
|
| 2000 |
/** C5-AUTOFIELD β the create form's automation config. */
|
| 2001 |
const [automationKind, setAutomationKind] = useState(AUTOMATION_KINDS[0].key);
|
| 2002 |
const [automationUrlField, setAutomationUrlField] = useState(
|
|
|
|
| 2208 |
// creating one without this gate produces a column that is named, configured and simply
|
| 2209 |
// absent on the next read. The same "permanently blank column" argument as the two rules
|
| 2210 |
// above, one step worse β the column does not exist at all.
|
| 2211 |
+
// ββ Wave-34 (R13) β the same argument one kind over: `_clean_field` returns None for an
|
| 2212 |
+
// `ai_enrich` column with no prompt, so creating one without this gate produces a column that
|
| 2213 |
+
// is named, configured and simply absent on the next read.
|
| 2214 |
+
(kind !== "ai_enrich" || aiEnrich.prompt.trim() !== "") &&
|
| 2215 |
(kind !== "link" || linkTable !== "") &&
|
| 2216 |
(kind !== "rollup" || (rollupMode === "source"
|
| 2217 |
// β 2026-08-09 β the read-through bag. ALL FOUR keys are required by `_clean_rollup`
|
|
|
|
| 2240 |
// "switch this column back to Plain text" a change that saves. Omitting it instead would
|
| 2241 |
// inherit the previous language (the patch path's omit-means-keep rule).
|
| 2242 |
if (t === "code") return { code: { language: lang } };
|
| 2243 |
+
// ββ Wave-34 (R13) β the enrichment bag, shaped exactly as `_clean_ai_enrich` stores it.
|
| 2244 |
+
// β `cron` TRAVELS ONLY WITH `schedule`, and `model` only when set: the server drops both in
|
| 2245 |
+
// those cases anyway, so sending them would put keys in the payload that come back absent,
|
| 2246 |
+
// which is how a panel starts looking like it does not save. Same rule the rollup bags above
|
| 2247 |
+
// follow for `limit`/`sortBy`.
|
| 2248 |
+
if (t === "ai_enrich")
|
| 2249 |
+
return {
|
| 2250 |
+
aiEnrich: {
|
| 2251 |
+
prompt: aiEnrich.prompt.trim(),
|
| 2252 |
+
...(aiEnrich.model.trim() ? { model: aiEnrich.model.trim() } : {}),
|
| 2253 |
+
maxTokens: aiEnrich.maxTokens,
|
| 2254 |
+
trigger: {
|
| 2255 |
+
mode: aiEnrich.mode,
|
| 2256 |
+
...(aiEnrich.mode === "schedule" && aiEnrich.cron.trim()
|
| 2257 |
+
? { cron: aiEnrich.cron.trim() }
|
| 2258 |
+
: {}),
|
| 2259 |
+
},
|
| 2260 |
+
overwrite: aiEnrich.overwrite,
|
| 2261 |
+
},
|
| 2262 |
+
};
|
| 2263 |
if (t === "automation")
|
| 2264 |
return {
|
| 2265 |
automation: {
|
|
|
|
| 2359 |
swapToNewType != null &&
|
| 2360 |
swapLabel.trim() !== "" &&
|
| 2361 |
(!needsOptions(swapToNewType) || swapNewOptions.length > 0) &&
|
| 2362 |
+
(swapToNewType !== "formula" || swapFormulaCheck.ok) &&
|
| 2363 |
+
// ββ Wave-34 (R13) β the SAME completeness gate `canCreate` carries, on this door too.
|
| 2364 |
+
//
|
| 2365 |
+
// β AND THIS DOOR HAD NO SUCH GATE FOR ANY KIND, WHICH IS A PRE-EXISTING DEFECT I AM NOT
|
| 2366 |
+
// FIXING HERE, ONLY REPORTING. `canCreate` refuses a `link` with no target, a `rollup` with
|
| 2367 |
+
// no function and an `automation` with no URL column; this predicate refuses none of them,
|
| 2368 |
+
// and the swap form's own `<ExtraTypeEditor>` mount passes none of their setters either, so
|
| 2369 |
+
// picking one of those three from "Change field" renders a panel wired to nothing and Save
|
| 2370 |
+
// ships whatever stale draft the CREATE pane happens to hold. Booked for the register rather
|
| 2371 |
+
// than repaired mid-ticket ([[defects-that-mask-each-other]] is why: the missing gate and the
|
| 2372 |
+
// missing props hide each other, and fixing one alone arms the other).
|
| 2373 |
+
// β `ai_enrich` cannot arrive here inert, because it is ONE bag and one patcher rather than a
|
| 2374 |
+
// scatter of optional setters, so this conjunct is the whole of its half.
|
| 2375 |
+
(swapToNewType !== "ai_enrich" || aiEnrich.prompt.trim() !== "");
|
| 2376 |
|
| 2377 |
const createAndSwap = () => {
|
| 2378 |
if (!swapToNewType || !canCreateSwap) return;
|
|
|
|
| 2477 |
editLinkSingle !== (field.link?.single === true));
|
| 2478 |
const canSaveEdit =
|
| 2479 |
!editBlocked &&
|
| 2480 |
+
// ββ Wave-34 (R13) β the third door onto the same completeness rule (`canCreate` and
|
| 2481 |
+
// `canCreateSwap` are the other two). Retyping a column to AI enrichment without a prompt
|
| 2482 |
+
// is refused by the server, so Save is refused here instead: a 400 arriving after the click
|
| 2483 |
+
// is the same "named, configured, gone" surprise one layer later.
|
| 2484 |
+
(editType !== "ai_enrich" || aiEnrich.prompt.trim() !== "") &&
|
| 2485 |
(editingRelational
|
| 2486 |
? (editRollupValid && (editRelationalTouched || editNameDirty) &&
|
| 2487 |
(field.type !== "link" || editLinkTable !== ""))
|
|
|
|
| 2545 |
},
|
| 2546 |
}
|
| 2547 |
: {}),
|
| 2548 |
+
// ββ Wave-34 (R13) β RETYPING A COLUMN TO AI ENRICHMENT CARRIES ITS BAG, or the
|
| 2549 |
+
// server refuses the whole write: `_clean_field` returns None for an `ai_enrich`
|
| 2550 |
+
// column with no prompt. The edit pane renders the same panel the create pane does
|
| 2551 |
+
// (`ExtraTypeEditor` is mounted with `kind={editType}`), so the draft the user just
|
| 2552 |
+
// filled in is the one sent here.
|
| 2553 |
+
...(editType === "ai_enrich"
|
| 2554 |
+
? {
|
| 2555 |
+
aiEnrich: {
|
| 2556 |
+
prompt: aiEnrich.prompt.trim(),
|
| 2557 |
+
...(aiEnrich.model.trim() ? { model: aiEnrich.model.trim() } : {}),
|
| 2558 |
+
maxTokens: aiEnrich.maxTokens,
|
| 2559 |
+
trigger: {
|
| 2560 |
+
mode: aiEnrich.mode,
|
| 2561 |
+
...(aiEnrich.mode === "schedule" && aiEnrich.cron.trim()
|
| 2562 |
+
? { cron: aiEnrich.cron.trim() }
|
| 2563 |
+
: {}),
|
| 2564 |
+
},
|
| 2565 |
+
overwrite: aiEnrich.overwrite,
|
| 2566 |
+
},
|
| 2567 |
+
}
|
| 2568 |
+
: {}),
|
| 2569 |
...(editNameDirty ? { label: cleanRename } : {}),
|
| 2570 |
}
|
| 2571 |
);
|
|
|
|
| 2771 |
</>
|
| 2772 |
)}
|
| 2773 |
<ExtraTypeEditor
|
| 2774 |
+
aiEnrich={aiEnrich}
|
| 2775 |
+
onAiEnrich={patchAiEnrich}
|
| 2776 |
kind={kind}
|
| 2777 |
fields={fields}
|
| 2778 |
formulaText={formulaText}
|
|
|
|
| 2943 |
{field.type === "rollup" ? "Rollup" : "Linked records"}
|
| 2944 |
</span>
|
| 2945 |
<ExtraTypeEditor
|
| 2946 |
+
aiEnrich={aiEnrich}
|
| 2947 |
+
onAiEnrich={patchAiEnrich}
|
| 2948 |
kind={field.type}
|
| 2949 |
fields={fields}
|
| 2950 |
linkTargets={linkTargets}
|
|
|
|
| 3017 |
<div className="cg-type-block">
|
| 3018 |
<span className="cg-type-title">Automation</span>
|
| 3019 |
<ExtraTypeEditor
|
| 3020 |
+
aiEnrich={aiEnrich}
|
| 3021 |
+
onAiEnrich={patchAiEnrich}
|
| 3022 |
kind="automation"
|
| 3023 |
fields={fields}
|
| 3024 |
formulaText={editFormulaText}
|
|
|
|
| 3168 |
)}
|
| 3169 |
{!onRetype && field.type === "formula" && onFormula && (
|
| 3170 |
<ExtraTypeEditor
|
| 3171 |
+
aiEnrich={aiEnrich}
|
| 3172 |
+
onAiEnrich={patchAiEnrich}
|
| 3173 |
kind="formula"
|
| 3174 |
fields={fields}
|
| 3175 |
formulaText={editFormulaText}
|
|
|
|
| 3270 |
/>
|
| 3271 |
</label>
|
| 3272 |
<ExtraTypeEditor
|
| 3273 |
+
aiEnrich={aiEnrich}
|
| 3274 |
+
onAiEnrich={patchAiEnrich}
|
| 3275 |
kind={swapToNewType}
|
| 3276 |
fields={fields}
|
| 3277 |
formulaText={swapFormulaText}
|
|
|
|
| 3795 |
>
|
| 3796 |
<MenuLabel icon="hide" text="Hide field" />
|
| 3797 |
</button>
|
| 3798 |
+
{/* ββ WAVE-34 (owner ruling R13) β THE BULK RUN, and it is TWO rows rather than one
|
| 3799 |
+
because "run this column" is two different questions with two different bills.
|
| 3800 |
+
β NEITHER LABEL SAYS "everything". `ai_enrich_may_write` refuses a human-edited cell
|
| 3801 |
+
under every policy, so "All rows" means every row the AI wrote; saying "all" flat
|
| 3802 |
+
would be a control that quietly means less than it says, which is worse than a
|
| 3803 |
+
narrower control. The server takes these as `scope: "blank" | "always"`.
|
| 3804 |
+
β `onEnrich` is OPTIONAL, exactly as `onDelete` above it is: a host that has not
|
| 3805 |
+
wired the door renders no row rather than a row that does nothing.
|
| 3806 |
+
β THE GLYPH IS `FieldTypeIcon`, NOT A `MenuIcon` NAME, and that is not a style
|
| 3807 |
+
choice. `MenuLabel`'s prop is `MenuIconName | ReactNode`, and `ReactNode` admits
|
| 3808 |
+
any string, so `icon="sparkle"` COMPILES and renders an empty glyph box forever
|
| 3809 |
+
(`MENU_ICONS` has no such key). Reusing the type icon paints the same mark the
|
| 3810 |
+
column header and the add-field picker already wear, from one geometry source, and
|
| 3811 |
+
needs no new key in a file no lane owns. [[schema-role-is-not-a-value-wall]] */}
|
| 3812 |
+
{field.type === "ai_enrich" && onEnrich && (
|
| 3813 |
+
<>
|
| 3814 |
+
<button
|
| 3815 |
+
type="button"
|
| 3816 |
+
title="Fills only the cells that are still empty. Costs nothing for rows that already have an answer."
|
| 3817 |
+
onClick={() => onEnrich("blank")}
|
| 3818 |
+
>
|
| 3819 |
+
<MenuLabel icon={<FieldTypeIcon type="ai_enrich" size={16} />}
|
| 3820 |
+
text="Fill rows never filled" />
|
| 3821 |
+
</button>
|
| 3822 |
+
<button
|
| 3823 |
+
type="button"
|
| 3824 |
+
title="Re-asks the model for every row the AI wrote. A cell you have typed into is left alone."
|
| 3825 |
+
onClick={() => onEnrich("always")}
|
| 3826 |
+
>
|
| 3827 |
+
<MenuLabel icon={<FieldTypeIcon type="ai_enrich" size={16} />}
|
| 3828 |
+
text="Fill all rows again" />
|
| 3829 |
+
</button>
|
| 3830 |
+
</>
|
| 3831 |
+
)}
|
| 3832 |
{!schemaLocked && onDelete && (
|
| 3833 |
<button
|
| 3834 |
type="button"
|
|
|
|
| 3851 |
field.measure
|
| 3852 |
? "Removes this formula column from the workspace."
|
| 3853 |
: field.automation?.preset === true
|
| 3854 |
+
? "Part of this database's built-in schema. It will be re-created on the " +
|
| 3855 |
"next run. Hide it to keep it off the grid."
|
| 3856 |
: field.type === "rollup" || field.type === "link" ||
|
| 3857 |
field.type === "formula"
|
| 3858 |
+
? "Removes this column. No values are lost. It holds none of its own."
|
| 3859 |
: "Removes this field AND its values on every row."
|
| 3860 |
}
|
| 3861 |
onClick={() => (confirmDelete ? onDelete() : setConfirmDelete(true))}
|
web/src/customer-grid/CustomerGrid.tsx
CHANGED
|
@@ -60,7 +60,7 @@ import { NAV_MINIMIZE_EVENT, ROWS_STALE_EVENT, TOAST_EVENT, VIEW_OPEN_EVENT,
|
|
| 60 |
from "../apiContract";
|
| 61 |
import type { ViewOpenDetail } from "../apiContract";
|
| 62 |
import { addTableField, addTableRow, deleteTableField, deleteTableRow, fetchLinkTargets,
|
| 63 |
-
fetchRollupSources, patchTableField } from "./apiBridge";
|
| 64 |
import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
|
| 65 |
import SelectFromFile from "./SelectFromFile";
|
| 66 |
import ImportDialog from "./ImportDialog";
|
|
@@ -76,7 +76,7 @@ import { buildTsCsv, tsSheetToTable } from "./timeSeriesData";
|
|
| 76 |
import type { TsSheet } from "./timeSeriesData";
|
| 77 |
import { echoReemit, reconcileEchoView } from "./viewEcho";
|
| 78 |
import { pruneStamps, reconcileFields } from "./optimism";
|
| 79 |
-
import { applyViewOrder, newFolderId, pruneFolderStamps, reconcileFolders,
|
| 80 |
resolveFolderId } from "./folders";
|
| 81 |
import type { FolderStamps } from "./folders";
|
| 82 |
// β WAVE 27 item 22 β the cells the active view's filter forces on a record added under it.
|
|
@@ -2105,8 +2105,8 @@ function CustomerGridSurface({
|
|
| 2105 |
if (moved)
|
| 2106 |
signal(
|
| 2107 |
TOAST_EVENT,
|
| 2108 |
-
`${moved} restored record${moved === 1 ? "" : "s"} came back under a new id
|
| 2109 |
-
`
|
| 2110 |
);
|
| 2111 |
signal(ROWS_STALE_EVENT);
|
| 2112 |
} else if (op.kind === "rowDelete") {
|
|
@@ -2244,7 +2244,7 @@ function CustomerGridSurface({
|
|
| 2244 |
if (offscreen.length) {
|
| 2245 |
signal(
|
| 2246 |
TOAST_EVENT,
|
| 2247 |
-
`Nothing was deleted
|
| 2248 |
`record${pids.length === 1 ? " is" : "s are"} not shown in this view. Clear the ` +
|
| 2249 |
`filter (or scroll them into view) and select again.`
|
| 2250 |
);
|
|
@@ -2305,9 +2305,9 @@ function CustomerGridSurface({
|
|
| 2305 |
`${risky.length} of these ${one ? "records was" : "records were"} FOUND by an ` +
|
| 2306 |
`automation. Deleting ${one ? "it" : "them"} is not undone by a re-find` +
|
| 2307 |
(losses.length
|
| 2308 |
-
? `: ${losses.join(", ")}
|
| 2309 |
`undecided`
|
| 2310 |
-
: `
|
| 2311 |
`. Press Delete again (or click again) to confirm.`
|
| 2312 |
);
|
| 2313 |
return false;
|
|
@@ -2319,7 +2319,7 @@ function CustomerGridSurface({
|
|
| 2319 |
for (const r of captured)
|
| 2320 |
if (await deleteTableRow(scope, r.rid)) gone.push(r);
|
| 2321 |
if (!gone.length) {
|
| 2322 |
-
signal(TOAST_EVENT, "Nothing was deleted
|
| 2323 |
return false;
|
| 2324 |
}
|
| 2325 |
undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
|
|
@@ -2351,7 +2351,7 @@ function CustomerGridSurface({
|
|
| 2351 |
TOAST_EVENT,
|
| 2352 |
`Deleted ${gone.length} record${gone.length === 1 ? "" : "s"}. Ctrl+Z to undo.` +
|
| 2353 |
(fedByAutomation
|
| 2354 |
-
? " If this automation finds one again it comes back as a NEW candidate
|
| 2355 |
" and times-found reset, and its stage back to Review. Undo keeps all of that."
|
| 2356 |
: "")
|
| 2357 |
);
|
|
@@ -2581,7 +2581,7 @@ function CustomerGridSurface({
|
|
| 2581 |
// returns the label by identity when it fits, so this asks the renderer what it did
|
| 2582 |
// rather than re-running the measurement and hoping the two agree.
|
| 2583 |
const cut = !!field && !!colDef && colDef.title !== field.label;
|
| 2584 |
-
const text = cut && field ? (note ? `${field.label}
|
| 2585 |
const bounds = args.bounds;
|
| 2586 |
if (text && bounds) {
|
| 2587 |
setHeaderTip((prev) =>
|
|
@@ -3698,7 +3698,7 @@ function CustomerGridSurface({
|
|
| 3698 |
if (serverWindowed) {
|
| 3699 |
out[id] =
|
| 3700 |
"No count: this database is read through its source in windows, so the browser holds " +
|
| 3701 |
-
"one page rather than the whole table
|
| 3702 |
"page, not of this view. Counting it honestly needs a server-side count per view.";
|
| 3703 |
} else if (pendingMeasures(view.config.filters, measureSets) > 0) {
|
| 3704 |
out[id] =
|
|
@@ -3961,7 +3961,7 @@ function CustomerGridSurface({
|
|
| 3961 |
if (!sheet) return refuse("a time series");
|
| 3962 |
if (sheet.empty) {
|
| 3963 |
signal(TOAST_EVENT,
|
| 3964 |
-
`"${view.name}" has no metrics on its sheet yet
|
| 3965 |
return;
|
| 3966 |
}
|
| 3967 |
if (format === "csv") {
|
|
@@ -3987,7 +3987,7 @@ function CustomerGridSurface({
|
|
| 3987 |
if (spec?.mode === "catalog") {
|
| 3988 |
signal(
|
| 3989 |
TOAST_EVENT,
|
| 3990 |
-
`"${view.name}" is a catalog
|
| 3991 |
`A spreadsheet of its products is not what it draws.`
|
| 3992 |
);
|
| 3993 |
return;
|
|
@@ -5177,7 +5177,7 @@ function CustomerGridSurface({
|
|
| 5177 |
const kanbanReason = !kanbanField
|
| 5178 |
? null
|
| 5179 |
: kanbanField.type === "status"
|
| 5180 |
-
? `Stacked by ${kanbanField.label}
|
| 5181 |
: !canEditField(kanbanField)
|
| 5182 |
? "You do not have permission to edit this field, so cards cannot be moved."
|
| 5183 |
: null;
|
|
@@ -5187,7 +5187,7 @@ function CustomerGridSurface({
|
|
| 5187 |
const swipeReason = !swipeField
|
| 5188 |
? null
|
| 5189 |
: swipeField.type === "status"
|
| 5190 |
-
? `Bound to ${swipeField.label}
|
| 5191 |
: !canEditField(swipeField)
|
| 5192 |
? "You do not have permission to edit this field, so records cannot be decided."
|
| 5193 |
: null;
|
|
@@ -5545,6 +5545,16 @@ function CustomerGridSurface({
|
|
| 5545 |
} as unknown as HostEvent);
|
| 5546 |
}}
|
| 5547 |
onItemMove={(viewId, folderId) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5548 |
stampFolder((p) => ({
|
| 5549 |
...p,
|
| 5550 |
moved: { ...p.moved, [viewId]: { at: Date.now(), folderId } },
|
|
@@ -5982,7 +5992,7 @@ function CustomerGridSurface({
|
|
| 5982 |
canEdit={!!activeView && mayEditView(activeView, viewer)}
|
| 5983 |
readOnlyReason={
|
| 5984 |
!activeView
|
| 5985 |
-
? "A form lives on a saved view
|
| 5986 |
: mayEditView(activeView, viewer)
|
| 5987 |
? null
|
| 5988 |
: "You have view-only access to this view, so its form cannot be changed."
|
|
@@ -6396,6 +6406,45 @@ function CustomerGridSurface({
|
|
| 6396 |
? (w) => changeMeasurePeriod(menuField.key, w)
|
| 6397 |
: undefined
|
| 6398 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6399 |
onDelete={
|
| 6400 |
menuField.custom && menuField.key !== lockedKey
|
| 6401 |
? () => {
|
|
@@ -6726,7 +6775,7 @@ function CustomerGridSurface({
|
|
| 6726 |
</div>
|
| 6727 |
{addCandidates.length > 50 && (
|
| 6728 |
<div className="cg-field-hint">
|
| 6729 |
-
Showing the first 50 of {addCandidates.length.toLocaleString()} matches
|
| 6730 |
typing to narrow.
|
| 6731 |
</div>
|
| 6732 |
)}
|
|
|
|
| 60 |
from "../apiContract";
|
| 61 |
import type { ViewOpenDetail } from "../apiContract";
|
| 62 |
import { addTableField, addTableRow, deleteTableField, deleteTableRow, fetchLinkTargets,
|
| 63 |
+
fetchRollupSources, patchTableField, enrichField } from "./apiBridge";
|
| 64 |
import type { LinkTarget, RollupSourceOffer } from "./apiBridge";
|
| 65 |
import SelectFromFile from "./SelectFromFile";
|
| 66 |
import ImportDialog from "./ImportDialog";
|
|
|
|
| 76 |
import type { TsSheet } from "./timeSeriesData";
|
| 77 |
import { echoReemit, reconcileEchoView } from "./viewEcho";
|
| 78 |
import { pruneStamps, reconcileFields } from "./optimism";
|
| 79 |
+
import { applyViewOrder, isSyntheticFolderId, newFolderId, pruneFolderStamps, reconcileFolders,
|
| 80 |
resolveFolderId } from "./folders";
|
| 81 |
import type { FolderStamps } from "./folders";
|
| 82 |
// β WAVE 27 item 22 β the cells the active view's filter forces on a record added under it.
|
|
|
|
| 2105 |
if (moved)
|
| 2106 |
signal(
|
| 2107 |
TOAST_EVENT,
|
| 2108 |
+
`${moved} restored record${moved === 1 ? "" : "s"} came back under a new id. ` +
|
| 2109 |
+
`Something had taken the original while it was gone.`
|
| 2110 |
);
|
| 2111 |
signal(ROWS_STALE_EVENT);
|
| 2112 |
} else if (op.kind === "rowDelete") {
|
|
|
|
| 2244 |
if (offscreen.length) {
|
| 2245 |
signal(
|
| 2246 |
TOAST_EVENT,
|
| 2247 |
+
`Nothing was deleted. ${offscreen.length} of the ${pids.length} selected ` +
|
| 2248 |
`record${pids.length === 1 ? " is" : "s are"} not shown in this view. Clear the ` +
|
| 2249 |
`filter (or scroll them into view) and select again.`
|
| 2250 |
);
|
|
|
|
| 2305 |
`${risky.length} of these ${one ? "records was" : "records were"} FOUND by an ` +
|
| 2306 |
`automation. Deleting ${one ? "it" : "them"} is not undone by a re-find` +
|
| 2307 |
(losses.length
|
| 2308 |
+
? `: ${losses.join(", ")}, so a card somebody had already moved on comes back ` +
|
| 2309 |
`undecided`
|
| 2310 |
+
: `, so ${one ? "it" : "they"} would come back as a new record with no history`) +
|
| 2311 |
`. Press Delete again (or click again) to confirm.`
|
| 2312 |
);
|
| 2313 |
return false;
|
|
|
|
| 2319 |
for (const r of captured)
|
| 2320 |
if (await deleteTableRow(scope, r.rid)) gone.push(r);
|
| 2321 |
if (!gone.length) {
|
| 2322 |
+
signal(TOAST_EVENT, "Nothing was deleted: the server refused.");
|
| 2323 |
return false;
|
| 2324 |
}
|
| 2325 |
undoBook.current[scope] = pushUndo(stackFor(undoBook.current, scope), {
|
|
|
|
| 2351 |
TOAST_EVENT,
|
| 2352 |
`Deleted ${gone.length} record${gone.length === 1 ? "" : "s"}. Ctrl+Z to undo.` +
|
| 2353 |
(fedByAutomation
|
| 2354 |
+
? " If this automation finds one again it comes back as a NEW candidate: first-found" +
|
| 2355 |
" and times-found reset, and its stage back to Review. Undo keeps all of that."
|
| 2356 |
: "")
|
| 2357 |
);
|
|
|
|
| 2581 |
// returns the label by identity when it fits, so this asks the renderer what it did
|
| 2582 |
// rather than re-running the measurement and hoping the two agree.
|
| 2583 |
const cut = !!field && !!colDef && colDef.title !== field.label;
|
| 2584 |
+
const text = cut && field ? (note ? `${field.label}: ${note}` : field.label) : note;
|
| 2585 |
const bounds = args.bounds;
|
| 2586 |
if (text && bounds) {
|
| 2587 |
setHeaderTip((prev) =>
|
|
|
|
| 3698 |
if (serverWindowed) {
|
| 3699 |
out[id] =
|
| 3700 |
"No count: this database is read through its source in windows, so the browser holds " +
|
| 3701 |
+
"one page rather than the whole table. A number from it would be the size of that " +
|
| 3702 |
"page, not of this view. Counting it honestly needs a server-side count per view.";
|
| 3703 |
} else if (pendingMeasures(view.config.filters, measureSets) > 0) {
|
| 3704 |
out[id] =
|
|
|
|
| 3961 |
if (!sheet) return refuse("a time series");
|
| 3962 |
if (sheet.empty) {
|
| 3963 |
signal(TOAST_EVENT,
|
| 3964 |
+
`"${view.name}" has no metrics on its sheet yet. Add one, then export.`);
|
| 3965 |
return;
|
| 3966 |
}
|
| 3967 |
if (format === "csv") {
|
|
|
|
| 3987 |
if (spec?.mode === "catalog") {
|
| 3988 |
signal(
|
| 3989 |
TOAST_EVENT,
|
| 3990 |
+
`"${view.name}" is a catalog. Open it and use Print to save it as a PDF. ` +
|
| 3991 |
`A spreadsheet of its products is not what it draws.`
|
| 3992 |
);
|
| 3993 |
return;
|
|
|
|
| 5177 |
const kanbanReason = !kanbanField
|
| 5178 |
? null
|
| 5179 |
: kanbanField.type === "status"
|
| 5180 |
+
? `Stacked by ${kanbanField.label}. That field is read-only, computed from the source system.`
|
| 5181 |
: !canEditField(kanbanField)
|
| 5182 |
? "You do not have permission to edit this field, so cards cannot be moved."
|
| 5183 |
: null;
|
|
|
|
| 5187 |
const swipeReason = !swipeField
|
| 5188 |
? null
|
| 5189 |
: swipeField.type === "status"
|
| 5190 |
+
? `Bound to ${swipeField.label}. That field is read-only, computed from the source system.`
|
| 5191 |
: !canEditField(swipeField)
|
| 5192 |
? "You do not have permission to edit this field, so records cannot be decided."
|
| 5193 |
: null;
|
|
|
|
| 5545 |
} as unknown as HostEvent);
|
| 5546 |
}}
|
| 5547 |
onItemMove={(viewId, folderId) => {
|
| 5548 |
+
// β WAVE 34 Β· T25 β REFUSE A SYNTHETIC FOLDER ID AT THE ONE PLACE THE WRITE LEAVES.
|
| 5549 |
+
// "Shared with me" and each shared-FOLDER group are READINGS of the view list, not
|
| 5550 |
+
// records: their ids exist in nobody's store. The rail already withholds drop
|
| 5551 |
+
// handlers from those groups, so nothing constructs this call today, and the server
|
| 5552 |
+
// refuses an unknown target anyway (`grid_events.py`'s `item_move` checks the id
|
| 5553 |
+
// against the caller's own folder list). This is the third layer, and it is here
|
| 5554 |
+
// rather than in the rail because this callback is the single choke point every
|
| 5555 |
+
// future surface will reach through: a second caller would otherwise inherit no
|
| 5556 |
+
// client-side check at all. Raised by T25's verifier, which traced exactly that gap.
|
| 5557 |
+
if (isSyntheticFolderId(folderId)) return;
|
| 5558 |
stampFolder((p) => ({
|
| 5559 |
...p,
|
| 5560 |
moved: { ...p.moved, [viewId]: { at: Date.now(), folderId } },
|
|
|
|
| 5992 |
canEdit={!!activeView && mayEditView(activeView, viewer)}
|
| 5993 |
readOnlyReason={
|
| 5994 |
!activeView
|
| 5995 |
+
? "A form lives on a saved view. Save this one first."
|
| 5996 |
: mayEditView(activeView, viewer)
|
| 5997 |
? null
|
| 5998 |
: "You have view-only access to this view, so its form cannot be changed."
|
|
|
|
| 6406 |
? (w) => changeMeasurePeriod(menuField.key, w)
|
| 6407 |
: undefined
|
| 6408 |
}
|
| 6409 |
+
/**
|
| 6410 |
+
* ββ WAVE 34 (R13, `W34-T54`) β THE BULK ENRICHMENT RUN.
|
| 6411 |
+
*
|
| 6412 |
+
* β WIRED AT CLOSE-OUT BY THE INTEGRATOR, and the reason belongs here. Lane F built the
|
| 6413 |
+
* server door, the field kind and the menu row, then offered this prop as optional with
|
| 6414 |
+
* *"nothing breaks if you decline"*. True of the BUILD, false of the FEATURE: this is
|
| 6415 |
+
* the ONLY call site that makes the bulk run reachable, and `W34-T54`'s `done-when`
|
| 6416 |
+
* reads *"the bulk menu offers at least all rows and rows never filled"*. Declining it
|
| 6417 |
+
* would have left that ticket BUILT with its own acceptance test unreachable.
|
| 6418 |
+
* β OPTIONAL IN THE TYPE IS STILL RIGHT β `ColumnMenu` renders no row when the host
|
| 6419 |
+
* passes nothing, so a host that has not wired it shows nothing rather than a control
|
| 6420 |
+
* that does nothing. What was wrong was leaving the one real host unwired.
|
| 6421 |
+
* β IT SPENDS MONEY, so the result is REPORTED, never swallowed: the server answers a
|
| 6422 |
+
* run report and the sentence names what actually happened. A run that filled 3 of 900
|
| 6423 |
+
* because a token ceiling bit is the silent cap R6's standing rule forbids.
|
| 6424 |
+
* β Gated on `isUserTable` for the same reason every schema write here is: the door
|
| 6425 |
+
* itself rides `_field_or_refuse`, so this is the affordance agreeing with the wall
|
| 6426 |
+
* rather than a second opinion about it.
|
| 6427 |
+
*/
|
| 6428 |
+
onEnrich={
|
| 6429 |
+
isUserTable && menuField.type === "ai_enrich"
|
| 6430 |
+
? async (runScope) => {
|
| 6431 |
+
setColumnMenu(null);
|
| 6432 |
+
const report = await enrichField(scope, menuField.key, runScope);
|
| 6433 |
+
if (!report) return; // apiBridge already said why
|
| 6434 |
+
const filled = report.filled ?? 0;
|
| 6435 |
+
const failed = report.failed ?? 0;
|
| 6436 |
+
const capped =
|
| 6437 |
+
report.limit !== undefined && (report.tokens ?? 0) >= report.limit;
|
| 6438 |
+
signal(
|
| 6439 |
+
TOAST_EVENT,
|
| 6440 |
+
`${filled} filled` +
|
| 6441 |
+
(failed ? `, ${failed} failed` : "") +
|
| 6442 |
+
(capped ? ". The token ceiling was reached, so the rest were left." : ".")
|
| 6443 |
+
);
|
| 6444 |
+
signal(ROWS_STALE_EVENT, scope);
|
| 6445 |
+
}
|
| 6446 |
+
: undefined
|
| 6447 |
+
}
|
| 6448 |
onDelete={
|
| 6449 |
menuField.custom && menuField.key !== lockedKey
|
| 6450 |
? () => {
|
|
|
|
| 6775 |
</div>
|
| 6776 |
{addCandidates.length > 50 && (
|
| 6777 |
<div className="cg-field-hint">
|
| 6778 |
+
Showing the first 50 of {addCandidates.length.toLocaleString()} matches. Keep
|
| 6779 |
typing to narrow.
|
| 6780 |
</div>
|
| 6781 |
)}
|
web/src/customer-grid/RecordDetail.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/customer-grid/ViewSidebar.tsx
CHANGED
|
@@ -47,7 +47,15 @@ import { ROOT_FOLDER_ID } from "./folders";
|
|
| 47 |
// menu vocabulary. The Inbox module's header wears the SAME component (`ui/icons.BellIcon`),
|
| 48 |
// so the bell that opens the Inbox and the bell that fills it cannot drift apart.
|
| 49 |
import { BellIcon, StarIcon } from "../ui/icons";
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 53 |
β WAVE 27 Β· OWNER ITEM 1 β a MARK on each "Who can edit" row.
|
|
@@ -506,7 +514,29 @@ export default function ViewSidebar({
|
|
| 506 |
* is not: a surface with folders switched off must still be able to show you somebody
|
| 507 |
* else's view under a heading that says whose it is.
|
| 508 |
*/
|
| 509 |
-
(v) => v.shared === true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
);
|
| 511 |
const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
|
| 512 |
const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null;
|
|
@@ -553,6 +583,25 @@ export default function ViewSidebar({
|
|
| 553 |
if (!foldersOn) return;
|
| 554 |
e.preventDefault();
|
| 555 |
setDropTarget(null);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
const id = e.dataTransfer.getData("text/plain");
|
| 557 |
// ββ W32-T27 (owner item 20) β DROPPING ON ROOT SENDS A VALUE, NOT `null`.
|
| 558 |
//
|
|
@@ -574,6 +623,47 @@ export default function ViewSidebar({
|
|
| 574 |
const noteView = noteFor
|
| 575 |
? views.find((view) => view.id === noteFor.viewId)
|
| 576 |
: undefined;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 577 |
|
| 578 |
const create = () => {
|
| 579 |
const value = name.trim();
|
|
@@ -710,35 +800,58 @@ export default function ViewSidebar({
|
|
| 710 |
Owner: the database should carry the SUM of its marked views' numbers, whenever at
|
| 711 |
least one view in it is marked.
|
| 712 |
β HERE, and not in the shell's database header, DELIBERATELY: this rail belongs to
|
| 713 |
-
the OPEN database, so a total in it needs no database name to be unambiguous
|
| 714 |
-
|
| 715 |
-
floating a bare number, because an unlabelled figure at the top of a list of views
|
| 716 |
-
reads as a count OF VIEWS.
|
| 717 |
-
β `counted < marked` is a PARTIAL total β some marked view could not be counted (a
|
| 718 |
windowed grid, an unresolved measure). It says so instead of presenting a short sum
|
| 719 |
-
as a whole one, which is D-205's rule one level up.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 720 |
{importantTotal && (
|
| 721 |
<span
|
| 722 |
className={
|
| 723 |
"cg-views-important" +
|
| 724 |
-
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
importantTotal.counted < importantTotal.marked
|
| 728 |
-
?
|
| 729 |
-
|
| 730 |
-
+ `important here β the rest cannot be counted in the browser, so this total `
|
| 731 |
-
+ `is lower than the real one`
|
| 732 |
-
: `${importantTotal.sum.toLocaleString()} records across the `
|
| 733 |
-
+ `${importantTotal.marked} view${importantTotal.marked === 1 ? "" : "s"} `
|
| 734 |
-
+ `marked important in this database`
|
| 735 |
}
|
| 736 |
>
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 742 |
</span>
|
| 743 |
)}
|
| 744 |
</div>
|
|
@@ -985,7 +1098,7 @@ export default function ViewSidebar({
|
|
| 985 |
// host-side, so say that rather than letting the user think they
|
| 986 |
// shared it.
|
| 987 |
<span className="cg-perm-empty">
|
| 988 |
-
No other accounts to pick
|
| 989 |
</span>
|
| 990 |
) : (
|
| 991 |
userOptions.map((u) => (
|
|
@@ -1079,7 +1192,11 @@ export default function ViewSidebar({
|
|
| 1079 |
* server would store. The row menu was already suppressed for this id in wave 20;
|
| 1080 |
* the DRAG surfaces were not, because nothing ever constructed the group.
|
| 1081 |
*/
|
| 1082 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1083 |
const shut = gid != null && collapsed.has(gid);
|
| 1084 |
return (
|
| 1085 |
<div
|
|
@@ -1101,7 +1218,27 @@ export default function ViewSidebar({
|
|
| 1101 |
//
|
| 1102 |
// The `is-drop-above` line above got this right (`&& foldDrag &&`), which is the
|
| 1103 |
// tell: two conditions written minutes apart, one guarded and one not.
|
| 1104 |
-
(foldDrag !== null && foldDrag === gid ? " is-folddrag" : "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1105 |
}
|
| 1106 |
{/* No drop handlers on the synthetic group β see `isSynthetic`. Spreading `{}`
|
| 1107 |
rather than branching the element keeps ONE render path for every group. */
|
|
@@ -1173,7 +1310,23 @@ export default function ViewSidebar({
|
|
| 1173 |
}}
|
| 1174 |
/>
|
| 1175 |
) : (
|
| 1176 |
-
<span
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1177 |
)}
|
| 1178 |
{/* β WAVE 20 item 21 β THE VIEW COUNT IS DELETED, and the item is not
|
| 1179 |
really about the number. `.cg-view-more` (the "β¦" beside this button)
|
|
@@ -1196,7 +1349,11 @@ export default function ViewSidebar({
|
|
| 1196 |
row the server would refuse is three fake affordances rather than
|
| 1197 |
one. Views MOVE OUT of it normally (that is per-receiver placement),
|
| 1198 |
which is the only thing anyone needs to do to it. */}
|
| 1199 |
-
{
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1200 |
<button
|
| 1201 |
type="button"
|
| 1202 |
className="cg-view-more"
|
|
@@ -1242,8 +1399,13 @@ export default function ViewSidebar({
|
|
| 1242 |
: "")
|
| 1243 |
}
|
| 1244 |
key={view.id}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1245 |
draggable={(foldersOn || viewsReorderable) && !renaming
|
| 1246 |
-
&& mayEditView(view, viewer)}
|
| 1247 |
onDragStart={(e) => {
|
| 1248 |
// β TWO TYPES ON ONE DRAG, and that is the discrimination C7 asks for.
|
| 1249 |
// `text/plain` is the FILE-INTO-A-FOLDER channel the folder groups already
|
|
@@ -1289,7 +1451,7 @@ export default function ViewSidebar({
|
|
| 1289 |
// ellipsis at 188px, so the hover is now the only way to read either one in
|
| 1290 |
// full. It used to show the note INSTEAD of the name, which meant a
|
| 1291 |
// described view could not have its own truncated name revealed at all.
|
| 1292 |
-
title={view.note ? `${view.name}
|
| 1293 |
>
|
| 1294 |
{/* I12 β the row wears the view's CURRENT display mode, not a kind dot.
|
| 1295 |
Same geometry the mode switcher and the create flyout use, so "what
|
|
@@ -1339,10 +1501,10 @@ export default function ViewSidebar({
|
|
| 1339 |
// number is the same fact; the promise attached to it is not.
|
| 1340 |
title={
|
| 1341 |
view.config?.important
|
| 1342 |
-
? `${alertCounts[view.id].toLocaleString()} records match this view `
|
| 1343 |
-
+ `
|
| 1344 |
: `${alertCounts[view.id].toLocaleString()} records match this `
|
| 1345 |
-
+ `view
|
| 1346 |
}
|
| 1347 |
>
|
| 1348 |
{alertCounts[view.id].toLocaleString()}
|
|
@@ -1365,7 +1527,15 @@ export default function ViewSidebar({
|
|
| 1365 |
title={countNotes[view.id]}
|
| 1366 |
aria-label={countNotes[view.id]}
|
| 1367 |
>
|
| 1368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1369 |
</span>
|
| 1370 |
)}
|
| 1371 |
{/* β wave17 R1 / C-LOCKV β ONE mark, TWO meanings, and they are genuinely
|
|
@@ -1406,7 +1576,7 @@ export default function ViewSidebar({
|
|
| 1406 |
title={
|
| 1407 |
(view.owner
|
| 1408 |
? `Shared with you by ${view.owner}.`
|
| 1409 |
-
: "Shared with you
|
| 1410 |
(view.sharedRole === "edit"
|
| 1411 |
? " You can edit it."
|
| 1412 |
: " You can view it, not change it.")
|
|
@@ -1437,12 +1607,12 @@ export default function ViewSidebar({
|
|
| 1437 |
}
|
| 1438 |
title={
|
| 1439 |
view.kind === "locked"
|
| 1440 |
-
? "Locked
|
| 1441 |
"Filters, sorts and columns still narrow within them." +
|
| 1442 |
(isModeFrozen(view)
|
| 1443 |
? ` It also stays a ${MODE_LABELS[mode].toLowerCase()}.`
|
| 1444 |
: "")
|
| 1445 |
-
: `Locked
|
| 1446 |
mode
|
| 1447 |
].toLowerCase()}. Filters, sorts and columns are still editable.`
|
| 1448 |
}
|
|
@@ -1492,7 +1662,7 @@ export default function ViewSidebar({
|
|
| 1492 |
);
|
| 1493 |
})}
|
| 1494 |
{group.folder && !shut && group.items.length === 0 && (
|
| 1495 |
-
<div className="cg-fold-empty">Empty
|
| 1496 |
)}
|
| 1497 |
</div>
|
| 1498 |
);
|
|
@@ -1661,7 +1831,7 @@ export default function ViewSidebar({
|
|
| 1661 |
setLockFor(null);
|
| 1662 |
}}
|
| 1663 |
>
|
| 1664 |
-
Unlock
|
| 1665 |
</button>
|
| 1666 |
)}
|
| 1667 |
</AnchoredOverlay>
|
|
@@ -1692,7 +1862,7 @@ export default function ViewSidebar({
|
|
| 1692 |
Not included:
|
| 1693 |
{addPreview.skipped.map((sk) => (
|
| 1694 |
<span key={sk.name} className="cg-fold-skip">
|
| 1695 |
-
{sk.name}
|
| 1696 |
</span>
|
| 1697 |
))}
|
| 1698 |
</div>
|
|
@@ -1826,10 +1996,17 @@ export default function ViewSidebar({
|
|
| 1826 |
</div>
|
| 1827 |
</>
|
| 1828 |
)}
|
| 1829 |
-
{/* I17 (C4) β the view's IDENTITY (name, description,
|
| 1830 |
-
|
| 1831 |
-
|
| 1832 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1833 |
{canEditMenuView && (
|
| 1834 |
<button
|
| 1835 |
type="button"
|
|
@@ -2177,7 +2354,20 @@ export default function ViewSidebar({
|
|
| 2177 |
{/* Wave-10 item 9 moved the description off the grid and onto the view's row,
|
| 2178 |
so this sentence had to move with it β it described a banner that no longer
|
| 2179 |
exists. */}
|
| 2180 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2181 |
<textarea
|
| 2182 |
data-overlay-autofocus
|
| 2183 |
value={noteDraft}
|
|
|
|
| 47 |
// menu vocabulary. The Inbox module's header wears the SAME component (`ui/icons.BellIcon`),
|
| 48 |
// so the bell that opens the Inbox and the bell that fills it cannot drift apart.
|
| 49 |
import { BellIcon, StarIcon } from "../ui/icons";
|
| 50 |
+
// β `SHARED_FOLDER_ID` is no longer imported: W34-T25 replaced both of its call sites with
|
| 51 |
+
// `isSyntheticFolderId`, and `noUnusedLocals` turns a leftover import into a build error rather
|
| 52 |
+
// than into dead weight. The constant itself is still exported and still used by `folders.ts`.
|
| 53 |
+
import {
|
| 54 |
+
groupByFolder,
|
| 55 |
+
isSyntheticFolderId,
|
| 56 |
+
mayFileView,
|
| 57 |
+
reorderFolderIds,
|
| 58 |
+
} from "./folders";
|
| 59 |
|
| 60 |
/* βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 61 |
β WAVE 27 Β· OWNER ITEM 1 β a MARK on each "Who can edit" row.
|
|
|
|
| 514 |
* is not: a surface with folders switched off must still be able to show you somebody
|
| 515 |
* else's view under a heading that says whose it is.
|
| 516 |
*/
|
| 517 |
+
(v) => v.shared === true,
|
| 518 |
+
/**
|
| 519 |
+
* ββ WAVE 34 Β· T25 (ruling R11) β WHICH shared folder this view arrived through.
|
| 520 |
+
*
|
| 521 |
+
* β READ STRUCTURALLY, and that is a deliberate, temporary compromise rather than sloppiness.
|
| 522 |
+
* `core/grid_events.py::_granted_views` has stamped `sharedFolder` on every folder-granted
|
| 523 |
+
* view since `D-37`, its three siblings `shared` / `sharedRole` / `owner` are all declared on
|
| 524 |
+
* `SavedView`, and this one never was: a census of `web/src` found ZERO occurrences before
|
| 525 |
+
* this line. `types.ts` is another session's file this wave (`ASK C-1` is out for the
|
| 526 |
+
* one-line declaration), so rather than block R11 on a type, the field is read through a
|
| 527 |
+
* narrow local shape and validated here. β When C-1 lands, delete this cast and take the
|
| 528 |
+
* declared field: a private spelling of a wire field is exactly how two layers drift.
|
| 529 |
+
*/
|
| 530 |
+
(v) => {
|
| 531 |
+
const via = (v as { sharedFolder?: unknown }).sharedFolder;
|
| 532 |
+
const name = typeof via === "string" ? via.trim() : "";
|
| 533 |
+
const owner = typeof v.owner === "string" ? v.owner.trim() : "";
|
| 534 |
+
// β BOTH, and the owner is not optional. Keyed on the NAME alone, two colleagues who each
|
| 535 |
+
// share a folder called "Reports" merge into ONE group whose head then attributes every
|
| 536 |
+
// view in it to whichever of them happens to sort first. `owner` is already stamped on
|
| 537 |
+
// every projected view by `_granted_views`, so this costs nothing on the wire.
|
| 538 |
+
return name && owner ? { name, owner } : null;
|
| 539 |
+
}
|
| 540 |
);
|
| 541 |
const folderMenuF = folderMenu ? folders?.find((f) => f.id === folderMenu.id) : undefined;
|
| 542 |
const addPreview = addTarget && folderAddPreview ? folderAddPreview(addTarget) : null;
|
|
|
|
| 583 |
if (!foldersOn) return;
|
| 584 |
e.preventDefault();
|
| 585 |
setDropTarget(null);
|
| 586 |
+
// ββ WAVE 34 Β· T21 (ruling R3) β THE ROW STOPS BEING FADED HERE, AND ONLY HERE.
|
| 587 |
+
//
|
| 588 |
+
// Owner: moving a View INTO a folder must not leave the view's icon and row blurred
|
| 589 |
+
// "until i move it around". `viewDrag` is set in the view row's `onDragStart` and was
|
| 590 |
+
// cleared in exactly two places: that row's own `onDragEnd`, and the SIBLING-REORDER
|
| 591 |
+
// drop in `viewDropHandlers`. Filing into a folder is NEITHER β it is this handler,
|
| 592 |
+
// which cleared `dropTarget` and never touched `viewDrag`.
|
| 593 |
+
//
|
| 594 |
+
// β AND `onDragEnd` CANNOT COVER IT, which is why the fade was permanent rather than
|
| 595 |
+
// one frame long. `onItemMove` below re-parents the row, so React unmounts the node
|
| 596 |
+
// that started the drag and the browser never dispatches `dragend` on a node removed
|
| 597 |
+
// mid-gesture. The state stayed set and the FRESH row rendered wearing
|
| 598 |
+
// `is-viewdrag` (opacity .45) until some later interaction happened to clear it.
|
| 599 |
+
//
|
| 600 |
+
// β Ordered BEFORE `onItemMove`, and that is free rather than delicate: `onItemMove`
|
| 601 |
+
// is synchronous and React batches both state writes into the same commit, so there
|
| 602 |
+
// is no frame in which the row is re-parented and still faded.
|
| 603 |
+
setViewDrag(null);
|
| 604 |
+
setViewOver(null);
|
| 605 |
const id = e.dataTransfer.getData("text/plain");
|
| 606 |
// ββ W32-T27 (owner item 20) β DROPPING ON ROOT SENDS A VALUE, NOT `null`.
|
| 607 |
//
|
|
|
|
| 623 |
const noteView = noteFor
|
| 624 |
? views.find((view) => view.id === noteFor.viewId)
|
| 625 |
: undefined;
|
| 626 |
+
/**
|
| 627 |
+
* ββ WAVE 34 Β· T20 (R1, contract C1) β THE SENTENCE THE BARE NUMBER CANNOT CARRY.
|
| 628 |
+
*
|
| 629 |
+
* R1 drops the word "Important" from this badge, so what is left on screen is a figure with
|
| 630 |
+
* no noun. W33-T16's own note argued the opposite ("an unlabelled figure at the top of a list
|
| 631 |
+
* of views reads as a count OF VIEWS") and it was right about the risk; the owner's ruling
|
| 632 |
+
* settles the pixels, and this is where the meaning goes instead. Hover and screen reader get
|
| 633 |
+
* the whole fact, in one string, on the one element.
|
| 634 |
+
*
|
| 635 |
+
* β THE `counted === 0` BRANCH IS D-252, NOT A TIDY-UP. On a fully server-windowed database
|
| 636 |
+
* every marked view returns no number, so `importantTotal` is `{sum: 0, counted: 0, marked: N}`
|
| 637 |
+
* β TRUTHY β and the rail painted "Important 0+". Drop the word and that becomes a bare "0+",
|
| 638 |
+
* which is strictly worse: a real zero and an unknown are the same three pixels. So a total
|
| 639 |
+
* that could count NOTHING wears the same is-unknown mark a single uncountable view wears
|
| 640 |
+
* (T14), and never a manufactured 0.
|
| 641 |
+
*
|
| 642 |
+
* β THE CAUSE IS QUOTED, NOT INVENTED. The two reasons a count is missing (a windowed grid, an
|
| 643 |
+
* unresolved measure) produce different sentences and this rail cannot tell them apart, so it
|
| 644 |
+
* reads the FIRST marked view's own `countNotes` entry rather than asserting one of them. No
|
| 645 |
+
* note at all still gets a sentence with a next step, never silence β R6's second half.
|
| 646 |
+
*/
|
| 647 |
+
let importantNote = "";
|
| 648 |
+
if (importantTotal) {
|
| 649 |
+
const { sum, counted, marked } = importantTotal;
|
| 650 |
+
const nViews = `${marked} view${marked === 1 ? "" : "s"} marked important in this database`;
|
| 651 |
+
if (counted === 0) {
|
| 652 |
+
const cause = views
|
| 653 |
+
.filter((v) => v.config?.important)
|
| 654 |
+
.map((v) => countNotes?.[v.id])
|
| 655 |
+
.find(Boolean);
|
| 656 |
+
importantNote =
|
| 657 |
+
`No number yet for the ${nViews}. `
|
| 658 |
+
+ (cause || "Open a marked view to see why its own count is missing.");
|
| 659 |
+
} else if (counted < marked) {
|
| 660 |
+
importantNote =
|
| 661 |
+
`${sum.toLocaleString()} records across ${counted} of the ${nViews}. `
|
| 662 |
+
+ `The rest cannot be counted in the browser, so this total is lower than the real one.`;
|
| 663 |
+
} else {
|
| 664 |
+
importantNote = `${sum.toLocaleString()} records across the ${nViews}.`;
|
| 665 |
+
}
|
| 666 |
+
}
|
| 667 |
|
| 668 |
const create = () => {
|
| 669 |
const value = name.trim();
|
|
|
|
| 800 |
Owner: the database should carry the SUM of its marked views' numbers, whenever at
|
| 801 |
least one view in it is marked.
|
| 802 |
β HERE, and not in the shell's database header, DELIBERATELY: this rail belongs to
|
| 803 |
+
the OPEN database, so a total in it needs no database name to be unambiguous.
|
| 804 |
+
β `counted < marked` is a PARTIAL total: some marked view could not be counted (a
|
|
|
|
|
|
|
|
|
|
| 805 |
windowed grid, an unresolved measure). It says so instead of presenting a short sum
|
| 806 |
+
as a whole one, which is D-205's rule one level up.
|
| 807 |
+
|
| 808 |
+
ββ WAVE 34 Β· T20 (R1) REVERSED THE LABEL DECISION, AND THE REVERSAL IS THE OWNER'S.
|
| 809 |
+
T16 shipped this as the word "Important" plus a badge, on the argument recorded in
|
| 810 |
+
its own note: "an unlabelled figure at the top of a list of views reads as a count OF
|
| 811 |
+
VIEWS". R1 answers that directly, in the owner's words: the count belongs in the
|
| 812 |
+
database NAVIGATION (B's flyout, contract C1), and what stays here is the number
|
| 813 |
+
alone, smaller, with no word. The risk T16 named is real and is answered by
|
| 814 |
+
`importantNote` rather than by a label: the badge carries the whole sentence in its
|
| 815 |
+
`title` AND its `aria-label`, so hover and screen reader both get the noun the pixels
|
| 816 |
+
gave up. β Do not re-add the word to satisfy the old note; `verify_grid_ux`'s
|
| 817 |
+
`[VIEWS RAIL]` scan reds on it, with a control that reproduces the regression. */}
|
| 818 |
{importantTotal && (
|
| 819 |
<span
|
| 820 |
className={
|
| 821 |
"cg-views-important" +
|
| 822 |
+
// β PARTIAL means "the number you can see is real and short". A total that counted
|
| 823 |
+
// NOTHING has no number to be short, so it must not wear the dashed-purple partial
|
| 824 |
+
// mark as well as the is-unknown one β two statements about one absence.
|
| 825 |
+
(importantTotal.counted > 0 && importantTotal.counted < importantTotal.marked
|
| 826 |
+
? " is-partial"
|
| 827 |
+
: "")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 828 |
}
|
| 829 |
>
|
| 830 |
+
{/* β `role="img"` ON BOTH, and it is not decoration. An `aria-label` on a plain
|
| 831 |
+
roleless inline `<span>` is not reliably exposed by assistive technology, and
|
| 832 |
+
this badge is now the ONLY carrier of a noun the pixels no longer say. `img` is
|
| 833 |
+
the correct role for "a graphic whose whole meaning is its label", and it also
|
| 834 |
+
stops a screen reader announcing the bare digits as loose text. */}
|
| 835 |
+
{importantTotal.counted === 0 ? (
|
| 836 |
+
<span
|
| 837 |
+
className="cg-view-count is-unknown"
|
| 838 |
+
role="img"
|
| 839 |
+
title={importantNote}
|
| 840 |
+
aria-label={importantNote}
|
| 841 |
+
>
|
| 842 |
+
-
|
| 843 |
+
</span>
|
| 844 |
+
) : (
|
| 845 |
+
<span
|
| 846 |
+
className="cg-view-count"
|
| 847 |
+
role="img"
|
| 848 |
+
title={importantNote}
|
| 849 |
+
aria-label={importantNote}
|
| 850 |
+
>
|
| 851 |
+
{importantTotal.sum.toLocaleString()}
|
| 852 |
+
{importantTotal.counted < importantTotal.marked ? "+" : ""}
|
| 853 |
+
</span>
|
| 854 |
+
)}
|
| 855 |
</span>
|
| 856 |
)}
|
| 857 |
</div>
|
|
|
|
| 1098 |
// host-side, so say that rather than letting the user think they
|
| 1099 |
// shared it.
|
| 1100 |
<span className="cg-perm-empty">
|
| 1101 |
+
No other accounts to pick. This will save as Personal.
|
| 1102 |
</span>
|
| 1103 |
) : (
|
| 1104 |
userOptions.map((u) => (
|
|
|
|
| 1192 |
* server would store. The row menu was already suppressed for this id in wave 20;
|
| 1193 |
* the DRAG surfaces were not, because nothing ever constructed the group.
|
| 1194 |
*/
|
| 1195 |
+
// β W34-T25 (R11): `isSyntheticFolderId`, NOT `=== SHARED_FOLDER_ID`. There is more than
|
| 1196 |
+
// one synthesised group now (one per shared FOLDER), and a rail that suppressed writes on
|
| 1197 |
+
// "Shared with me" while offering them on a colleague's folder would emit `item_move` /
|
| 1198 |
+
// `folder_rename` / `folder_delete` against an id that exists in nobody's store.
|
| 1199 |
+
const isSynthetic = isSyntheticFolderId(gid);
|
| 1200 |
const shut = gid != null && collapsed.has(gid);
|
| 1201 |
return (
|
| 1202 |
<div
|
|
|
|
| 1218 |
//
|
| 1219 |
// The `is-drop-above` line above got this right (`&& foldDrag &&`), which is the
|
| 1220 |
// tell: two conditions written minutes apart, one guarded and one not.
|
| 1221 |
+
(foldDrag !== null && foldDrag === gid ? " is-folddrag" : "") +
|
| 1222 |
+
// ββ WAVE 34 Β· T22 (ruling R4) β THE ROOT SECTION IS A DROP TARGET WITH NO PIXELS.
|
| 1223 |
+
//
|
| 1224 |
+
// β THIS IS THE HALF `D-249` DOES NOT NAME, and on its own it reproduces the owner's
|
| 1225 |
+
// whole report. `groupByFolder` ALWAYS pushes the root group, so a user who has filed
|
| 1226 |
+
// every view into folders gets `<div class="cg-fold-root">` with no head, no rows and
|
| 1227 |
+
// no min-height: a ZERO-PIXEL drop zone. The drag starts fine (that is B1's fix) and
|
| 1228 |
+
// there is physically nowhere to release it. Even a POPULATED root is barely better β
|
| 1229 |
+
// this element has no padding, so its box is exactly the union of its rows, and each
|
| 1230 |
+
// row's own `onDragOver` calls `stopPropagation` to claim the reorder gesture. The
|
| 1231 |
+
// only surface that ever meant "file at the top level" was the sliver between rows.
|
| 1232 |
+
//
|
| 1233 |
+
// So while a VIEW drag is in flight the root section is given real height and a real
|
| 1234 |
+
// strip below its last row (index.css `.cg-fold-root.is-viewdrop`). The hover paint is
|
| 1235 |
+
// the existing `.is-drop` rule: this ticket supplies the AREA, not a second vocabulary.
|
| 1236 |
+
//
|
| 1237 |
+
// β `viewDrag !== null` explicitly, never a bare truthiness test on a value that idles
|
| 1238 |
+
// at `null` beside a `gid` that is `null` for this very section β that is the
|
| 1239 |
+
// `foldDrag === gid` trap two lines up, which faded the whole ungrouped section
|
| 1240 |
+
// permanently once already. `isRoot` is a separate boolean, so the two cannot collide.
|
| 1241 |
+
(isRoot && viewDrag !== null ? " is-viewdrop" : "")
|
| 1242 |
}
|
| 1243 |
{/* No drop handlers on the synthetic group β see `isSynthetic`. Spreading `{}`
|
| 1244 |
rather than branching the element keeps ONE render path for every group. */
|
|
|
|
| 1310 |
}}
|
| 1311 |
/>
|
| 1312 |
) : (
|
| 1313 |
+
<span
|
| 1314 |
+
className="cg-fold-name"
|
| 1315 |
+
/* β W34-T25 (R11) β WHOSE folder this is, on the folder itself.
|
| 1316 |
+
A synthesised shared-folder group looks exactly like one of the reader's
|
| 1317 |
+
own folders otherwise, and two people can share folders with the SAME
|
| 1318 |
+
NAME. The owner rides on every projected view (`_granted_views` stamps
|
| 1319 |
+
`owner`), so the group can answer it without a second wire field. Left as
|
| 1320 |
+
a `title` rather than painted into the name: the rail's indent is measured
|
| 1321 |
+
to the pixel and a second line here would push every view row. */
|
| 1322 |
+
title={
|
| 1323 |
+
isSynthetic && group.items[0]?.owner
|
| 1324 |
+
? `Shared with you by ${group.items[0].owner}`
|
| 1325 |
+
: undefined
|
| 1326 |
+
}
|
| 1327 |
+
>
|
| 1328 |
+
{group.folder.name}
|
| 1329 |
+
</span>
|
| 1330 |
)}
|
| 1331 |
{/* β WAVE 20 item 21 β THE VIEW COUNT IS DELETED, and the item is not
|
| 1332 |
really about the number. `.cg-view-more` (the "β¦" beside this button)
|
|
|
|
| 1349 |
row the server would refuse is three fake affordances rather than
|
| 1350 |
one. Views MOVE OUT of it normally (that is per-receiver placement),
|
| 1351 |
which is the only thing anyone needs to do to it. */}
|
| 1352 |
+
{/* β W34-T25 (R11) β widened from `=== SHARED_FOLDER_ID` to EVERY synthesised
|
| 1353 |
+
group, for the reason the block above gives: a colleague's shared folder is no
|
| 1354 |
+
more the reader's to rename, duplicate or delete than "Shared with me" is, and
|
| 1355 |
+
every row of that menu would be refused by a server that has no such folder. */}
|
| 1356 |
+
{isSynthetic ? null : (
|
| 1357 |
<button
|
| 1358 |
type="button"
|
| 1359 |
className="cg-view-more"
|
|
|
|
| 1399 |
: "")
|
| 1400 |
}
|
| 1401 |
key={view.id}
|
| 1402 |
+
// ββ WAVE 34 Β· T22 (ruling R4, `D-249`) β `mayFileView`, NOT `mayEditView`.
|
| 1403 |
+
// A read-only shared view could not BEGIN a drag, so it could never be filed OR
|
| 1404 |
+
// taken back out. The full argument, and the two places the SERVER already
|
| 1405 |
+
// disagreed with this line, are on `mayFileView` in `folders.ts` β which is where
|
| 1406 |
+
// the predicate lives so `verify_folders.py` can run its truth table.
|
| 1407 |
draggable={(foldersOn || viewsReorderable) && !renaming
|
| 1408 |
+
&& mayFileView(view, mayEditView(view, viewer))}
|
| 1409 |
onDragStart={(e) => {
|
| 1410 |
// β TWO TYPES ON ONE DRAG, and that is the discrimination C7 asks for.
|
| 1411 |
// `text/plain` is the FILE-INTO-A-FOLDER channel the folder groups already
|
|
|
|
| 1451 |
// ellipsis at 188px, so the hover is now the only way to read either one in
|
| 1452 |
// full. It used to show the note INSTEAD of the name, which meant a
|
| 1453 |
// described view could not have its own truncated name revealed at all.
|
| 1454 |
+
title={view.note ? `${view.name}: ${view.note}` : view.name}
|
| 1455 |
>
|
| 1456 |
{/* I12 β the row wears the view's CURRENT display mode, not a kind dot.
|
| 1457 |
Same geometry the mode switcher and the create flyout use, so "what
|
|
|
|
| 1501 |
// number is the same fact; the promise attached to it is not.
|
| 1502 |
title={
|
| 1503 |
view.config?.important
|
| 1504 |
+
? `${alertCounts[view.id].toLocaleString()} records match this view. `
|
| 1505 |
+
+ `You marked it important.`
|
| 1506 |
: `${alertCounts[view.id].toLocaleString()} records match this `
|
| 1507 |
+
+ `view. You are alerted when a new one arrives.`
|
| 1508 |
}
|
| 1509 |
>
|
| 1510 |
{alertCounts[view.id].toLocaleString()}
|
|
|
|
| 1527 |
title={countNotes[view.id]}
|
| 1528 |
aria-label={countNotes[view.id]}
|
| 1529 |
>
|
| 1530 |
+
{/* β W34-T26 (R6): an ASCII hyphen, not the EN DASH this shipped with.
|
| 1531 |
+
The en dash is exactly the character the standing rule bans from copy
|
| 1532 |
+
that reaches a screen, and `web_prose` CANNOT SEE IT: its JSX pass
|
| 1533 |
+
requires the text run to contain no newline, and this span has enough
|
| 1534 |
+
attributes that Prettier puts the closing `>` on its own line. Reported
|
| 1535 |
+
to A in mailbox C-3 as a structural hole rather than worked around
|
| 1536 |
+
silently. β It is also what makes T20's header badge and this one ONE
|
| 1537 |
+
mark rather than two that merely claim to match. */}
|
| 1538 |
+
-
|
| 1539 |
</span>
|
| 1540 |
)}
|
| 1541 |
{/* β wave17 R1 / C-LOCKV β ONE mark, TWO meanings, and they are genuinely
|
|
|
|
| 1576 |
title={
|
| 1577 |
(view.owner
|
| 1578 |
? `Shared with you by ${view.owner}.`
|
| 1579 |
+
: "Shared with you. Someone gave you access to this view.") +
|
| 1580 |
(view.sharedRole === "edit"
|
| 1581 |
? " You can edit it."
|
| 1582 |
: " You can view it, not change it.")
|
|
|
|
| 1607 |
}
|
| 1608 |
title={
|
| 1609 |
view.kind === "locked"
|
| 1610 |
+
? "Locked: this view shows only the records locked into it. " +
|
| 1611 |
"Filters, sorts and columns still narrow within them." +
|
| 1612 |
(isModeFrozen(view)
|
| 1613 |
? ` It also stays a ${MODE_LABELS[mode].toLowerCase()}.`
|
| 1614 |
: "")
|
| 1615 |
+
: `Locked: this view stays a ${MODE_LABELS[
|
| 1616 |
mode
|
| 1617 |
].toLowerCase()}. Filters, sorts and columns are still editable.`
|
| 1618 |
}
|
|
|
|
| 1662 |
);
|
| 1663 |
})}
|
| 1664 |
{group.folder && !shut && group.items.length === 0 && (
|
| 1665 |
+
<div className="cg-fold-empty">Empty. Drag a view here.</div>
|
| 1666 |
)}
|
| 1667 |
</div>
|
| 1668 |
);
|
|
|
|
| 1831 |
setLockFor(null);
|
| 1832 |
}}
|
| 1833 |
>
|
| 1834 |
+
Unlock and show the whole table again
|
| 1835 |
</button>
|
| 1836 |
)}
|
| 1837 |
</AnchoredOverlay>
|
|
|
|
| 1862 |
Not included:
|
| 1863 |
{addPreview.skipped.map((sk) => (
|
| 1864 |
<span key={sk.name} className="cg-fold-skip">
|
| 1865 |
+
{sk.name}: {sk.why}
|
| 1866 |
</span>
|
| 1867 |
))}
|
| 1868 |
</div>
|
|
|
|
| 1996 |
</div>
|
| 1997 |
</>
|
| 1998 |
)}
|
| 1999 |
+
{/* I17 (C4) β the view's IDENTITY (name, description, deletion) is what the view
|
| 2000 |
+
permission governs. Duplicate is deliberately NOT gated: making your own copy
|
| 2001 |
+
changes nothing about this view, and it is how someone without edit rights gets a
|
| 2002 |
+
version they can work in.
|
| 2003 |
+
β W34-T22 REMOVED "folder" FROM THAT LIST, and the removal is the point rather
|
| 2004 |
+
than a tidy-up. Filing a view writes a PLACEMENT into the reader's own workspace
|
| 2005 |
+
stratum and never touches the owner's object, so it is navigation rather than an
|
| 2006 |
+
identity edit; the drag site asks `mayFileView` now (`folders.ts`) while every row
|
| 2007 |
+
below still asks `mayEditView`. Left as a correction rather than deleted, because
|
| 2008 |
+
a sentence three hundred lines from the code it describes is exactly the shape that
|
| 2009 |
+
went stale unnoticed here. */}
|
| 2010 |
{canEditMenuView && (
|
| 2011 |
<button
|
| 2012 |
type="button"
|
|
|
|
| 2354 |
{/* Wave-10 item 9 moved the description off the grid and onto the view's row,
|
| 2355 |
so this sentence had to move with it β it described a banner that no longer
|
| 2356 |
exists. */}
|
| 2357 |
+
{/* β W34-T26 β "the view name", not "this view's name", and the apostrophe is the
|
| 2358 |
+
whole reason. `web_prose`'s TypeScript walk has no parser: an apostrophe in JSX
|
| 2359 |
+
TEXT opens a single-quoted string it never closes, so the walk ran from here to
|
| 2360 |
+
the end of this region, swallowing ~37 lines (this span's tail, the <textarea>
|
| 2361 |
+
and its attributes, two <button>s and a `//` comment). It then reported the em
|
| 2362 |
+
dash inside that swallowed COMMENT as a finding on this line: mislocated, and a
|
| 2363 |
+
false positive by the gate's own contract, since comments are exempt.
|
| 2364 |
+
β THE POINT OF THIS EDIT IS NOT THE GREEN. A walker that believes it is inside a
|
| 2365 |
+
string also SKIPS every real string after it, so those 37 lines were unscanned;
|
| 2366 |
+
removing the apostrophe restores the gate's sight over them. The underlying defect
|
| 2367 |
+
is `verify_prose.py`'s and is reported to A (post C-3; lane B raised the same
|
| 2368 |
+
thing as B-3, measuring 248 lines lost in one file). Do NOT rewrite the COMMENTS
|
| 2369 |
+
it reports to silence it, which is the version of this that destroys reasoning. */}
|
| 2370 |
+
<span>Shown under the view name in the list.</span>
|
| 2371 |
<textarea
|
| 2372 |
data-overlay-autofocus
|
| 2373 |
value={noteDraft}
|
web/src/customer-grid/apiBridge.ts
CHANGED
|
@@ -1401,3 +1401,60 @@ function standaloneSink(event: HostEvent): boolean {
|
|
| 1401 |
export function installStandaloneBridge(): void {
|
| 1402 |
setStandaloneSink(standaloneSink);
|
| 1403 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1401 |
export function installStandaloneBridge(): void {
|
| 1402 |
setStandaloneSink(standaloneSink);
|
| 1403 |
}
|
| 1404 |
+
|
| 1405 |
+
/**
|
| 1406 |
+
* ββ WAVE 34 (owner ruling R13, `W34-T54`) β RUN AN `ai_enrich` COLUMN OVER A SCOPE.
|
| 1407 |
+
*
|
| 1408 |
+
* `POST /api/v1/tables/{key}/fields/{fkey}/enrich`, body `{scope: "blank" | "always"}`.
|
| 1409 |
+
* `blank` fills only cells nothing has written; `always` re-runs every row.
|
| 1410 |
+
*
|
| 1411 |
+
* β ADDED BY THE INTEGRATOR AT CLOSE-OUT, and the reason is worth stating rather than burying.
|
| 1412 |
+
* Lane F built the server door, the menu row and the field kind, and left the host call site as an
|
| 1413 |
+
* OPTIONAL prop with the note *"nothing breaks if you decline"*. That is true of the BUILD and
|
| 1414 |
+
* false of the FEATURE: this is the only path that makes the bulk run reachable, and `W34-T54`'s
|
| 1415 |
+
* own `done-when` reads *"the bulk menu offers at least all rows and rows never filled"*. Left
|
| 1416 |
+
* unwired, that ticket would have been BUILT with its acceptance test unreachable β the exact
|
| 1417 |
+
* shape this repo has shipped five times ([[mounted-is-not-callable]]).
|
| 1418 |
+
*
|
| 1419 |
+
* β IT SPENDS MONEY, so it reports rather than returns a boolean. The server answers a RUN REPORT
|
| 1420 |
+
* (`filled`, `failed`, `skipped` by reason, `tokens`, `limit`) and the caller shows the sentence:
|
| 1421 |
+
* a bulk enrichment that quietly did 3 of 900 rows because a ceiling bit is precisely the silent
|
| 1422 |
+
* cap R6's standing rule forbids, and a `true` would hide it.
|
| 1423 |
+
*/
|
| 1424 |
+
export async function enrichField(
|
| 1425 |
+
tableKey: string,
|
| 1426 |
+
fieldKey: string,
|
| 1427 |
+
scope: "blank" | "always"
|
| 1428 |
+
): Promise<EnrichReport | null> {
|
| 1429 |
+
try {
|
| 1430 |
+
const res = await fetch(
|
| 1431 |
+
`${API_V1}/tables/${encodeURIComponent(tableKey)}/fields/${encodeURIComponent(fieldKey)}/enrich`,
|
| 1432 |
+
{
|
| 1433 |
+
method: "POST",
|
| 1434 |
+
credentials: CREDENTIALS,
|
| 1435 |
+
headers: JSON_HEADERS,
|
| 1436 |
+
body: JSON.stringify({ scope }),
|
| 1437 |
+
}
|
| 1438 |
+
);
|
| 1439 |
+
if (handledSession(res)) return null;
|
| 1440 |
+
const body = (await readJson(res)) as (EnrichReport & { error?: { message?: string } }) | null;
|
| 1441 |
+
if (!res.ok) {
|
| 1442 |
+
// The server states WHY (no key, a token ceiling, a locked column). Its sentence beats ours.
|
| 1443 |
+
signal(TOAST_EVENT, refusalMessage(body, res.status));
|
| 1444 |
+
return null;
|
| 1445 |
+
}
|
| 1446 |
+
return body ?? null;
|
| 1447 |
+
} catch {
|
| 1448 |
+
signal(DATA_ERROR_EVENT, "The enrichment run could not be reached. Nothing was changed.");
|
| 1449 |
+
return null;
|
| 1450 |
+
}
|
| 1451 |
+
}
|
| 1452 |
+
|
| 1453 |
+
/** What `POST .../enrich` answers. Shapes the sentence the caller shows. */
|
| 1454 |
+
export type EnrichReport = {
|
| 1455 |
+
filled?: number;
|
| 1456 |
+
failed?: number;
|
| 1457 |
+
skipped?: Record<string, number>;
|
| 1458 |
+
tokens?: number;
|
| 1459 |
+
limit?: number;
|
| 1460 |
+
};
|
web/src/customer-grid/cells.ts
CHANGED
|
@@ -610,6 +610,43 @@ function baseCell(
|
|
| 610 |
allowOverlay: false,
|
| 611 |
};
|
| 612 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 613 |
case "rating": {
|
| 614 |
const max = ratingMax(field);
|
| 615 |
const n = Math.max(0, Math.min(max, Math.round(num(v))));
|
|
|
|
| 610 |
allowOverlay: false,
|
| 611 |
};
|
| 612 |
}
|
| 613 |
+
case "ai_enrich": {
|
| 614 |
+
// ββ Wave-34 (owner ruling R13) β the AI ENRICHMENT cell. The value is an ordinary stored
|
| 615 |
+
// string, so this is a Text cell; what it is NOT is a `formula` (recomputed in the browser)
|
| 616 |
+
// or a `rollup` (read through from another table). Nobody has to be told a number came from
|
| 617 |
+
// somewhere else here, because the answer is words.
|
| 618 |
+
//
|
| 619 |
+
// Written the same way `code` and `json` are, and for their reason rather than a new one:
|
| 620 |
+
// Β· `allowOverlay: false` β glide's ONE-LINE overlay editor over multi-line prose is an
|
| 621 |
+
// editor that can only damage the value. The place a person edits an answer is the
|
| 622 |
+
// record drawer, which shows the whole thing.
|
| 623 |
+
// Β· `readonly` deliberately NOT set, so the paste path that legitimately writes a whole
|
| 624 |
+
// answer still works.
|
| 625 |
+
// Β· `copyData` the RAW text, never the preview, or copying this column would yield
|
| 626 |
+
// "Ann is a florist +2 more", which is not data and cannot be pasted back. That is the
|
| 627 |
+
// lesson the image cell booked in wave 19 and the date cell repeated in wave 26.
|
| 628 |
+
//
|
| 629 |
+
// β EDITING IS THE POINT, NOT AN AFTERTHOUGHT, which is why the column is NOT in
|
| 630 |
+
// `READONLY_CELL_TYPES` and is NOT in `is_computed_cell` server-side: typing over a cell is
|
| 631 |
+
// what marks it the human's, and `core.user_tables.ai_enrich_human_authored` then protects
|
| 632 |
+
// it from every automatic run forever.
|
| 633 |
+
//
|
| 634 |
+
// β OWED, AND NAMED RATHER THAN FAKED: this branch cannot paint the agent-written /
|
| 635 |
+
// human-edited / stale / errored STATE, because `makeCell(field, v, editable, avatars)` is
|
| 636 |
+
// handed the value and never the row, and the per-cell mark lives in a stratum the row
|
| 637 |
+
// payload does not carry. The drawer shows the state; the canvas will once the call site in
|
| 638 |
+
// `CustomerGrid.tsx` (lane C's file) passes it. Rendering a guessed state here would be
|
| 639 |
+
// worse than none: "done" on a cell nothing has run for is a claim.
|
| 640 |
+
const raw = String(v ?? "");
|
| 641 |
+
const text = codePreview(raw);
|
| 642 |
+
return {
|
| 643 |
+
kind: GridCellKind.Text,
|
| 644 |
+
data: text,
|
| 645 |
+
displayData: text,
|
| 646 |
+
copyData: raw,
|
| 647 |
+
allowOverlay: false,
|
| 648 |
+
};
|
| 649 |
+
}
|
| 650 |
case "rating": {
|
| 651 |
const max = ratingMax(field);
|
| 652 |
const n = Math.max(0, Math.min(max, Math.round(num(v))));
|
web/src/customer-grid/display.ts
CHANGED
|
@@ -675,6 +675,16 @@ export function formatDisplay(field: Field, v: CellValue): string {
|
|
| 675 |
// for `json`'s exact reason: a snippet is multi-LINE, and falling through to `default`
|
| 676 |
// would put raw newlines into a CSV cell, a kanban card and a calendar chip.
|
| 677 |
return codePreview(v);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 678 |
case "automation":
|
| 679 |
// The machine-written line, verbatim. Explicit rather than left to the `default` branch
|
| 680 |
// below: `formula` fell through a default once and printed every text result as `0` for
|
|
|
|
| 675 |
// for `json`'s exact reason: a snippet is multi-LINE, and falling through to `default`
|
| 676 |
// would put raw newlines into a CSV cell, a kanban card and a calendar chip.
|
| 677 |
return codePreview(v);
|
| 678 |
+
case "ai_enrich":
|
| 679 |
+
// ββ Wave-34 (owner ruling R13) β an enrichment answer is model-authored PROSE and can run
|
| 680 |
+
// to several lines, so it is explicit here for exactly the reason `json` and `code` are:
|
| 681 |
+
// this function feeds ListView, KanbanView, the calendar, the record panel and all four
|
| 682 |
+
// EXPORT formats, and `default` would put raw newlines into a CSV cell.
|
| 683 |
+
// β `codePreview` is REUSED rather than given a prose-flavoured twin. Its "first line
|
| 684 |
+
// +N more" is already this product's one way of saying "a multi-line value in a one-line
|
| 685 |
+
// slot", and the count is checkable against what the drawer shows; a second helper would
|
| 686 |
+
// be a second answer to one question ([[one-question-two-normalizers]]).
|
| 687 |
+
return codePreview(v);
|
| 688 |
case "automation":
|
| 689 |
// The machine-written line, verbatim. Explicit rather than left to the `default` branch
|
| 690 |
// below: `formula` fell through a default once and printed every text result as `0` for
|
web/src/customer-grid/folders.ts
CHANGED
|
@@ -239,6 +239,62 @@ export interface FolderGroup<T> {
|
|
| 239 |
export const SHARED_FOLDER_ID = "__shared__";
|
| 240 |
export const SHARED_FOLDER_NAME = "Shared with me";
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
/**
|
| 243 |
* ββ WAVE 32 Β· T27 (owner item 20) β **"FILED AT ROOT", AS A VALUE.**
|
| 244 |
*
|
|
@@ -293,11 +349,26 @@ export function groupByFolder<T>(
|
|
| 293 |
items: T[],
|
| 294 |
folders: GridFolder[],
|
| 295 |
folderIdOf: (item: T) => string | null,
|
| 296 |
-
isShared?: (item: T) => boolean
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
): FolderGroup<T>[] {
|
| 298 |
const buckets = new Map<string, T[]>(folders.map((f) => [f.id, []]));
|
| 299 |
const root: T[] = [];
|
| 300 |
const shared: T[] = [];
|
|
|
|
|
|
|
| 301 |
for (const item of items) {
|
| 302 |
const id = folderIdOf(item);
|
| 303 |
const bucket = id == null ? undefined : buckets.get(id);
|
|
@@ -308,13 +379,38 @@ export function groupByFolder<T>(
|
|
| 308 |
// value, `isShared` won, and a shared view dragged to root returned to "Shared with me" on
|
| 309 |
// the next render β the owner's item 20, in one branch.
|
| 310 |
else if (id === ROOT_FOLDER_ID) root.push(item);
|
| 311 |
-
else if (isShared?.(item))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 312 |
else root.push(item);
|
| 313 |
}
|
| 314 |
const out: FolderGroup<T>[] = folders.map((f) => ({ folder: f, items: buckets.get(f.id) ?? [] }));
|
| 315 |
out.push({ folder: null, items: root });
|
| 316 |
if (shared.length)
|
| 317 |
out.push({ folder: { id: SHARED_FOLDER_ID, name: SHARED_FOLDER_NAME }, items: shared });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 318 |
return out;
|
| 319 |
}
|
| 320 |
|
|
@@ -356,6 +452,36 @@ export function reorderFolderIds(
|
|
| 356 |
return next;
|
| 357 |
}
|
| 358 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 359 |
/** A fresh folder id. Client-generated, like every other id in this component. */
|
| 360 |
export function newFolderId(): string {
|
| 361 |
const rand =
|
|
|
|
| 239 |
export const SHARED_FOLDER_ID = "__shared__";
|
| 240 |
export const SHARED_FOLDER_NAME = "Shared with me";
|
| 241 |
|
| 242 |
+
/**
|
| 243 |
+
* ββ WAVE 34 Β· T25 (ruling R11) β A SHARED **FOLDER** GETS ITS OWN GROUP, under this prefix.
|
| 244 |
+
*
|
| 245 |
+
* R11: *"A folder (with the Views inside it) can be shared with a team member."* Everything below
|
| 246 |
+
* the client already worked and was VERIFIED before this was written: `core/shares.py::KINDS` has
|
| 247 |
+
* carried `'folder'` since wave 20, the rail already offers "Share folder", and
|
| 248 |
+
* `grid_events.py::_granted_views` has projected each granted folder's views since `D-37`. What
|
| 249 |
+
* did not work is the last inch: every shared view landed in ONE flat "Shared with me" group, so
|
| 250 |
+
* the receiver got loose views and **the folder itself was invisible**.
|
| 251 |
+
*
|
| 252 |
+
* β The name has been on the wire the whole time. `_granted_views` stamps
|
| 253 |
+
* `v['sharedFolder'] = <folder name>` with the comment *"so the client can group these together
|
| 254 |
+
* later"*, and a census found ZERO readers of it anywhere in `web/src` β a flag shipped without
|
| 255 |
+
* its reader. This is the reader.
|
| 256 |
+
*
|
| 257 |
+
* β A PREFIX RATHER THAN ONE RESERVED ID, because there can be several: two colleagues can each
|
| 258 |
+
* share a folder with you. {@link isSyntheticFolderId} is what every affordance must ask instead
|
| 259 |
+
* of comparing against `SHARED_FOLDER_ID` β a rail that suppressed actions on one synthetic group
|
| 260 |
+
* and offered them on another would emit `item_move`/`folder_rename` against a folder id that
|
| 261 |
+
* exists in nobody's store.
|
| 262 |
+
*
|
| 263 |
+
* ββ THE KEY IS **OWNER + NAME**, NOT NAME, AND THE FIRST DRAFT OF THIS FUNCTION GOT IT WRONG.
|
| 264 |
+
* It keyed on the folder name alone while the comment right here claimed to handle two colleagues
|
| 265 |
+
* sharing folders with the SAME NAME β so those two folders merged into ONE group, and the head's
|
| 266 |
+
* `title` (which reads the first item's `owner`) misattributed every view from the second sharer.
|
| 267 |
+
* Per-view `sharedRole` was never affected, so nothing leaked; what broke was the rail telling you
|
| 268 |
+
* whose work you were reading, which is the entire point of the group. **A comment that describes
|
| 269 |
+
* the case the code does not handle is worse than no comment**, because it is what a reviewer
|
| 270 |
+
* checks against. Found by this ticket's own verifier reading the two side by side.
|
| 271 |
+
*
|
| 272 |
+
* β NOTHING MAY EVER WRITE ONE. Like `SHARED_FOLDER_ID` these ids are a READING of the view list,
|
| 273 |
+
* not a record: no `order`, no icon, no row in `folders`.
|
| 274 |
+
*/
|
| 275 |
+
export const SHARED_FOLDER_PREFIX = "__sharedfld__:";
|
| 276 |
+
|
| 277 |
+
/**
|
| 278 |
+
* The synthetic group id for a folder somebody shared with you.
|
| 279 |
+
*
|
| 280 |
+
* β `owner` FIRST and separated by a character usernames cannot contain. `core/users.py` keeps
|
| 281 |
+
* usernames lower-case and non-empty; a folder NAME is free text and can contain anything a user
|
| 282 |
+
* types, `:` included. Putting the free-text half last means no pair of (owner, name) can spell
|
| 283 |
+
* another pair's id, whatever somebody calls a folder.
|
| 284 |
+
*/
|
| 285 |
+
export function sharedFolderGroupId(owner: string, name: string): string {
|
| 286 |
+
return `${SHARED_FOLDER_PREFIX}${owner}:${name}`;
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
/**
|
| 290 |
+
* Is this group id SYNTHESISED rather than stored? True for "Shared with me" and for every
|
| 291 |
+
* shared-folder group. Every drag, drop, rename and delete affordance in the rail asks this.
|
| 292 |
+
*/
|
| 293 |
+
export function isSyntheticFolderId(id: string | null | undefined): boolean {
|
| 294 |
+
return id === SHARED_FOLDER_ID
|
| 295 |
+
|| (typeof id === "string" && id.startsWith(SHARED_FOLDER_PREFIX));
|
| 296 |
+
}
|
| 297 |
+
|
| 298 |
/**
|
| 299 |
* ββ WAVE 32 Β· T27 (owner item 20) β **"FILED AT ROOT", AS A VALUE.**
|
| 300 |
*
|
|
|
|
| 349 |
items: T[],
|
| 350 |
folders: GridFolder[],
|
| 351 |
folderIdOf: (item: T) => string | null,
|
| 352 |
+
isShared?: (item: T) => boolean,
|
| 353 |
+
/**
|
| 354 |
+
* β WAVE 34 Β· T25 (R11) β the folder a shared item arrived through, if it did.
|
| 355 |
+
*
|
| 356 |
+
* Returns the folder's NAME and its OWNER, because the pair is the identity: the wire carries
|
| 357 |
+
* no folder id for a grant (`_granted_views` stamps a name and an owner, never an id), and the
|
| 358 |
+
* name alone is not unique across sharers.
|
| 359 |
+
*
|
| 360 |
+
* Optional, so every existing caller (the cohort rail, the tests) keeps the pre-T25 shape
|
| 361 |
+
* byte-for-byte: with it omitted, every shared item falls into the flat "Shared with me"
|
| 362 |
+
* group exactly as before. Supplied, the shared views SPLIT by the folder that carried them,
|
| 363 |
+
* and the folder finally appears on the receiver's screen.
|
| 364 |
+
*/
|
| 365 |
+
sharedFolderOf?: (item: T) => { name: string; owner: string } | null
|
| 366 |
): FolderGroup<T>[] {
|
| 367 |
const buckets = new Map<string, T[]>(folders.map((f) => [f.id, []]));
|
| 368 |
const root: T[] = [];
|
| 369 |
const shared: T[] = [];
|
| 370 |
+
/** synthetic group id -> {name, views}. Keyed by OWNER+NAME; see `SHARED_FOLDER_PREFIX`. */
|
| 371 |
+
const sharedFolders = new Map<string, { name: string; items: T[] }>();
|
| 372 |
for (const item of items) {
|
| 373 |
const id = folderIdOf(item);
|
| 374 |
const bucket = id == null ? undefined : buckets.get(id);
|
|
|
|
| 379 |
// value, `isShared` won, and a shared view dragged to root returned to "Shared with me" on
|
| 380 |
// the next render β the owner's item 20, in one branch.
|
| 381 |
else if (id === ROOT_FOLDER_ID) root.push(item);
|
| 382 |
+
else if (isShared?.(item)) {
|
| 383 |
+
// β W34-T25 (R11): a shared view that arrived through a FOLDER goes to that folder's own
|
| 384 |
+
// group; one that was granted directly still goes to the flat "Shared with me". The two
|
| 385 |
+
// are different facts and the receiver can see which is which.
|
| 386 |
+
const via = sharedFolderOf?.(item);
|
| 387 |
+
const name = via ? String(via.name || "").trim() : "";
|
| 388 |
+
const owner = via ? String(via.owner || "").trim() : "";
|
| 389 |
+
// β BOTH halves required. A folder name with no owner cannot be told apart from another
|
| 390 |
+
// sharer's folder of the same name, so it falls back to the flat group rather than
|
| 391 |
+
// inventing a group that might merge two people's work under one person's label.
|
| 392 |
+
if (name && owner) {
|
| 393 |
+
const key = sharedFolderGroupId(owner, name);
|
| 394 |
+
const bucket2 = sharedFolders.get(key);
|
| 395 |
+
if (bucket2) bucket2.items.push(item);
|
| 396 |
+
else sharedFolders.set(key, { name, items: [item] });
|
| 397 |
+
} else shared.push(item);
|
| 398 |
+
}
|
| 399 |
else root.push(item);
|
| 400 |
}
|
| 401 |
const out: FolderGroup<T>[] = folders.map((f) => ({ folder: f, items: buckets.get(f.id) ?? [] }));
|
| 402 |
out.push({ folder: null, items: root });
|
| 403 |
if (shared.length)
|
| 404 |
out.push({ folder: { id: SHARED_FOLDER_ID, name: SHARED_FOLDER_NAME }, items: shared });
|
| 405 |
+
// β SORTED, not first-seen. A Map preserves insertion order, which here is the order the VIEWS
|
| 406 |
+
// happen to arrive in β so one view moving could reorder whole folders in the rail for no reason
|
| 407 |
+
// a reader could name. Sorting on the composite ID (owner first) is stable across every payload
|
| 408 |
+
// AND groups one colleague's folders together, which reads better than interleaving two people's
|
| 409 |
+
// folders alphabetically by name.
|
| 410 |
+
for (const key of [...sharedFolders.keys()].sort((a, b) => a.localeCompare(b))) {
|
| 411 |
+
const grp = sharedFolders.get(key)!;
|
| 412 |
+
out.push({ folder: { id: key, name: grp.name }, items: grp.items });
|
| 413 |
+
}
|
| 414 |
return out;
|
| 415 |
}
|
| 416 |
|
|
|
|
| 452 |
return next;
|
| 453 |
}
|
| 454 |
|
| 455 |
+
/**
|
| 456 |
+
* ββ WAVE 34 Β· T22 (ruling R4, `D-249`) β MAY THIS VIEWER FILE THIS VIEW INTO A FOLDER?
|
| 457 |
+
*
|
| 458 |
+
* β NOT THE SAME QUESTION AS `mayEditView`, AND CONFLATING THEM IS THE DEFECT. The rail gated its
|
| 459 |
+
* `draggable` on `mayEditView`, which fail-closes on a shared view whose role is not `edit` β so a
|
| 460 |
+
* read-only shared view could not BEGIN a drag, and the owner's report is that a view cannot be
|
| 461 |
+
* taken out of a folder. Filing is not a content edit: it writes a PLACEMENT into the receiver's
|
| 462 |
+
* own workspace stratum and never touches the owner's object.
|
| 463 |
+
*
|
| 464 |
+
* β THE SERVER ALREADY AGREED, IN TWO PLACES, BEFORE THIS FUNCTION EXISTED. `grid_events.py::
|
| 465 |
+
* table_workspace` merges granted views into `ws['views']`, which is the very set `item_move`'s
|
| 466 |
+
* own-ids test reads β so the write this drag produces is ACCEPTED today. And the `view_reorder`
|
| 467 |
+
* branch beside it carries the rule in prose: *"NO OWNERSHIP FILTER β¦ refusing ids they do not own
|
| 468 |
+
* would make exactly those un-draggable, which is the half of sharing people notice."* The client
|
| 469 |
+
* was the only refuser, and it was refusing a write the server was ready to take.
|
| 470 |
+
*
|
| 471 |
+
* β `mayEdit` IS A PARAMETER, NOT AN IMPORT, and that is deliberate: this module is
|
| 472 |
+
* "pure and React-free so verify_folders.py can run it under node" (see the header), and its only
|
| 473 |
+
* imports today are one constant and one type. Taking the verdict rather than the resolver keeps
|
| 474 |
+
* the truth table testable in isolation and keeps this file's dependency surface where its own
|
| 475 |
+
* header promised it would stay.
|
| 476 |
+
*
|
| 477 |
+
* β USE IT ONLY AT THE DRAG SITE. Every other affordance the rail gates on `mayEditView` β rename,
|
| 478 |
+
* description, delete, permissions β really is a content edit on somebody else's object, and the
|
| 479 |
+
* server really does refuse those.
|
| 480 |
+
*/
|
| 481 |
+
export function mayFileView(view: { shared?: boolean }, mayEdit: boolean): boolean {
|
| 482 |
+
return view.shared === true || mayEdit;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
/** A fresh folder id. Client-generated, like every other id in this component. */
|
| 486 |
export function newFolderId(): string {
|
| 487 |
const rand =
|
web/src/customer-grid/iconShapes.ts
CHANGED
|
@@ -159,6 +159,18 @@ export const TYPE_SHAPES: Record<FieldType, IconShape[]> = {
|
|
| 159 |
{ d: "M4.8 8h6.4" },
|
| 160 |
{ d: "M6.6 11.6h2.8" },
|
| 161 |
],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
};
|
| 163 |
|
| 164 |
/**
|
|
@@ -617,6 +629,10 @@ export const TYPE_LABELS: Record<FieldType, string> = {
|
|
| 617 |
// is called everywhere else in this wave (the ruling, the language picker, the viewer header),
|
| 618 |
// and it says what the column holds without implying the product will run it.
|
| 619 |
code: "Code",
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
|
| 621 |
};
|
| 622 |
|
|
|
|
| 159 |
{ d: "M4.8 8h6.4" },
|
| 160 |
{ d: "M6.6 11.6h2.8" },
|
| 161 |
],
|
| 162 |
+
// β WAVE 34 Β· T53 (R13), drawn by C on F's ask (`F-2`) β AI ENRICHMENT.
|
| 163 |
+
// The SPARKLE, because it is the product's own AI mark already: `Shell.tsx::SparkIcon` wears it
|
| 164 |
+
// on the Assistant rail row, so a column the AI fills reads as the same family rather than as a
|
| 165 |
+
// second vocabulary for one idea. Deliberately NOT the `automation` cycle-ring two entries up
|
| 166 |
+
// (that means "a job that runs, repeatedly", and an enrichment can be manual) and not a robot
|
| 167 |
+
// head (`Shell.tsx::RobotIcon` claims that for the Agents MODULE; a field is not a module).
|
| 168 |
+
// β TWO stars, not one: a lone four-point star at 16px with a 1.35 stroke reads as a plus sign.
|
| 169 |
+
// The small companion is what makes the mark say "sparkle".
|
| 170 |
+
ai_enrich: [
|
| 171 |
+
{ d: "M6.6 2.6l1.3 3.1 3.1 1.3-3.1 1.3-1.3 3.1-1.3-3.1L2.2 7l3.1-1.3z" },
|
| 172 |
+
{ d: "M11.9 9.8l.7 1.6 1.6.7-1.6.7-.7 1.6-.7-1.6-1.6-.7 1.6-.7z" },
|
| 173 |
+
],
|
| 174 |
};
|
| 175 |
|
| 176 |
/**
|
|
|
|
| 629 |
// is called everywhere else in this wave (the ruling, the language picker, the viewer header),
|
| 630 |
// and it says what the column holds without implying the product will run it.
|
| 631 |
code: "Code",
|
| 632 |
+
// β WAVE 34 Β· T53 (R13), on F's ask (`F-2`) β the owner's own noun for the kind: "a field kind
|
| 633 |
+
// called AI enrichment". Not "AI field" (every field in an AI-built view would qualify) and not
|
| 634 |
+
// "Generate" (that names the verb, and the column's value is the point, not the act).
|
| 635 |
+
ai_enrich: "AI enrichment",
|
| 636 |
status: "Lifecycle status (Odoo)", // never creatable; present so the map stays total
|
| 637 |
};
|
| 638 |
|
web/src/customer-grid/types.ts
CHANGED
|
@@ -76,7 +76,31 @@ export type FieldType =
|
|
| 76 |
// a per-column `language` that selects a HIGHLIGHTER and nothing else. R13: "syntax-highlighted
|
| 77 |
// storage + language config, NO execution engine" β no value of `language` may ever reach a
|
| 78 |
// run path, and there is none to reach.
|
| 79 |
-
| "code"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
| 81 |
export type FieldSource = "odoo" | "overlay";
|
| 82 |
|
|
@@ -163,6 +187,18 @@ export const CREATABLE_TYPES: readonly FieldType[] = [
|
|
| 163 |
// declared language stores and renders as plain text, which is a legitimate state. So
|
| 164 |
// `ColumnMenu.canCreate` does not gate on it, and the language picker is an ordinary edit.
|
| 165 |
"code",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
];
|
| 167 |
|
| 168 |
/**
|
|
@@ -1798,6 +1834,32 @@ export interface Field {
|
|
| 1798 |
* `isMachineOwned` β a person types here, so it must NOT take the machine wash.
|
| 1799 |
*/
|
| 1800 |
profile?: { source?: string };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1801 |
/**
|
| 1802 |
* β 2026-08-07 β a LINK to rows of another user database (Airtable's `multipleRecordLinks`).
|
| 1803 |
* The cell holds a COMMA-JOINED list of linked row ids, because a `Row` value is a scalar
|
|
|
|
| 76 |
// a per-column `language` that selects a HIGHLIGHTER and nothing else. R13: "syntax-highlighted
|
| 77 |
// storage + language config, NO execution engine" β no value of `language` may ever reach a
|
| 78 |
// run path, and there is none to reach.
|
| 79 |
+
| "code"
|
| 80 |
+
// ββ WAVE-34 (owner ruling R13) β AI ENRICHMENT: a prompt per row that populates text. The
|
| 81 |
+
// cell is an ordinary stored STRING, which is what separates it from `formula` (recomputed in
|
| 82 |
+
// the browser on every paint) and `rollup` (read through from another table). A person may type
|
| 83 |
+
// over it, and doing so permanently protects that cell from every automatic run, so it is
|
| 84 |
+
// deliberately NOT in `READONLY_CELL_TYPES`. Its config lives in `Field.aiEnrich`.
|
| 85 |
+
| "ai_enrich";
|
| 86 |
+
|
| 87 |
+
/**
|
| 88 |
+
* ββ WAVE-34 (owner ruling R13) β WHOSE VALUE IS IN THIS ENRICHMENT CELL.
|
| 89 |
+
*
|
| 90 |
+
* A row carries `_ai_<colId>` for every `ai_enrich` column that has a state to report, stamped by
|
| 91 |
+
* `routes_tables._stamp_ai_states` beside the `_created` / `lat` / `lon` keys the row already
|
| 92 |
+
* carries. An ABSENT key means nothing has run for that cell.
|
| 93 |
+
*
|
| 94 |
+
* β Declared HERE, once, because two client surfaces read it (the record drawer today, the grid
|
| 95 |
+
* cell when its call site can pass a row) and a prefix spelled twice is a prefix that will be
|
| 96 |
+
* spelled differently once. The server holds the matching constant in `routes_tables.py`.
|
| 97 |
+
* β Collision-proof by construction: `_clean_field` strips leading underscores off every field
|
| 98 |
+
* key, so no column can ever be named `_ai_*`.
|
| 99 |
+
*/
|
| 100 |
+
export const AI_STATE_PREFIX = "_ai_";
|
| 101 |
+
|
| 102 |
+
/** What `_ai_<colId>` can say. `empty` is never stamped: an absent key IS empty. */
|
| 103 |
+
export type AiCellState = "agent" | "human" | "stale" | "error";
|
| 104 |
|
| 105 |
export type FieldSource = "odoo" | "overlay";
|
| 106 |
|
|
|
|
| 187 |
// declared language stores and renders as plain text, which is a legitimate state. So
|
| 188 |
// `ColumnMenu.canCreate` does not gate on it, and the language picker is an ordinary edit.
|
| 189 |
"code",
|
| 190 |
+
// ββ WAVE-34 (owner ruling R13) β AI enrichment, landing in the SAME change as `FieldType`,
|
| 191 |
+
// `UT_FIELD_TYPES`, `CUSTOM_FIELD_TYPES`, the shape/label tables and the cell renderer, for the
|
| 192 |
+
// reason the three comments above already give in their own words. β THE WAVE PLANNED IT AS TWO
|
| 193 |
+
// TICKETS (server in `W34-T51`, client in `W34-T53`) AND THAT SPLIT IS NOT BUILDABLE: three
|
| 194 |
+
// parity gates chain over these sets and none permits a partial landing. `api_api` went
|
| 195 |
+
// 969/969 to 968/969 on the server half alone, measured by another lane on the shared tree.
|
| 196 |
+
//
|
| 197 |
+
// β LIKE `link`/`rollup` AND UNLIKE `code`, IT REQUIRES ITS CONFIG BAG: `_clean_field` returns
|
| 198 |
+
// None for an `ai_enrich` column with no `aiEnrich` bag, because a prompt is the only thing
|
| 199 |
+
// that can ever produce a value here. So `ColumnMenu.canCreate` gates on it, or the create
|
| 200 |
+
// button is the "created, named, configured, gone" failure arriving one door earlier.
|
| 201 |
+
"ai_enrich",
|
| 202 |
];
|
| 203 |
|
| 204 |
/**
|
|
|
|
| 1834 |
* `isMachineOwned` β a person types here, so it must NOT take the machine wash.
|
| 1835 |
*/
|
| 1836 |
profile?: { source?: string };
|
| 1837 |
+
/**
|
| 1838 |
+
* ββ WAVE-34 (owner ruling R13) β the AI ENRICHMENT column's config: a prompt per row that
|
| 1839 |
+
* populates text, with the token ceiling in the field's own configuration.
|
| 1840 |
+
*
|
| 1841 |
+
* `prompt` may name other columns with `{key}` tokens, the same brace syntax a formula uses, so
|
| 1842 |
+
* somebody who has written one already knows this one. `maxTokens` bounds ONE call's output and
|
| 1843 |
+
* is also what the run's whole budget is derived from. `trigger.cron` is present only when
|
| 1844 |
+
* `mode` is `"schedule"` β a cadence stored under `manual` is a setting nothing reads.
|
| 1845 |
+
* `overwrite` decides what an automatic run may replace among the cells THE AGENT ITSELF wrote;
|
| 1846 |
+
* a human-edited cell is never overwritten by any of them, which is why `"human"` is not one of
|
| 1847 |
+
* the values (`core.user_tables.ai_enrich_human_authored` is the law).
|
| 1848 |
+
*
|
| 1849 |
+
* β THE BAG IS HERE WHILE `"ai_enrich"` IS NOT YET IN `CREATABLE_TYPES`, and the asymmetry is
|
| 1850 |
+
* deliberate: the server stores this key today (`W34-T51`) and `verify_grid_ux::c_field_parity`
|
| 1851 |
+
* tests server->client, so the interface has to mirror it now. The add-field MENU arrives with
|
| 1852 |
+
* `W34-T53`. A kind the server accepts and the client does not offer is invisible; a kind the
|
| 1853 |
+
* client offers and the server refuses deletes the user's column, which is the direction that
|
| 1854 |
+
* shipped as a live defect for three waves (`image`, wave 19).
|
| 1855 |
+
*/
|
| 1856 |
+
aiEnrich?: {
|
| 1857 |
+
prompt: string;
|
| 1858 |
+
model?: string;
|
| 1859 |
+
maxTokens: number;
|
| 1860 |
+
trigger: { mode: "manual" | "on_change" | "schedule"; cron?: string };
|
| 1861 |
+
overwrite: "blank" | "stale" | "always";
|
| 1862 |
+
};
|
| 1863 |
/**
|
| 1864 |
* β 2026-08-07 β a LINK to rows of another user database (Airtable's `multipleRecordLinks`).
|
| 1865 |
* The cell holds a COMMA-JOINED list of linked row ids, because a `Row` value is a scalar
|
web/src/filter-kit/FieldsHidePanel.tsx
CHANGED
|
@@ -200,7 +200,7 @@ export function FieldsHidePanel({
|
|
| 200 |
locked
|
| 201 |
? `${fieldLabel(f)} is always first`
|
| 202 |
: canReorder
|
| 203 |
-
? `Reorder ${fieldLabel(f)}
|
| 204 |
: `Reorder ${fieldLabel(f)} (clear the search first)`
|
| 205 |
}
|
| 206 |
title={
|
|
|
|
| 200 |
locked
|
| 201 |
? `${fieldLabel(f)} is always first`
|
| 202 |
: canReorder
|
| 203 |
+
? `Reorder ${fieldLabel(f)}: drag, or use the arrow keys`
|
| 204 |
: `Reorder ${fieldLabel(f)} (clear the search first)`
|
| 205 |
}
|
| 206 |
title={
|
web/src/filter-kit/FilterBuilderPanel.tsx
CHANGED
|
@@ -1,988 +1,1003 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// filter-kit / FilterBuilderPanel.tsx
|
| 3 |
-
// The Airtable-style RECURSIVE condition builder, as a MOUNTABLE PANEL.
|
| 4 |
-
//
|
| 5 |
-
// Extracted from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so the admin
|
| 6 |
-
// permission editor and the grid toolbar build filters with the SAME control
|
| 7 |
-
// rather than two that drift. Behaviour is identical to the toolbar's β this is a
|
| 8 |
-
// move, not a rewrite.
|
| 9 |
-
//
|
| 10 |
-
// THE KIT FETCHES NOTHING. Every vocabulary it needs (fields, cohorts, measures,
|
| 11 |
-
// the people a `user` field may name) arrives as a prop, which is what lets the
|
| 12 |
-
// same panel serve a module whose rows this browser has never loaded.
|
| 13 |
-
// ---------------------------------------------------------------------------
|
| 14 |
-
|
| 15 |
-
import { useCallback, useMemo, useState } from "react";
|
| 16 |
-
import type { ReactNode } from "react";
|
| 17 |
-
import type {
|
| 18 |
-
Conjunction,
|
| 19 |
-
Field,
|
| 20 |
-
FilterGroup,
|
| 21 |
-
FilterNode,
|
| 22 |
-
FilterOp,
|
| 23 |
-
FilterRhs,
|
| 24 |
-
FilterRule,
|
| 25 |
-
FilterTree,
|
| 26 |
-
Measure,
|
| 27 |
-
} from "../customer-grid/types";
|
| 28 |
-
import {
|
| 29 |
-
COHORT_FIELD, MAX_COHORT_IDS, MAX_FILTER_DEPTH, RANK_OPS, STAT_KINDS, STAT_LABELS,
|
| 30 |
-
VALUE_FREE_OPS, choiceOptions, cohortIds, filterTreeConj, filterTreeNodes,
|
| 31 |
-
filterableFields, isFilterGroup, isMeasureRule, isRankOp, normalizeCohortOp, rhsColId,
|
| 32 |
-
} from "../customer-grid/types";
|
| 33 |
-
import type { CohortOp, StatKind } from "../customer-grid/types";
|
| 34 |
-
import type { AnchorMode } from "../customer-grid/windows";
|
| 35 |
-
import { ANCHOR_LABELS, ANCHOR_MODES, MAX_N } from "../customer-grid/windows";
|
| 36 |
-
import { FieldSelectButton } from "../customer-grid/FieldSelect";
|
| 37 |
-
import type { FieldSelectItem } from "../customer-grid/FieldSelect";
|
| 38 |
-
import { WindowPicker } from "./WindowPicker";
|
| 39 |
-
import {
|
| 40 |
-
COHORT_OP_LIST, DEFAULT_DATE_WINDOW, DEFAULT_WINDOW, MEASURE_MARK, MEASURE_OP_LIST,
|
| 41 |
-
MEASURE_PAIR_OP_LIST, WITHIN_KINDS, cohortOpLabel, groupSummary,
|
| 42 |
-
isDateFamily, isNumericType, newCondition, newRuleId, opLabel, opsForType, rankDefault,
|
| 43 |
-
withCurrent, withCurrentField,
|
| 44 |
-
} from "./ops";
|
| 45 |
-
import "../customer-grid/filters.css";
|
| 46 |
-
|
| 47 |
-
/** The choice vocabulary of one field, resolved once and threaded down as a function so no
|
| 48 |
-
* level of the tree has to carry three separate maps to answer one question. */
|
| 49 |
-
type ChoicesFor = (colId: string) => string[];
|
| 50 |
-
|
| 51 |
-
/**
|
| 52 |
-
* Owner item 3 β the DATE value: an anchor mode, then whatever that mode needs.
|
| 53 |
-
*
|
| 54 |
-
* [is before] [one month ago] <- mode only
|
| 55 |
-
* [is before] [N days ago] [30] <- mode + a number
|
| 56 |
-
* [is before] [an exact date] [ π
] <- mode + a date
|
| 57 |
-
*
|
| 58 |
-
* The four value-free modes render NO second control, which is exactly why `isRuleActive` has
|
| 59 |
-
* to know about them: an empty value would otherwise read as a half-typed condition and be
|
| 60 |
-
* ignored, quietly widening the result.
|
| 61 |
-
*/
|
| 62 |
-
function AnchorValue({
|
| 63 |
-
rule,
|
| 64 |
-
onPatch,
|
| 65 |
-
}: {
|
| 66 |
-
rule: FilterRule;
|
| 67 |
-
onPatch: (p: Partial<FilterRule>) => void;
|
| 68 |
-
}) {
|
| 69 |
-
const mode = rule.dateMode ?? "exact";
|
| 70 |
-
return (
|
| 71 |
-
<span className="cg-cond-anchor">
|
| 72 |
-
<select
|
| 73 |
-
className="cg-select cg-cond-anchor-mode"
|
| 74 |
-
value={mode}
|
| 75 |
-
aria-label="Date"
|
| 76 |
-
onChange={(e) => {
|
| 77 |
-
// Clear the value with the mode. A "30" left behind on `an exact date`, or a date
|
| 78 |
-
// left behind on `N days ago`, is state the user can neither see nor clear.
|
| 79 |
-
const next = e.target.value as AnchorMode;
|
| 80 |
-
onPatch({ dateMode: next, value: "" });
|
| 81 |
-
}}
|
| 82 |
-
>
|
| 83 |
-
{ANCHOR_MODES.map((m) => (
|
| 84 |
-
<option key={m} value={m}>
|
| 85 |
-
{m === "n_days_ago" ? ANCHOR_LABELS[m].replace("{n}", "N") : ANCHOR_LABELS[m]}
|
| 86 |
-
</option>
|
| 87 |
-
))}
|
| 88 |
-
</select>
|
| 89 |
-
{mode === "n_days_ago" && (
|
| 90 |
-
<input
|
| 91 |
-
className="cg-input cg-cond-win-n"
|
| 92 |
-
type="number"
|
| 93 |
-
min={0}
|
| 94 |
-
max={MAX_N}
|
| 95 |
-
aria-label="Number of days ago"
|
| 96 |
-
value={rule.value}
|
| 97 |
-
onChange={(e) => onPatch({ value: e.target.value })}
|
| 98 |
-
/>
|
| 99 |
-
)}
|
| 100 |
-
{mode === "exact" && (
|
| 101 |
-
<input
|
| 102 |
-
className="cg-input cg-cond-val"
|
| 103 |
-
type="date"
|
| 104 |
-
aria-label="Date"
|
| 105 |
-
value={rule.value}
|
| 106 |
-
onChange={(e) => onPatch({ value: e.target.value })}
|
| 107 |
-
/>
|
| 108 |
-
)}
|
| 109 |
-
</span>
|
| 110 |
-
);
|
| 111 |
-
}
|
| 112 |
-
|
| 113 |
-
/**
|
| 114 |
-
* CG-9 (owner item 4) β the comparand when it is ANOTHER ATTRIBUTE rather than a number.
|
| 115 |
-
*
|
| 116 |
-
* `Sales [this year] > Sales [year to date, last year]`. For a MEASURE the right side carries
|
| 117 |
-
* its own window, which is the whole point: the two sides differ by period, not by name.
|
| 118 |
-
*/
|
| 119 |
-
function RhsPicker({
|
| 120 |
-
rule,
|
| 121 |
-
options,
|
| 122 |
-
isMeasure,
|
| 123 |
-
onPatch,
|
| 124 |
-
}: {
|
| 125 |
-
rule: FilterRule;
|
| 126 |
-
options: FieldSelectItem[];
|
| 127 |
-
isMeasure: boolean;
|
| 128 |
-
onPatch: (p: Partial<FilterRule>) => void;
|
| 129 |
-
}) {
|
| 130 |
-
const rhs = rule.rhs;
|
| 131 |
-
const colId = rhsColId(rhs) ?? "";
|
| 132 |
-
return (
|
| 133 |
-
<span className="cg-cond-rhs">
|
| 134 |
-
{/* Item 20 β the empty comparand was an `<option value="">` that a select renders as
|
| 135 |
-
its first row; here it is the picker's PLACEHOLDER state, so "not chosen yet" is a
|
| 136 |
-
state of the control rather than an entry in the vocabulary. */}
|
| 137 |
-
<FieldSelectButton
|
| 138 |
-
className="cg-cond-field"
|
| 139 |
-
ariaLabel="Compare with field"
|
| 140 |
-
placeholder="Select a fieldβ¦"
|
| 141 |
-
value={colId || undefined}
|
| 142 |
-
fields={options}
|
| 143 |
-
onChange={(key) => {
|
| 144 |
-
onPatch({
|
| 145 |
-
rhs: isMeasure
|
| 146 |
-
? { kind: "measure", colId: key,
|
| 147 |
-
window: (rhs?.kind === "measure" && rhs.window) || DEFAULT_WINDOW }
|
| 148 |
-
: { kind: "field", colId: key },
|
| 149 |
-
});
|
| 150 |
-
}}
|
| 151 |
-
/>
|
| 152 |
-
{isMeasure && colId !== "" && (
|
| 153 |
-
<WindowPicker
|
| 154 |
-
window={(rhs?.kind === "measure" && rhs.window) || DEFAULT_WINDOW}
|
| 155 |
-
onWindow={(w) => onPatch({ rhs: { kind: "measure", colId, window: w } })}
|
| 156 |
-
/>
|
| 157 |
-
)}
|
| 158 |
-
</span>
|
| 159 |
-
);
|
| 160 |
-
}
|
| 161 |
-
|
| 162 |
-
// --- the value control for one filter condition (type/op aware) ------------
|
| 163 |
-
function ConditionValue({
|
| 164 |
-
rule,
|
| 165 |
-
type,
|
| 166 |
-
statusOptions,
|
| 167 |
-
onValue,
|
| 168 |
-
onValue2,
|
| 169 |
-
}: {
|
| 170 |
-
rule: FilterRule;
|
| 171 |
-
type: Field["type"];
|
| 172 |
-
statusOptions: string[];
|
| 173 |
-
onValue: (v: string) => void;
|
| 174 |
-
onValue2: (v: string) => void;
|
| 175 |
-
}) {
|
| 176 |
-
if (rule.op === "between") {
|
| 177 |
-
const inputType = type === "date" || type === "created_time" ? "date" : "number";
|
| 178 |
-
return (
|
| 179 |
-
<span className="cg-cond-between">
|
| 180 |
-
<input
|
| 181 |
-
className="cg-input cg-cond-val"
|
| 182 |
-
type={inputType}
|
| 183 |
-
value={rule.value}
|
| 184 |
-
onChange={(e) => onValue(e.target.value)}
|
| 185 |
-
/>
|
| 186 |
-
<span className="cg-cond-and">and</span>
|
| 187 |
-
<input
|
| 188 |
-
className="cg-input cg-cond-val"
|
| 189 |
-
type={inputType}
|
| 190 |
-
value={rule.value2 ?? ""}
|
| 191 |
-
onChange={(e) => onValue2(e.target.value)}
|
| 192 |
-
/>
|
| 193 |
-
</span>
|
| 194 |
-
);
|
| 195 |
-
}
|
| 196 |
-
if (
|
| 197 |
-
type === "status" || type === "select" || type === "user" || type === "multiselect"
|
| 198 |
-
) {
|
| 199 |
-
return (
|
| 200 |
-
<select
|
| 201 |
-
className="cg-select cg-cond-val"
|
| 202 |
-
value={rule.value}
|
| 203 |
-
onChange={(e) => onValue(e.target.value)}
|
| 204 |
-
>
|
| 205 |
-
<option value="">Selectβ¦</option>
|
| 206 |
-
{statusOptions.map((v) => (
|
| 207 |
-
<option key={v} value={v}>
|
| 208 |
-
{v}
|
| 209 |
-
</option>
|
| 210 |
-
))}
|
| 211 |
-
</select>
|
| 212 |
-
);
|
| 213 |
-
}
|
| 214 |
-
const inputType =
|
| 215 |
-
type === "date" || type === "created_time"
|
| 216 |
-
? "date"
|
| 217 |
-
: isNumericType(type)
|
| 218 |
-
? "number"
|
| 219 |
-
: "text";
|
| 220 |
-
return (
|
| 221 |
-
<input
|
| 222 |
-
className="cg-input cg-cond-val"
|
| 223 |
-
type={inputType}
|
| 224 |
-
placeholder="Value"
|
| 225 |
-
value={rule.value}
|
| 226 |
-
onChange={(e) => onValue(e.target.value)}
|
| 227 |
-
/>
|
| 228 |
-
);
|
| 229 |
-
}
|
| 230 |
-
|
| 231 |
-
/**
|
| 232 |
-
* C-OPS β the value control for a rank condition.
|
| 233 |
-
*
|
| 234 |
-
* Two shapes, because the two questions are different: a COUNT (or a percentage) is typed and
|
| 235 |
-
* unbounded-ish, while a quantile is a choice from four or ten. Rendering the quantile as a
|
| 236 |
-
* number box would let somebody type `inQuartile 7`, which the engine refuses outright β the
|
| 237 |
-
* control that cannot express the invalid state is the better one.
|
| 238 |
-
*
|
| 239 |
-
* Bounds are on the input as `min`/`max` AND enforced in the engine, which is not redundancy:
|
| 240 |
-
* a number input's bounds are advisory (typing past them is allowed and only surfaces on form
|
| 241 |
-
* validation), so the box is a hint and `rankOps.bound` is the wall. A value outside the range
|
| 242 |
-
* makes the condition UNANSWERABLE β it matches nothing and says so beside the count.
|
| 243 |
-
*/
|
| 244 |
-
function RankValue({
|
| 245 |
-
rule,
|
| 246 |
-
onValue,
|
| 247 |
-
}: {
|
| 248 |
-
rule: FilterRule;
|
| 249 |
-
onValue: (v: string) => void;
|
| 250 |
-
}) {
|
| 251 |
-
const buckets = rule.op === "inQuartile" ? 4 : rule.op === "inDecile" ? 10 : 0;
|
| 252 |
-
if (buckets) {
|
| 253 |
-
return (
|
| 254 |
-
<select
|
| 255 |
-
className="cg-select cg-cond-val"
|
| 256 |
-
value={rule.value}
|
| 257 |
-
aria-label={rule.op === "inQuartile" ? "Quartile" : "Decile"}
|
| 258 |
-
onChange={(e) => onValue(e.target.value)}
|
| 259 |
-
>
|
| 260 |
-
{Array.from({ length: buckets }, (_, i) => String(i + 1)).map((n) => (
|
| 261 |
-
<option key={n} value={n}>
|
| 262 |
-
{/* The ends are the ones people mean, so they say so rather than making the
|
| 263 |
-
reader remember which end 1 is. */}
|
| 264 |
-
{n === "1"
|
| 265 |
-
? `${n} (lowest)`
|
| 266 |
-
: n === String(buckets)
|
| 267 |
-
? `${n} (highest)`
|
| 268 |
-
: n}
|
| 269 |
-
</option>
|
| 270 |
-
))}
|
| 271 |
-
</select>
|
| 272 |
-
);
|
| 273 |
-
}
|
| 274 |
-
const pct = rule.op === "inTopPct" || rule.op === "inBottomPct";
|
| 275 |
-
return (
|
| 276 |
-
<input
|
| 277 |
-
className="cg-input cg-cond-val"
|
| 278 |
-
type="number"
|
| 279 |
-
min={1}
|
| 280 |
-
max={pct ? 100 : 10000}
|
| 281 |
-
step={1}
|
| 282 |
-
placeholder={pct ? "10" : "10"}
|
| 283 |
-
aria-label={pct ? "Percent" : "How many"}
|
| 284 |
-
value={rule.value}
|
| 285 |
-
onChange={(e) => onValue(e.target.value)}
|
| 286 |
-
/>
|
| 287 |
-
);
|
| 288 |
-
}
|
| 289 |
-
|
| 290 |
-
/**
|
| 291 |
-
* The cohorts a `Where [Cohort] [is any of] [β¦]` condition names (owner, 2026-07-27).
|
| 292 |
-
*
|
| 293 |
-
* Chips plus an add-select, not a `<select multiple>`: a native multi-select needs ctrl-click to
|
| 294 |
-
* build a set and shows what is chosen only by highlight, so the condition would stop reading as
|
| 295 |
-
* a sentence β and the one thing this row has to do is say what it filters. The add-select also
|
| 296 |
-
* keeps exactly one `.cg-cond-val` in the row, which is the control the shipped round-trip gate
|
| 297 |
-
* drives; choosing a cohort there APPENDS rather than replaces.
|
| 298 |
-
*
|
| 299 |
-
* The select is rendered even when there is nothing left to add, disabled. A control that
|
| 300 |
-
* vanishes at the cap would reflow the sentence mid-edit, and a gate that located it by class
|
| 301 |
-
* would report a missing control rather than a full set.
|
| 302 |
-
*
|
| 303 |
-
* A cohort the view names but the user no longer has still renders, as `(deleted list)` β the
|
| 304 |
-
* `withCurrent` rule applied to a set. The engine refuses such a condition outright (evalNode
|
| 305 |
-
* returns false for the whole leaf, not for that member), so showing the stale chip is exactly
|
| 306 |
-
* what lets somebody remove it.
|
| 307 |
-
*/
|
| 308 |
-
function CohortPicker({
|
| 309 |
-
rule,
|
| 310 |
-
lists,
|
| 311 |
-
onPatch,
|
| 312 |
-
}: {
|
| 313 |
-
rule: FilterRule;
|
| 314 |
-
lists: { id: string; name: string }[];
|
| 315 |
-
onPatch: (p: Partial<FilterRule>) => void;
|
| 316 |
-
}) {
|
| 317 |
-
const chosen = cohortIds(rule.value);
|
| 318 |
-
const nameById = new Map(lists.map((l) => [l.id, l.name]));
|
| 319 |
-
const remaining = lists.filter((l) => !chosen.includes(l.id));
|
| 320 |
-
const canAdd = remaining.length > 0 && chosen.length < MAX_COHORT_IDS;
|
| 321 |
-
const setIds = (ids: string[]) => onPatch({ value: ids.join(",") });
|
| 322 |
-
return (
|
| 323 |
-
<span className="cg-cond-cohorts">
|
| 324 |
-
{chosen.map((id) => (
|
| 325 |
-
<span key={id} className={"cg-chip" + (nameById.has(id) ? "" : " cg-chip--gone")}>
|
| 326 |
-
<span className="cg-chip-text">{nameById.get(id) ?? "(deleted list)"}</span>
|
| 327 |
-
<button
|
| 328 |
-
type="button"
|
| 329 |
-
className="cg-chip-x"
|
| 330 |
-
aria-label={`Remove ${nameById.get(id) ?? id}`}
|
| 331 |
-
onClick={() => setIds(chosen.filter((c) => c !== id))}
|
| 332 |
-
>
|
| 333 |
-
Γ
|
| 334 |
-
</button>
|
| 335 |
-
</span>
|
| 336 |
-
))}
|
| 337 |
-
<select
|
| 338 |
-
className="cg-select cg-cond-val cg-cond-cohort-add"
|
| 339 |
-
value=""
|
| 340 |
-
aria-label="Cohort"
|
| 341 |
-
disabled={!canAdd}
|
| 342 |
-
onChange={(e) => e.target.value && setIds([...chosen, e.target.value])}
|
| 343 |
-
>
|
| 344 |
-
{/* "nothing left to add" rendered as "nothing left to adβ¦" at 136px β the fourth time a
|
| 345 |
-
closed vocabulary has outgrown its control here. Shortened, not widened: the control
|
| 346 |
-
is sized so a COHORT NAME stays readable, and that is the string that matters. */}
|
| 347 |
-
<option value="">
|
| 348 |
-
{!canAdd ? "all added" : chosen.length ? "Add a cohortβ¦" : "Select a cohortβ¦"}
|
| 349 |
-
</option>
|
| 350 |
-
{remaining.map((l) => (
|
| 351 |
-
<option key={l.id} value={l.id}>
|
| 352 |
-
{l.name}
|
| 353 |
-
</option>
|
| 354 |
-
))}
|
| 355 |
-
</select>
|
| 356 |
-
</span>
|
| 357 |
-
);
|
| 358 |
-
}
|
| 359 |
-
|
| 360 |
-
// --- Filter: an Airtable-style RECURSIVE condition builder -----------------
|
| 361 |
-
// Anatomy per Airtable (captured from the live product 2026-07-25):
|
| 362 |
-
// row 0 : static "Where"
|
| 363 |
-
// row 1 : the and/or DROPDOWN β one conjunction per LEVEL, not per condition
|
| 364 |
-
// row 2..n : static text mirroring that level's conjunction
|
| 365 |
-
// a group : inset panel + summary line ("All/Any of the following are trueβ¦")
|
| 366 |
-
// carrying its OWN conjunction and its own Where/and-or rows
|
| 367 |
-
// Nesting is capped at MAX_FILTER_DEPTH (3) β Airtable grays the group button out.
|
| 368 |
-
|
| 369 |
-
/** The leading cell of a condition row: "Where", the conjunction picker, or the mirror. */
|
| 370 |
-
function RowPrefix({
|
| 371 |
-
index,
|
| 372 |
-
conj,
|
| 373 |
-
onConj,
|
| 374 |
-
}: {
|
| 375 |
-
index: number;
|
| 376 |
-
conj: Conjunction;
|
| 377 |
-
onConj: (c: Conjunction) => void;
|
| 378 |
-
}) {
|
| 379 |
-
if (index === 0) return <span className="cg-cond-prefix">Where</span>;
|
| 380 |
-
if (index === 1)
|
| 381 |
-
return (
|
| 382 |
-
<select
|
| 383 |
-
className="cg-select cg-cond-conj"
|
| 384 |
-
value={conj}
|
| 385 |
-
aria-label="Combine conditions with"
|
| 386 |
-
onChange={(e) => onConj(e.target.value as Conjunction)}
|
| 387 |
-
>
|
| 388 |
-
<option value="and">and</option>
|
| 389 |
-
<option value="or">or</option>
|
| 390 |
-
</select>
|
| 391 |
-
);
|
| 392 |
-
return <span className="cg-cond-prefix cg-cond-conj-echo">{conj}</span>;
|
| 393 |
-
}
|
| 394 |
-
|
| 395 |
-
/** Everything a level of the tree needs that does not change as it recurses. One object rather
|
| 396 |
-
* than eight repeated props: the list grew past the point where each level restating it made
|
| 397 |
-
* the recursion easier to read. */
|
| 398 |
-
interface LevelContext {
|
| 399 |
-
fields: Field[];
|
| 400 |
-
fieldByKey: Map<string, Field>;
|
| 401 |
-
measures: Measure[];
|
| 402 |
-
measureByKey: Map<string, Measure>;
|
| 403 |
-
/** Cohorts this user has β the vocabulary of `Where [Cohort] [is part of] [β¦]`. */
|
| 404 |
-
lists: { id: string; name: string }[];
|
| 405 |
-
choicesFor: ChoicesFor;
|
| 406 |
-
}
|
| 407 |
-
|
| 408 |
-
/**
|
| 409 |
-
* One nesting level: renders its nodes (leaves and nested groups) plus the
|
| 410 |
-
* add-buttons. Recurses through GroupNode for children that are groups.
|
| 411 |
-
*/
|
| 412 |
-
function ConditionLevel({
|
| 413 |
-
nodes,
|
| 414 |
-
conj,
|
| 415 |
-
depth,
|
| 416 |
-
ctx,
|
| 417 |
-
onNodes,
|
| 418 |
-
onConj,
|
| 419 |
-
}: {
|
| 420 |
-
nodes: FilterNode[];
|
| 421 |
-
conj: Conjunction;
|
| 422 |
-
/** 1 = the root level. A new group's depth would be depth + 1. */
|
| 423 |
-
depth: number;
|
| 424 |
-
ctx: LevelContext;
|
| 425 |
-
onNodes: (nodes: FilterNode[]) => void;
|
| 426 |
-
onConj: (c: Conjunction) => void;
|
| 427 |
-
}) {
|
| 428 |
-
/** Item 13 / R9 β which add-link was last used at THIS level. Per level, because the state
|
| 429 |
-
* belongs to the row of buttons the user clicked, not to the builder as a whole. */
|
| 430 |
-
const [lastAdd, setLastAdd] = useState<"condition" | "group" | null>(null);
|
| 431 |
-
const setNode = (i: number, next: FilterNode) =>
|
| 432 |
-
onNodes(nodes.map((n, j) => (j === i ? next : n)));
|
| 433 |
-
const removeNode = (i: number) => onNodes(nodes.filter((_, j) => j !== i));
|
| 434 |
-
|
| 435 |
-
const addCondition = () => {
|
| 436 |
-
const c = newCondition(ctx.fields);
|
| 437 |
-
if (c) onNodes([...nodes, c]);
|
| 438 |
-
};
|
| 439 |
-
// New groups default to "or" β matching Airtable, and the useful case (the
|
| 440 |
-
// root ANDs a set of alternatives together).
|
| 441 |
-
const addGroup = () => {
|
| 442 |
-
const c = newCondition(ctx.fields);
|
| 443 |
-
if (c) onNodes([...nodes, { conj: "or", children: [c] }]);
|
| 444 |
-
};
|
| 445 |
-
const canNest = depth < MAX_FILTER_DEPTH;
|
| 446 |
-
|
| 447 |
-
return (
|
| 448 |
-
<>
|
| 449 |
-
{nodes.map((node, i) => (
|
| 450 |
-
<div className="cg-cond-line" key={i}>
|
| 451 |
-
<RowPrefix index={i} conj={conj} onConj={onConj} />
|
| 452 |
-
{isFilterGroup(node) ? (
|
| 453 |
-
<GroupNode
|
| 454 |
-
group={node}
|
| 455 |
-
depth={depth + 1}
|
| 456 |
-
ctx={ctx}
|
| 457 |
-
onGroup={(g) => setNode(i, g)}
|
| 458 |
-
onRemove={() => removeNode(i)}
|
| 459 |
-
/>
|
| 460 |
-
) : (
|
| 461 |
-
<LeafRow
|
| 462 |
-
rule={node}
|
| 463 |
-
ctx={ctx}
|
| 464 |
-
onRule={(r) => setNode(i, r)}
|
| 465 |
-
onRemove={() => removeNode(i)}
|
| 466 |
-
/>
|
| 467 |
-
)}
|
| 468 |
-
</div>
|
| 469 |
-
))}
|
| 470 |
-
{/* Item 13 (owner, 2026-08-02) β the two add-links are BOLD, and the one you just used
|
| 471 |
-
holds a dark-blue state.
|
| 472 |
-
β R9 says that state lasts while the popover is open and a fresh open starts
|
| 473 |
-
neutral β which is exactly what a `useState` HERE gives for free, because `Popover`
|
| 474 |
-
renders its children only while open (`{open && <AnchoredOverlayβ¦>}`) and unmounts
|
| 475 |
-
them on close. Nothing persists it, nothing has to clear it, and there is no way for
|
| 476 |
-
it to survive a reopen. A `:active` pseudo-class would have been the wrong tool
|
| 477 |
-
twice over: it lasts for the duration of the mouse-down, and it says "you are
|
| 478 |
-
pressing this" rather than "this is the one you have been using". */}
|
| 479 |
-
<div className="cg-builder-add">
|
| 480 |
-
<button
|
| 481 |
-
type="button"
|
| 482 |
-
className={"cg-link-btn" + (lastAdd === "condition" ? " is-on" : "")}
|
| 483 |
-
onClick={() => {
|
| 484 |
-
setLastAdd("condition");
|
| 485 |
-
addCondition();
|
| 486 |
-
}}
|
| 487 |
-
>
|
| 488 |
-
+ Add condition
|
| 489 |
-
</button>
|
| 490 |
-
<button
|
| 491 |
-
type="button"
|
| 492 |
-
className={"cg-link-btn" + (lastAdd === "group" ? " is-on" : "")}
|
| 493 |
-
onClick={() => {
|
| 494 |
-
setLastAdd("group");
|
| 495 |
-
addGroup();
|
| 496 |
-
}}
|
| 497 |
-
disabled={!canNest}
|
| 498 |
-
title={
|
| 499 |
-
canNest
|
| 500 |
-
? "Group conditions so they evaluate together"
|
| 501 |
-
: `Condition groups can nest ${MAX_FILTER_DEPTH} levels deep`
|
| 502 |
-
}
|
| 503 |
-
>
|
| 504 |
-
+ Add condition group
|
| 505 |
-
</button>
|
| 506 |
-
</div>
|
| 507 |
-
</>
|
| 508 |
-
);
|
| 509 |
-
}
|
| 510 |
-
|
| 511 |
-
/**
|
| 512 |
-
* One leaf condition: field βΎ | (window βΎ) | operator βΎ | value | remove.
|
| 513 |
-
*
|
| 514 |
-
* The window bracket appears only for a MEASURE (CG-8) β a column already has its window baked
|
| 515 |
-
* in ("YTD $" IS year-to-date), and offering one there would imply it could be changed.
|
| 516 |
-
*/
|
| 517 |
-
function LeafRow({
|
| 518 |
-
rule,
|
| 519 |
-
ctx,
|
| 520 |
-
onRule,
|
| 521 |
-
onRemove,
|
| 522 |
-
}: {
|
| 523 |
-
rule: FilterRule;
|
| 524 |
-
ctx: LevelContext;
|
| 525 |
-
onRule: (r: FilterRule) => void;
|
| 526 |
-
onRemove: () => void;
|
| 527 |
-
}) {
|
| 528 |
-
const { fields, fieldByKey, measures, measureByKey, lists, choicesFor } = ctx;
|
| 529 |
-
const isMeasure = isMeasureRule(rule);
|
| 530 |
-
const isCohort = rule.colId === COHORT_FIELD;
|
| 531 |
-
const hasCohorts = lists.length > 0;
|
| 532 |
-
const t = isMeasure
|
| 533 |
-
? measureByKey.get(rule.colId)?.type ?? "currency"
|
| 534 |
-
: fieldByKey.get(rule.colId)?.type ?? "text";
|
| 535 |
-
const patch = (p: Partial<FilterRule>) => onRule({ ...rule, ...p });
|
| 536 |
-
// Switching field resets op to that type's default + clears the value(s), so a
|
| 537 |
-
// numeric op can never linger on a text column. Crossing the column/measure line also
|
| 538 |
-
// adds or removes the WINDOW and the rule ID, since those are what MAKE it a measure β
|
| 539 |
-
// leaving a window behind on a column condition would keep the server answering it. The
|
| 540 |
-
// rhs goes too: a comparand chosen for the old field is meaningless against the new one.
|
| 541 |
-
const onField = (key: string) => {
|
| 542 |
-
if (key === COHORT_FIELD) {
|
| 543 |
-
onRule({ colId: COHORT_FIELD, op: COHORT_OP_LIST[0], value: "" });
|
| 544 |
-
return;
|
| 545 |
-
}
|
| 546 |
-
const m = measureByKey.get(key);
|
| 547 |
-
if (m) {
|
| 548 |
-
onRule({
|
| 549 |
-
id: rule.id ?? newRuleId(),
|
| 550 |
-
colId: key,
|
| 551 |
-
op: MEASURE_OP_LIST[0],
|
| 552 |
-
value: "",
|
| 553 |
-
value2: undefined,
|
| 554 |
-
window: rule.window ?? DEFAULT_WINDOW,
|
| 555 |
-
});
|
| 556 |
-
return;
|
| 557 |
-
}
|
| 558 |
-
const nt = fieldByKey.get(key)?.type ?? "text";
|
| 559 |
-
onRule({ colId: key, op: opsForType(nt)[0], value: "", value2: undefined });
|
| 560 |
-
};
|
| 561 |
-
// Switching to/from a value-free op clears the stale value it can't show. `within` swaps the
|
| 562 |
-
// value for a RANGE and drops any field comparand, since a range has no other side.
|
| 563 |
-
const onOp = (op: FilterOp | CohortOp) => {
|
| 564 |
-
// A cohort keeps its SET when the set operator changes β "any of these three" and "none of
|
| 565 |
-
// these three" are the same three cohorts asked about differently, and clearing them would
|
| 566 |
-
// make the obvious edit destructive.
|
| 567 |
-
if (isCohort) return patch({ op });
|
| 568 |
-
// C-OPS: a rank op arrives with its N already chosen (see rankDefault) and drops every
|
| 569 |
-
// comparand β "the top 10 compared with another field" is not a question.
|
| 570 |
-
if (isRankOp(op as string))
|
| 571 |
-
return onRule({ ...rule, op: op as FilterOp, value: rankDefault(op as FilterOp),
|
| 572 |
-
value2: undefined, rhs: undefined, dateWindow: undefined });
|
| 573 |
-
if (VALUE_FREE_OPS.has(op as FilterOp))
|
| 574 |
-
return onRule({ ...rule, op, value: "", value2: undefined, rhs: undefined });
|
| 575 |
-
if (op === "within")
|
| 576 |
-
return onRule({ ...rule, op, value: "", value2: undefined, rhs: undefined,
|
| 577 |
-
dateWindow: rule.dateWindow ?? DEFAULT_DATE_WINDOW });
|
| 578 |
-
if (rule.op === "within")
|
| 579 |
-
return onRule({ ...rule, op, dateWindow: undefined, value: "" });
|
| 580 |
-
patch({ op });
|
| 581 |
-
};
|
| 582 |
-
// Owner item 4: the value box is REPLACEABLE by another attribute. Offered where a
|
| 583 |
-
// comparison between two of the same kind of thing means something β numbers against
|
| 584 |
-
// numbers, dates against dates, a measure against a measure over its own period.
|
| 585 |
-
const rhsOptions: FieldSelectItem[] = isMeasure
|
| 586 |
-
? measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK }))
|
| 587 |
-
: fields
|
| 588 |
-
.filter((f) => f.key !== rule.colId
|
| 589 |
-
&& (isNumericType(t) ? isNumericType(f.type) : f.type === t))
|
| 590 |
-
.map((f) => ({ key: f.key, label: f.label, type: f.type }));
|
| 591 |
-
const canCompareField = !isCohort && rule.op !== "within"
|
| 592 |
-
&& !VALUE_FREE_OPS.has(rule.op as FilterOp) && !isRankOp(rule.op)
|
| 593 |
-
&& rhsOptions.length > 0 && (isMeasure || isNumericType(t) || isDateFamily(t));
|
| 594 |
-
const usingStat = rule.rhs?.kind === "stat";
|
| 595 |
-
const usingField = rule.rhs != null && !usingStat;
|
| 596 |
-
// Explicitly widened: the cohort branch contributes a DISJOINT vocabulary, and without the
|
| 597 |
-
// annotation `withCurrent`'s `T extends string` infers from the first branch alone.
|
| 598 |
-
const opList: (FilterOp | CohortOp)[] = isMeasure
|
| 599 |
-
? (usingField || usingStat ? MEASURE_PAIR_OP_LIST : MEASURE_OP_LIST)
|
| 600 |
-
: isCohort ? COHORT_OP_LIST : opsForType(t);
|
| 601 |
-
const opValue: FilterOp | CohortOp = isCohort ? normalizeCohortOp(rule.op) : rule.op;
|
| 602 |
-
// C-OPS β the RANK family, offered under its own divider for the numeric family only.
|
| 603 |
-
//
|
| 604 |
-
// A COLUMN only: a measure leaf is answered by a server-side pid set over a window, so
|
| 605 |
-
// ranking one client-side would be a second answer to the same question (and the host's
|
| 606 |
-
// MEASURE_OPS deliberately does not carry these). A cohort leaf is set membership.
|
| 607 |
-
//
|
| 608 |
-
// Offered separately rather than appended to `opsForType` because these operators are a
|
| 609 |
-
// different KIND of question β every other one is about this row's value, these are about
|
| 610 |
-
// where the row sits among the others β and a divider is the cheapest way to say so.
|
| 611 |
-
const canRank = !isMeasure && !isCohort && isNumericType(t);
|
| 612 |
-
// `withCurrent` runs over the COMBINED offer so a rule already holding a rank op is not
|
| 613 |
-
// appended a second time; the split below then puts each op under the right heading. A
|
| 614 |
-
// rank op on a field that can no longer be ranked still renders β as itself, in the group,
|
| 615 |
-
// which is what lets somebody see it and change it.
|
| 616 |
-
const offeredOps = withCurrent(canRank ? [...opList, ...RANK_OPS] : opList, opValue);
|
| 617 |
-
const plainOps = offeredOps.filter((op) => !isRankOp(op));
|
| 618 |
-
const rankedOps = offeredOps.filter((op) => isRankOp(op));
|
| 619 |
-
|
| 620 |
-
return (
|
| 621 |
-
<span className={"cg-cond-row" + (isMeasure ? " is-measure" : "")
|
| 622 |
-
+ (isCohort ? " is-cohort" : "")
|
| 623 |
-
+ (rule.rhs != null ? " has-rhs" : "")}>
|
| 624 |
-
{/* ONE flat list (owner item 1). A measure used to sit under a "Measures (pick a
|
| 625 |
-
period)" optgroup; the owner's instruction was that it is all the same list β
|
| 626 |
-
you pick a thing to filter on, and a measure simply also asks for a period. Item 20
|
| 627 |
-
adds the type MARK to each row and takes NO grouping with it: the marks are what the
|
| 628 |
-
optgroup was reaching for, and they say it per row instead of splitting the list. */}
|
| 629 |
-
<FieldSelectButton
|
| 630 |
-
className="cg-cond-field"
|
| 631 |
-
ariaLabel="Field"
|
| 632 |
-
value={rule.colId}
|
| 633 |
-
onChange={onField}
|
| 634 |
-
fields={[
|
| 635 |
-
...withCurrentField(fields, rule.colId, fieldByKey, measureByKey, hasCohorts),
|
| 636 |
-
...measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK })),
|
| 637 |
-
// A cohort leaf asks about membership of a SET, which is the multiselect mark's
|
| 638 |
-
// own meaning β the vocabulary already had the right glyph for it.
|
| 639 |
-
...(hasCohorts
|
| 640 |
-
? [{ key: COHORT_FIELD, label: "Cohort", type: "multiselect" as const }]
|
| 641 |
-
: []),
|
| 642 |
-
]}
|
| 643 |
-
/>
|
| 644 |
-
{isMeasure && (
|
| 645 |
-
<WindowPicker
|
| 646 |
-
window={rule.window ?? DEFAULT_WINDOW}
|
| 647 |
-
onWindow={(w) => patch({ window: w })}
|
| 648 |
-
/>
|
| 649 |
-
)}
|
| 650 |
-
<select
|
| 651 |
-
className="cg-select cg-cond-op"
|
| 652 |
-
// A view saved before 2026-07-27 holds `eq`/`neq`. Normalising HERE, rather than letting
|
| 653 |
-
// `withCurrent` append it, is the difference between a legacy rule reading "is any of"
|
| 654 |
-
// once and the list carrying two options with the same label and different values. The
|
| 655 |
-
// display stays truthful either way β `eq` IS `anyOf` β and the first edit writes the
|
| 656 |
-
// new op, matching what the validator already does on the server.
|
| 657 |
-
value={opValue}
|
| 658 |
-
aria-label="Condition"
|
| 659 |
-
onChange={(e) => onOp(e.target.value as FilterOp | CohortOp)}
|
| 660 |
-
>
|
| 661 |
-
{plainOps.map((op) => (
|
| 662 |
-
<option key={op} value={op}>
|
| 663 |
-
{/* `op as FilterOp` is safe by construction, not by hope: `opList` is the cohort
|
| 664 |
-
vocabulary EXACTLY when `isCohort`, so the other branch only ever sees the
|
| 665 |
-
column/measure one. The two lists are disjoint, so a wrong cast would show as a
|
| 666 |
-
raw key ("anyOf") on screen rather than as a plausible label. */}
|
| 667 |
-
{isCohort ? cohortOpLabel(op) : opLabel(op as FilterOp, t)}
|
| 668 |
-
</option>
|
| 669 |
-
))}
|
| 670 |
-
{rankedOps.length > 0 && (
|
| 671 |
-
<optgroup label="Ranked">
|
| 672 |
-
{rankedOps.map((op) => (
|
| 673 |
-
<option key={op} value={op}>
|
| 674 |
-
{opLabel(op as FilterOp, t)}
|
| 675 |
-
</option>
|
| 676 |
-
))}
|
| 677 |
-
</optgroup>
|
| 678 |
-
)}
|
| 679 |
-
</select>
|
| 680 |
-
{canCompareField && (
|
| 681 |
-
// The switch between "a value I type" and "another attribute". Two options rather than
|
| 682 |
-
// a checkbox, because the row already reads as a sentence and this is the noun slot.
|
| 683 |
-
<select
|
| 684 |
-
className="cg-select cg-cond-mode"
|
| 685 |
-
value={usingStat ? "stat" : usingField ? "field" : "value"}
|
| 686 |
-
aria-label="Compare against"
|
| 687 |
-
onChange={(e) => {
|
| 688 |
-
const mode = e.target.value;
|
| 689 |
-
if (mode === "stat")
|
| 690 |
-
return patch({ rhs: { kind: "stat", stat: "median" },
|
| 691 |
-
value: "", value2: undefined,
|
| 692 |
-
// a statistic is a computed cut-off: the same four ops a
|
| 693 |
-
// measure-vs-measure allows, for the same float reason
|
| 694 |
-
// reachable only via `canCompareField`, which excludes a cohort
|
| 695 |
-
op: MEASURE_PAIR_OP_LIST.includes(rule.op as FilterOp)
|
| 696 |
-
? rule.op : MEASURE_PAIR_OP_LIST[1] });
|
| 697 |
-
if (mode === "field") {
|
| 698 |
-
// An EMPTY comparand: incomplete, so the rule stays inactive until a field is
|
| 699 |
-
// picked. RhsPicker adds the window when the measure side gets one.
|
| 700 |
-
const empty: FilterRhs = isMeasure
|
| 701 |
-
? { kind: "measure", colId: "", window: DEFAULT_WINDOW }
|
| 702 |
-
: { kind: "field", colId: "" };
|
| 703 |
-
return patch({ rhs: empty, value: "", value2: undefined });
|
| 704 |
-
}
|
| 705 |
-
patch({ rhs: undefined, value: "" });
|
| 706 |
-
}}
|
| 707 |
-
>
|
| 708 |
-
<option value="value">a value</option>
|
| 709 |
-
<option value="field">another field</option>
|
| 710 |
-
{/* Statistics are computed SERVER-SIDE over the caller's pool, so they are offered
|
| 711 |
-
for a MEASURE only. A column statistic would need a second percentile
|
| 712 |
-
implementation on the client and the lock-step gate that goes with it. */}
|
| 713 |
-
{isMeasure && <option value="stat">a statistic</option>}
|
| 714 |
-
</select>
|
| 715 |
-
)}
|
| 716 |
-
{/* value-free ops render NO value control (Airtable does the same) */}
|
| 717 |
-
{VALUE_FREE_OPS.has(rule.op as FilterOp) ? (
|
| 718 |
-
<span className="cg-cond-val cg-cond-noval" aria-hidden />
|
| 719 |
-
) : usingStat ? (
|
| 720 |
-
<select
|
| 721 |
-
className="cg-select cg-cond-val cg-cond-stat"
|
| 722 |
-
value={rule.rhs?.kind === "stat" ? rule.rhs.stat : "median"}
|
| 723 |
-
aria-label="Statistic"
|
| 724 |
-
onChange={(e) => patch({ rhs: { kind: "stat", stat: e.target.value as StatKind } })}
|
| 725 |
-
>
|
| 726 |
-
{STAT_KINDS.map((k) => (
|
| 727 |
-
<option key={k} value={k}>
|
| 728 |
-
{STAT_LABELS[k]}
|
| 729 |
-
</option>
|
| 730 |
-
))}
|
| 731 |
-
</select>
|
| 732 |
-
) : usingField ? (
|
| 733 |
-
<RhsPicker rule={rule} options={rhsOptions} isMeasure={isMeasure} onPatch={patch} />
|
| 734 |
-
) : isCohort ? (
|
| 735 |
-
<CohortPicker rule={rule} lists={lists} onPatch={patch} />
|
| 736 |
-
) : isRankOp(rule.op) ? (
|
| 737 |
-
<RankValue rule={rule} onValue={(v) => patch({ value: v })} />
|
| 738 |
-
) : rule.op === "within" ? (
|
| 739 |
-
<WindowPicker
|
| 740 |
-
window={rule.dateWindow ?? DEFAULT_DATE_WINDOW}
|
| 741 |
-
onWindow={(w) => patch({ dateWindow: w })}
|
| 742 |
-
kinds={WITHIN_KINDS}
|
| 743 |
-
/>
|
| 744 |
-
) : isDateFamily(t) ? (
|
| 745 |
-
<AnchorValue rule={rule} onPatch={patch} />
|
| 746 |
-
) : (
|
| 747 |
-
<ConditionValue
|
| 748 |
-
rule={rule}
|
| 749 |
-
type={t}
|
| 750 |
-
statusOptions={choicesFor(rule.colId)}
|
| 751 |
-
onValue={(v) => patch({ value: v })}
|
| 752 |
-
onValue2={(v) => patch({ value2: v })}
|
| 753 |
-
/>
|
| 754 |
-
)}
|
| 755 |
-
<button
|
| 756 |
-
type="button"
|
| 757 |
-
className="cg-cond-x"
|
| 758 |
-
aria-label="Remove condition"
|
| 759 |
-
onClick={onRemove}
|
| 760 |
-
>
|
| 761 |
-
Γ
|
| 762 |
-
</button>
|
| 763 |
-
</span>
|
| 764 |
-
);
|
| 765 |
-
}
|
| 766 |
-
|
| 767 |
-
/** A nested condition group: summary line + its own recursive level. */
|
| 768 |
-
function GroupNode({
|
| 769 |
-
group,
|
| 770 |
-
depth,
|
| 771 |
-
ctx,
|
| 772 |
-
onGroup,
|
| 773 |
-
onRemove,
|
| 774 |
-
}: {
|
| 775 |
-
group: FilterGroup;
|
| 776 |
-
depth: number;
|
| 777 |
-
ctx: LevelContext;
|
| 778 |
-
onGroup: (g: FilterGroup) => void;
|
| 779 |
-
onRemove: () => void;
|
| 780 |
-
}) {
|
| 781 |
-
return (
|
| 782 |
-
<span className="cg-cond-group" data-depth={depth}>
|
| 783 |
-
<span className="cg-cond-group-head">
|
| 784 |
-
<span className="cg-cond-group-sum">
|
| 785 |
-
{group.children.length
|
| 786 |
-
? groupSummary(group.conj)
|
| 787 |
-
: "Add a condition to this groupβ¦"}
|
| 788 |
-
</span>
|
| 789 |
-
<button
|
| 790 |
-
type="button"
|
| 791 |
-
className="cg-cond-x"
|
| 792 |
-
aria-label="Remove condition group"
|
| 793 |
-
onClick={onRemove}
|
| 794 |
-
>
|
| 795 |
-
Γ
|
| 796 |
-
</button>
|
| 797 |
-
</span>
|
| 798 |
-
<span className="cg-cond-group-body">
|
| 799 |
-
<ConditionLevel
|
| 800 |
-
nodes={group.children}
|
| 801 |
-
conj={group.conj}
|
| 802 |
-
depth={depth}
|
| 803 |
-
ctx={ctx}
|
| 804 |
-
onNodes={(children) => onGroup({ ...group, children })}
|
| 805 |
-
onConj={(conj) => onGroup({ ...group, conj })}
|
| 806 |
-
/>
|
| 807 |
-
</span>
|
| 808 |
-
</span>
|
| 809 |
-
);
|
| 810 |
-
}
|
| 811 |
-
|
| 812 |
-
export interface FilterBuilderPanelProps {
|
| 813 |
-
/**
|
| 814 |
-
* The module's COMPLETE field list. The panel offers the FILTERABLE ones
|
| 815 |
-
* (`filterableFields`) and resolves labels from all of them β a condition saved against a
|
| 816 |
-
* column that has since stopped being filterable must still render its own name rather than
|
| 817 |
-
* turning into "?", so narrowing the picker may never orphan history.
|
| 818 |
-
*/
|
| 819 |
-
fields: Field[];
|
| 820 |
-
/** The whole filter as ONE value β nodes plus the root conjunction. See types.FilterTree. */
|
| 821 |
-
filters: FilterTree;
|
| 822 |
-
onChange: (next: FilterTree) => void;
|
| 823 |
-
/** Cohorts the subject of this filter has, for `Where [Cohort] [is any of] [β¦]`. */
|
| 824 |
-
cohorts?: { id: string; name: string }[];
|
| 825 |
-
/** CG-8 β measures the builder may offer alongside the columns. Absent = columns only. */
|
| 826 |
-
measures?: Measure[];
|
| 827 |
-
/** The people a `user` field may name. Never invented here β the host's real user list. */
|
| 828 |
-
userOptions?: string[];
|
| 829 |
-
/**
|
| 830 |
-
* The tenant's today, ISO. RESERVED: it is the anchor a relative window resolves against,
|
| 831 |
-
* and it is accepted so a caller that holds it (the grid, the admin editor) has one obvious
|
| 832 |
-
* place to put it. It renders NOTHING today β a "this resolves to <range>" hint under the
|
| 833 |
-
* date controls was considered for wave 15 and rejected as a visual delta C-KIT forbids.
|
| 834 |
-
* Kept in the signature rather than added later so no call site has to change to gain it.
|
| 835 |
-
*/
|
| 836 |
-
today?: string;
|
| 837 |
-
/**
|
| 838 |
-
* The per-field CHOICE VOCABULARY, by field key. The one prop that is not cosmetic when it
|
| 839 |
-
* is absent: some columns take their values from the DATA (the grid discovers them from its
|
| 840 |
-
* rows β `customer-grid/types.ts::choiceVocabulary`), which a panel over a module whose rows
|
| 841 |
-
* it has never loaded cannot do. Absent, choices fall back to the field's own declared
|
| 842 |
-
* `options` and, for `user`, to `userOptions`.
|
| 843 |
-
*
|
| 844 |
-
* β **Which columns those are is NOT decided by the type, and the old wording here saying
|
| 845 |
-
* "only Odoo `status` columns come up empty" was wrong in a way that shipped a live defect**
|
| 846 |
-
* (owner item 24, 2026-08-06). `stock_bucket` on the Product grid is declared `select` and
|
| 847 |
-
* declares no `options` β its vocabulary is computed server-side and rides the rows, exactly
|
| 848 |
-
* like a `status` column's. **Any column with no declared `options` needs this prop**, whatever
|
| 849 |
-
* its type says. A host that cannot enumerate one renders an empty picker; see `choicesFor`.
|
| 850 |
-
*/
|
| 851 |
-
statusValues?: Record<string, string[]>;
|
| 852 |
-
/**
|
| 853 |
-
* Item 12 (C-LOCK) β this view is LOCKED to a cohort. Present = say so, because the lock
|
| 854 |
-
* narrows the list and is not one of the conditions shown here: without the banner the count
|
| 855 |
-
* and the conditions disagree with no visible reason.
|
| 856 |
-
*
|
| 857 |
-
* `name`/`count` ABSENT means the reader cannot see the set β a shared view locked to
|
| 858 |
-
* somebody else's cohort. The engine matches nothing in that case (fail-closed, the
|
| 859 |
-
* cohort-leaf law), so the banner has to say which kind of nothing this is.
|
| 860 |
-
*/
|
| 861 |
-
cohortLock?: { name?: string; count?: number };
|
| 862 |
-
/**
|
| 863 |
-
* Extra footer content, rendered beside "Clear all" β the grid puts its "Copy from another
|
| 864 |
-
* view" door here. A SLOT rather than props, exactly like the toolbar's `cohortAction`: the
|
| 865 |
-
* door needs the other views and the view-patch plumbing, none of which a panel that also
|
| 866 |
-
* serves an admin editor should know about.
|
| 867 |
-
*/
|
| 868 |
-
footerExtra?: ReactNode;
|
| 869 |
-
}
|
| 870 |
-
|
| 871 |
-
export function FilterBuilderPanel({
|
| 872 |
-
fields,
|
| 873 |
-
filters,
|
| 874 |
-
onChange,
|
| 875 |
-
cohorts = [],
|
| 876 |
-
measures = [],
|
| 877 |
-
userOptions,
|
| 878 |
-
statusValues,
|
| 879 |
-
cohortLock,
|
| 880 |
-
footerExtra,
|
| 881 |
-
}: FilterBuilderPanelProps) {
|
| 882 |
-
const nodes = filterTreeNodes(filters);
|
| 883 |
-
const conj = filterTreeConj(filters);
|
| 884 |
-
// The COMPLETE map on purpose (see `fields` above): the picker is narrowed, the label
|
| 885 |
-
// resolution is not.
|
| 886 |
-
const fieldByKey = useMemo(() => {
|
| 887 |
-
const m = new Map<string, Field>();
|
| 888 |
-
for (const f of fields) m.set(f.key, f);
|
| 889 |
-
return m;
|
| 890 |
-
}, [fields]);
|
| 891 |
-
const condFields = useMemo(() => filterableFields(fields), [fields]);
|
| 892 |
-
const measureByKey = useMemo(() => {
|
| 893 |
-
const m = new Map<string, Measure>();
|
| 894 |
-
for (const item of measures) m.set(item.key, item);
|
| 895 |
-
return m;
|
| 896 |
-
}, [measures]);
|
| 897 |
-
const choicesFor = useCallback<ChoicesFor>(
|
| 898 |
-
(colId) => {
|
| 899 |
-
// A supplied map WINS, including when it holds an empty list: "this status column has no
|
| 900 |
-
// values in the data" is an answer, not a missing one.
|
| 901 |
-
const declared = statusValues?.[colId];
|
| 902 |
-
if (declared) return declared;
|
| 903 |
-
const f = fieldByKey.get(colId);
|
| 904 |
-
if (!f) return [];
|
| 905 |
-
return f.type === "user" ? userOptions ?? [] : choiceOptions(f);
|
| 906 |
-
},
|
| 907 |
-
[statusValues, fieldByKey, userOptions]
|
| 908 |
-
);
|
| 909 |
-
const ctx = useMemo<LevelContext>(
|
| 910 |
-
() => ({ fields: condFields, fieldByKey, measures, measureByKey, lists: cohorts,
|
| 911 |
-
choicesFor }),
|
| 912 |
-
[condFields, fieldByKey, measures, measureByKey, cohorts, choicesFor]
|
| 913 |
-
);
|
| 914 |
-
|
| 915 |
-
return (
|
| 916 |
-
<div className="cg-pop-body cg-builder">
|
| 917 |
-
<div className="cg-pop-title">Filter</div>
|
| 918 |
-
{/* C-LOCK (item 12) β the lock is NOT a condition row: it cannot be written here and
|
| 919 |
-
cannot be removed here (the view menu owns it). So it has to be SAID here, above
|
| 920 |
-
the conditions it bounds β otherwise the count and the conditions disagree and the
|
| 921 |
-
builder shows nothing that explains the gap. A reader who cannot see the set gets a
|
| 922 |
-
different sentence, because the engine shows them nobody and "no records" alone does
|
| 923 |
-
not distinguish a permission wall from a filter that genuinely matches nobody. */}
|
| 924 |
-
{cohortLock && (
|
| 925 |
-
<div className="cg-flt-lockchip">
|
| 926 |
-
{cohortLock.name
|
| 927 |
-
? `Customer list locked to ${cohortLock.name}` +
|
| 928 |
-
(typeof cohortLock.count === "number"
|
| 929 |
-
? ` Β· ${cohortLock.count.toLocaleString()} ${
|
| 930 |
-
cohortLock.count === 1 ? "customer" : "customers"
|
| 931 |
-
}`
|
| 932 |
-
: "")
|
| 933 |
-
: "Customer list locked to a set you cannot see"}
|
| 934 |
-
</div>
|
| 935 |
-
)}
|
| 936 |
-
{nodes.length === 0 ? (
|
| 937 |
-
<div className="cg-builder-empty">
|
| 938 |
-
No conditions. Add one to narrow the list.
|
| 939 |
-
</div>
|
| 940 |
-
) : (
|
| 941 |
-
<div className="cg-builder-caption">In this view, show records</div>
|
| 942 |
-
)}
|
| 943 |
-
<ConditionLevel
|
| 944 |
-
nodes={nodes}
|
| 945 |
-
conj={conj}
|
| 946 |
-
depth={1}
|
| 947 |
-
ctx={ctx}
|
| 948 |
-
onNodes={(next) => onChange({ conj, nodes: next })}
|
| 949 |
-
onConj={(next) => onChange({ conj: next, nodes })}
|
| 950 |
-
/>
|
| 951 |
-
{/*
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
| 966 |
-
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
| 972 |
-
|
| 973 |
-
|
| 974 |
-
|
| 975 |
-
|
| 976 |
-
|
| 977 |
-
|
| 978 |
-
|
| 979 |
-
|
| 980 |
-
|
| 981 |
-
|
| 982 |
-
|
| 983 |
-
|
| 984 |
-
|
| 985 |
-
|
| 986 |
-
|
| 987 |
-
|
| 988 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// filter-kit / FilterBuilderPanel.tsx
|
| 3 |
+
// The Airtable-style RECURSIVE condition builder, as a MOUNTABLE PANEL.
|
| 4 |
+
//
|
| 5 |
+
// Extracted from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so the admin
|
| 6 |
+
// permission editor and the grid toolbar build filters with the SAME control
|
| 7 |
+
// rather than two that drift. Behaviour is identical to the toolbar's β this is a
|
| 8 |
+
// move, not a rewrite.
|
| 9 |
+
//
|
| 10 |
+
// THE KIT FETCHES NOTHING. Every vocabulary it needs (fields, cohorts, measures,
|
| 11 |
+
// the people a `user` field may name) arrives as a prop, which is what lets the
|
| 12 |
+
// same panel serve a module whose rows this browser has never loaded.
|
| 13 |
+
// ---------------------------------------------------------------------------
|
| 14 |
+
|
| 15 |
+
import { useCallback, useMemo, useState } from "react";
|
| 16 |
+
import type { ReactNode } from "react";
|
| 17 |
+
import type {
|
| 18 |
+
Conjunction,
|
| 19 |
+
Field,
|
| 20 |
+
FilterGroup,
|
| 21 |
+
FilterNode,
|
| 22 |
+
FilterOp,
|
| 23 |
+
FilterRhs,
|
| 24 |
+
FilterRule,
|
| 25 |
+
FilterTree,
|
| 26 |
+
Measure,
|
| 27 |
+
} from "../customer-grid/types";
|
| 28 |
+
import {
|
| 29 |
+
COHORT_FIELD, MAX_COHORT_IDS, MAX_FILTER_DEPTH, RANK_OPS, STAT_KINDS, STAT_LABELS,
|
| 30 |
+
VALUE_FREE_OPS, choiceOptions, cohortIds, filterTreeConj, filterTreeNodes,
|
| 31 |
+
filterableFields, isFilterGroup, isMeasureRule, isRankOp, normalizeCohortOp, rhsColId,
|
| 32 |
+
} from "../customer-grid/types";
|
| 33 |
+
import type { CohortOp, StatKind } from "../customer-grid/types";
|
| 34 |
+
import type { AnchorMode } from "../customer-grid/windows";
|
| 35 |
+
import { ANCHOR_LABELS, ANCHOR_MODES, MAX_N } from "../customer-grid/windows";
|
| 36 |
+
import { FieldSelectButton } from "../customer-grid/FieldSelect";
|
| 37 |
+
import type { FieldSelectItem } from "../customer-grid/FieldSelect";
|
| 38 |
+
import { WindowPicker } from "./WindowPicker";
|
| 39 |
+
import {
|
| 40 |
+
COHORT_OP_LIST, DEFAULT_DATE_WINDOW, DEFAULT_WINDOW, MEASURE_MARK, MEASURE_OP_LIST,
|
| 41 |
+
MEASURE_PAIR_OP_LIST, WITHIN_KINDS, cohortOpLabel, groupSummary,
|
| 42 |
+
isDateFamily, isNumericType, newCondition, newRuleId, opLabel, opsForType, rankDefault,
|
| 43 |
+
withCurrent, withCurrentField,
|
| 44 |
+
} from "./ops";
|
| 45 |
+
import "../customer-grid/filters.css";
|
| 46 |
+
|
| 47 |
+
/** The choice vocabulary of one field, resolved once and threaded down as a function so no
|
| 48 |
+
* level of the tree has to carry three separate maps to answer one question. */
|
| 49 |
+
type ChoicesFor = (colId: string) => string[];
|
| 50 |
+
|
| 51 |
+
/**
|
| 52 |
+
* Owner item 3 β the DATE value: an anchor mode, then whatever that mode needs.
|
| 53 |
+
*
|
| 54 |
+
* [is before] [one month ago] <- mode only
|
| 55 |
+
* [is before] [N days ago] [30] <- mode + a number
|
| 56 |
+
* [is before] [an exact date] [ π
] <- mode + a date
|
| 57 |
+
*
|
| 58 |
+
* The four value-free modes render NO second control, which is exactly why `isRuleActive` has
|
| 59 |
+
* to know about them: an empty value would otherwise read as a half-typed condition and be
|
| 60 |
+
* ignored, quietly widening the result.
|
| 61 |
+
*/
|
| 62 |
+
function AnchorValue({
|
| 63 |
+
rule,
|
| 64 |
+
onPatch,
|
| 65 |
+
}: {
|
| 66 |
+
rule: FilterRule;
|
| 67 |
+
onPatch: (p: Partial<FilterRule>) => void;
|
| 68 |
+
}) {
|
| 69 |
+
const mode = rule.dateMode ?? "exact";
|
| 70 |
+
return (
|
| 71 |
+
<span className="cg-cond-anchor">
|
| 72 |
+
<select
|
| 73 |
+
className="cg-select cg-cond-anchor-mode"
|
| 74 |
+
value={mode}
|
| 75 |
+
aria-label="Date"
|
| 76 |
+
onChange={(e) => {
|
| 77 |
+
// Clear the value with the mode. A "30" left behind on `an exact date`, or a date
|
| 78 |
+
// left behind on `N days ago`, is state the user can neither see nor clear.
|
| 79 |
+
const next = e.target.value as AnchorMode;
|
| 80 |
+
onPatch({ dateMode: next, value: "" });
|
| 81 |
+
}}
|
| 82 |
+
>
|
| 83 |
+
{ANCHOR_MODES.map((m) => (
|
| 84 |
+
<option key={m} value={m}>
|
| 85 |
+
{m === "n_days_ago" ? ANCHOR_LABELS[m].replace("{n}", "N") : ANCHOR_LABELS[m]}
|
| 86 |
+
</option>
|
| 87 |
+
))}
|
| 88 |
+
</select>
|
| 89 |
+
{mode === "n_days_ago" && (
|
| 90 |
+
<input
|
| 91 |
+
className="cg-input cg-cond-win-n"
|
| 92 |
+
type="number"
|
| 93 |
+
min={0}
|
| 94 |
+
max={MAX_N}
|
| 95 |
+
aria-label="Number of days ago"
|
| 96 |
+
value={rule.value}
|
| 97 |
+
onChange={(e) => onPatch({ value: e.target.value })}
|
| 98 |
+
/>
|
| 99 |
+
)}
|
| 100 |
+
{mode === "exact" && (
|
| 101 |
+
<input
|
| 102 |
+
className="cg-input cg-cond-val"
|
| 103 |
+
type="date"
|
| 104 |
+
aria-label="Date"
|
| 105 |
+
value={rule.value}
|
| 106 |
+
onChange={(e) => onPatch({ value: e.target.value })}
|
| 107 |
+
/>
|
| 108 |
+
)}
|
| 109 |
+
</span>
|
| 110 |
+
);
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
/**
|
| 114 |
+
* CG-9 (owner item 4) β the comparand when it is ANOTHER ATTRIBUTE rather than a number.
|
| 115 |
+
*
|
| 116 |
+
* `Sales [this year] > Sales [year to date, last year]`. For a MEASURE the right side carries
|
| 117 |
+
* its own window, which is the whole point: the two sides differ by period, not by name.
|
| 118 |
+
*/
|
| 119 |
+
function RhsPicker({
|
| 120 |
+
rule,
|
| 121 |
+
options,
|
| 122 |
+
isMeasure,
|
| 123 |
+
onPatch,
|
| 124 |
+
}: {
|
| 125 |
+
rule: FilterRule;
|
| 126 |
+
options: FieldSelectItem[];
|
| 127 |
+
isMeasure: boolean;
|
| 128 |
+
onPatch: (p: Partial<FilterRule>) => void;
|
| 129 |
+
}) {
|
| 130 |
+
const rhs = rule.rhs;
|
| 131 |
+
const colId = rhsColId(rhs) ?? "";
|
| 132 |
+
return (
|
| 133 |
+
<span className="cg-cond-rhs">
|
| 134 |
+
{/* Item 20 β the empty comparand was an `<option value="">` that a select renders as
|
| 135 |
+
its first row; here it is the picker's PLACEHOLDER state, so "not chosen yet" is a
|
| 136 |
+
state of the control rather than an entry in the vocabulary. */}
|
| 137 |
+
<FieldSelectButton
|
| 138 |
+
className="cg-cond-field"
|
| 139 |
+
ariaLabel="Compare with field"
|
| 140 |
+
placeholder="Select a fieldβ¦"
|
| 141 |
+
value={colId || undefined}
|
| 142 |
+
fields={options}
|
| 143 |
+
onChange={(key) => {
|
| 144 |
+
onPatch({
|
| 145 |
+
rhs: isMeasure
|
| 146 |
+
? { kind: "measure", colId: key,
|
| 147 |
+
window: (rhs?.kind === "measure" && rhs.window) || DEFAULT_WINDOW }
|
| 148 |
+
: { kind: "field", colId: key },
|
| 149 |
+
});
|
| 150 |
+
}}
|
| 151 |
+
/>
|
| 152 |
+
{isMeasure && colId !== "" && (
|
| 153 |
+
<WindowPicker
|
| 154 |
+
window={(rhs?.kind === "measure" && rhs.window) || DEFAULT_WINDOW}
|
| 155 |
+
onWindow={(w) => onPatch({ rhs: { kind: "measure", colId, window: w } })}
|
| 156 |
+
/>
|
| 157 |
+
)}
|
| 158 |
+
</span>
|
| 159 |
+
);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
// --- the value control for one filter condition (type/op aware) ------------
|
| 163 |
+
function ConditionValue({
|
| 164 |
+
rule,
|
| 165 |
+
type,
|
| 166 |
+
statusOptions,
|
| 167 |
+
onValue,
|
| 168 |
+
onValue2,
|
| 169 |
+
}: {
|
| 170 |
+
rule: FilterRule;
|
| 171 |
+
type: Field["type"];
|
| 172 |
+
statusOptions: string[];
|
| 173 |
+
onValue: (v: string) => void;
|
| 174 |
+
onValue2: (v: string) => void;
|
| 175 |
+
}) {
|
| 176 |
+
if (rule.op === "between") {
|
| 177 |
+
const inputType = type === "date" || type === "created_time" ? "date" : "number";
|
| 178 |
+
return (
|
| 179 |
+
<span className="cg-cond-between">
|
| 180 |
+
<input
|
| 181 |
+
className="cg-input cg-cond-val"
|
| 182 |
+
type={inputType}
|
| 183 |
+
value={rule.value}
|
| 184 |
+
onChange={(e) => onValue(e.target.value)}
|
| 185 |
+
/>
|
| 186 |
+
<span className="cg-cond-and">and</span>
|
| 187 |
+
<input
|
| 188 |
+
className="cg-input cg-cond-val"
|
| 189 |
+
type={inputType}
|
| 190 |
+
value={rule.value2 ?? ""}
|
| 191 |
+
onChange={(e) => onValue2(e.target.value)}
|
| 192 |
+
/>
|
| 193 |
+
</span>
|
| 194 |
+
);
|
| 195 |
+
}
|
| 196 |
+
if (
|
| 197 |
+
type === "status" || type === "select" || type === "user" || type === "multiselect"
|
| 198 |
+
) {
|
| 199 |
+
return (
|
| 200 |
+
<select
|
| 201 |
+
className="cg-select cg-cond-val"
|
| 202 |
+
value={rule.value}
|
| 203 |
+
onChange={(e) => onValue(e.target.value)}
|
| 204 |
+
>
|
| 205 |
+
<option value="">Selectβ¦</option>
|
| 206 |
+
{statusOptions.map((v) => (
|
| 207 |
+
<option key={v} value={v}>
|
| 208 |
+
{v}
|
| 209 |
+
</option>
|
| 210 |
+
))}
|
| 211 |
+
</select>
|
| 212 |
+
);
|
| 213 |
+
}
|
| 214 |
+
const inputType =
|
| 215 |
+
type === "date" || type === "created_time"
|
| 216 |
+
? "date"
|
| 217 |
+
: isNumericType(type)
|
| 218 |
+
? "number"
|
| 219 |
+
: "text";
|
| 220 |
+
return (
|
| 221 |
+
<input
|
| 222 |
+
className="cg-input cg-cond-val"
|
| 223 |
+
type={inputType}
|
| 224 |
+
placeholder="Value"
|
| 225 |
+
value={rule.value}
|
| 226 |
+
onChange={(e) => onValue(e.target.value)}
|
| 227 |
+
/>
|
| 228 |
+
);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
/**
|
| 232 |
+
* C-OPS β the value control for a rank condition.
|
| 233 |
+
*
|
| 234 |
+
* Two shapes, because the two questions are different: a COUNT (or a percentage) is typed and
|
| 235 |
+
* unbounded-ish, while a quantile is a choice from four or ten. Rendering the quantile as a
|
| 236 |
+
* number box would let somebody type `inQuartile 7`, which the engine refuses outright β the
|
| 237 |
+
* control that cannot express the invalid state is the better one.
|
| 238 |
+
*
|
| 239 |
+
* Bounds are on the input as `min`/`max` AND enforced in the engine, which is not redundancy:
|
| 240 |
+
* a number input's bounds are advisory (typing past them is allowed and only surfaces on form
|
| 241 |
+
* validation), so the box is a hint and `rankOps.bound` is the wall. A value outside the range
|
| 242 |
+
* makes the condition UNANSWERABLE β it matches nothing and says so beside the count.
|
| 243 |
+
*/
|
| 244 |
+
function RankValue({
|
| 245 |
+
rule,
|
| 246 |
+
onValue,
|
| 247 |
+
}: {
|
| 248 |
+
rule: FilterRule;
|
| 249 |
+
onValue: (v: string) => void;
|
| 250 |
+
}) {
|
| 251 |
+
const buckets = rule.op === "inQuartile" ? 4 : rule.op === "inDecile" ? 10 : 0;
|
| 252 |
+
if (buckets) {
|
| 253 |
+
return (
|
| 254 |
+
<select
|
| 255 |
+
className="cg-select cg-cond-val"
|
| 256 |
+
value={rule.value}
|
| 257 |
+
aria-label={rule.op === "inQuartile" ? "Quartile" : "Decile"}
|
| 258 |
+
onChange={(e) => onValue(e.target.value)}
|
| 259 |
+
>
|
| 260 |
+
{Array.from({ length: buckets }, (_, i) => String(i + 1)).map((n) => (
|
| 261 |
+
<option key={n} value={n}>
|
| 262 |
+
{/* The ends are the ones people mean, so they say so rather than making the
|
| 263 |
+
reader remember which end 1 is. */}
|
| 264 |
+
{n === "1"
|
| 265 |
+
? `${n} (lowest)`
|
| 266 |
+
: n === String(buckets)
|
| 267 |
+
? `${n} (highest)`
|
| 268 |
+
: n}
|
| 269 |
+
</option>
|
| 270 |
+
))}
|
| 271 |
+
</select>
|
| 272 |
+
);
|
| 273 |
+
}
|
| 274 |
+
const pct = rule.op === "inTopPct" || rule.op === "inBottomPct";
|
| 275 |
+
return (
|
| 276 |
+
<input
|
| 277 |
+
className="cg-input cg-cond-val"
|
| 278 |
+
type="number"
|
| 279 |
+
min={1}
|
| 280 |
+
max={pct ? 100 : 10000}
|
| 281 |
+
step={1}
|
| 282 |
+
placeholder={pct ? "10" : "10"}
|
| 283 |
+
aria-label={pct ? "Percent" : "How many"}
|
| 284 |
+
value={rule.value}
|
| 285 |
+
onChange={(e) => onValue(e.target.value)}
|
| 286 |
+
/>
|
| 287 |
+
);
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/**
|
| 291 |
+
* The cohorts a `Where [Cohort] [is any of] [β¦]` condition names (owner, 2026-07-27).
|
| 292 |
+
*
|
| 293 |
+
* Chips plus an add-select, not a `<select multiple>`: a native multi-select needs ctrl-click to
|
| 294 |
+
* build a set and shows what is chosen only by highlight, so the condition would stop reading as
|
| 295 |
+
* a sentence β and the one thing this row has to do is say what it filters. The add-select also
|
| 296 |
+
* keeps exactly one `.cg-cond-val` in the row, which is the control the shipped round-trip gate
|
| 297 |
+
* drives; choosing a cohort there APPENDS rather than replaces.
|
| 298 |
+
*
|
| 299 |
+
* The select is rendered even when there is nothing left to add, disabled. A control that
|
| 300 |
+
* vanishes at the cap would reflow the sentence mid-edit, and a gate that located it by class
|
| 301 |
+
* would report a missing control rather than a full set.
|
| 302 |
+
*
|
| 303 |
+
* A cohort the view names but the user no longer has still renders, as `(deleted list)` β the
|
| 304 |
+
* `withCurrent` rule applied to a set. The engine refuses such a condition outright (evalNode
|
| 305 |
+
* returns false for the whole leaf, not for that member), so showing the stale chip is exactly
|
| 306 |
+
* what lets somebody remove it.
|
| 307 |
+
*/
|
| 308 |
+
function CohortPicker({
|
| 309 |
+
rule,
|
| 310 |
+
lists,
|
| 311 |
+
onPatch,
|
| 312 |
+
}: {
|
| 313 |
+
rule: FilterRule;
|
| 314 |
+
lists: { id: string; name: string }[];
|
| 315 |
+
onPatch: (p: Partial<FilterRule>) => void;
|
| 316 |
+
}) {
|
| 317 |
+
const chosen = cohortIds(rule.value);
|
| 318 |
+
const nameById = new Map(lists.map((l) => [l.id, l.name]));
|
| 319 |
+
const remaining = lists.filter((l) => !chosen.includes(l.id));
|
| 320 |
+
const canAdd = remaining.length > 0 && chosen.length < MAX_COHORT_IDS;
|
| 321 |
+
const setIds = (ids: string[]) => onPatch({ value: ids.join(",") });
|
| 322 |
+
return (
|
| 323 |
+
<span className="cg-cond-cohorts">
|
| 324 |
+
{chosen.map((id) => (
|
| 325 |
+
<span key={id} className={"cg-chip" + (nameById.has(id) ? "" : " cg-chip--gone")}>
|
| 326 |
+
<span className="cg-chip-text">{nameById.get(id) ?? "(deleted list)"}</span>
|
| 327 |
+
<button
|
| 328 |
+
type="button"
|
| 329 |
+
className="cg-chip-x"
|
| 330 |
+
aria-label={`Remove ${nameById.get(id) ?? id}`}
|
| 331 |
+
onClick={() => setIds(chosen.filter((c) => c !== id))}
|
| 332 |
+
>
|
| 333 |
+
Γ
|
| 334 |
+
</button>
|
| 335 |
+
</span>
|
| 336 |
+
))}
|
| 337 |
+
<select
|
| 338 |
+
className="cg-select cg-cond-val cg-cond-cohort-add"
|
| 339 |
+
value=""
|
| 340 |
+
aria-label="Cohort"
|
| 341 |
+
disabled={!canAdd}
|
| 342 |
+
onChange={(e) => e.target.value && setIds([...chosen, e.target.value])}
|
| 343 |
+
>
|
| 344 |
+
{/* "nothing left to add" rendered as "nothing left to adβ¦" at 136px β the fourth time a
|
| 345 |
+
closed vocabulary has outgrown its control here. Shortened, not widened: the control
|
| 346 |
+
is sized so a COHORT NAME stays readable, and that is the string that matters. */}
|
| 347 |
+
<option value="">
|
| 348 |
+
{!canAdd ? "all added" : chosen.length ? "Add a cohortβ¦" : "Select a cohortβ¦"}
|
| 349 |
+
</option>
|
| 350 |
+
{remaining.map((l) => (
|
| 351 |
+
<option key={l.id} value={l.id}>
|
| 352 |
+
{l.name}
|
| 353 |
+
</option>
|
| 354 |
+
))}
|
| 355 |
+
</select>
|
| 356 |
+
</span>
|
| 357 |
+
);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
// --- Filter: an Airtable-style RECURSIVE condition builder -----------------
|
| 361 |
+
// Anatomy per Airtable (captured from the live product 2026-07-25):
|
| 362 |
+
// row 0 : static "Where"
|
| 363 |
+
// row 1 : the and/or DROPDOWN β one conjunction per LEVEL, not per condition
|
| 364 |
+
// row 2..n : static text mirroring that level's conjunction
|
| 365 |
+
// a group : inset panel + summary line ("All/Any of the following are trueβ¦")
|
| 366 |
+
// carrying its OWN conjunction and its own Where/and-or rows
|
| 367 |
+
// Nesting is capped at MAX_FILTER_DEPTH (3) β Airtable grays the group button out.
|
| 368 |
+
|
| 369 |
+
/** The leading cell of a condition row: "Where", the conjunction picker, or the mirror. */
|
| 370 |
+
function RowPrefix({
|
| 371 |
+
index,
|
| 372 |
+
conj,
|
| 373 |
+
onConj,
|
| 374 |
+
}: {
|
| 375 |
+
index: number;
|
| 376 |
+
conj: Conjunction;
|
| 377 |
+
onConj: (c: Conjunction) => void;
|
| 378 |
+
}) {
|
| 379 |
+
if (index === 0) return <span className="cg-cond-prefix">Where</span>;
|
| 380 |
+
if (index === 1)
|
| 381 |
+
return (
|
| 382 |
+
<select
|
| 383 |
+
className="cg-select cg-cond-conj"
|
| 384 |
+
value={conj}
|
| 385 |
+
aria-label="Combine conditions with"
|
| 386 |
+
onChange={(e) => onConj(e.target.value as Conjunction)}
|
| 387 |
+
>
|
| 388 |
+
<option value="and">and</option>
|
| 389 |
+
<option value="or">or</option>
|
| 390 |
+
</select>
|
| 391 |
+
);
|
| 392 |
+
return <span className="cg-cond-prefix cg-cond-conj-echo">{conj}</span>;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
/** Everything a level of the tree needs that does not change as it recurses. One object rather
|
| 396 |
+
* than eight repeated props: the list grew past the point where each level restating it made
|
| 397 |
+
* the recursion easier to read. */
|
| 398 |
+
interface LevelContext {
|
| 399 |
+
fields: Field[];
|
| 400 |
+
fieldByKey: Map<string, Field>;
|
| 401 |
+
measures: Measure[];
|
| 402 |
+
measureByKey: Map<string, Measure>;
|
| 403 |
+
/** Cohorts this user has β the vocabulary of `Where [Cohort] [is part of] [β¦]`. */
|
| 404 |
+
lists: { id: string; name: string }[];
|
| 405 |
+
choicesFor: ChoicesFor;
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
/**
|
| 409 |
+
* One nesting level: renders its nodes (leaves and nested groups) plus the
|
| 410 |
+
* add-buttons. Recurses through GroupNode for children that are groups.
|
| 411 |
+
*/
|
| 412 |
+
function ConditionLevel({
|
| 413 |
+
nodes,
|
| 414 |
+
conj,
|
| 415 |
+
depth,
|
| 416 |
+
ctx,
|
| 417 |
+
onNodes,
|
| 418 |
+
onConj,
|
| 419 |
+
}: {
|
| 420 |
+
nodes: FilterNode[];
|
| 421 |
+
conj: Conjunction;
|
| 422 |
+
/** 1 = the root level. A new group's depth would be depth + 1. */
|
| 423 |
+
depth: number;
|
| 424 |
+
ctx: LevelContext;
|
| 425 |
+
onNodes: (nodes: FilterNode[]) => void;
|
| 426 |
+
onConj: (c: Conjunction) => void;
|
| 427 |
+
}) {
|
| 428 |
+
/** Item 13 / R9 β which add-link was last used at THIS level. Per level, because the state
|
| 429 |
+
* belongs to the row of buttons the user clicked, not to the builder as a whole. */
|
| 430 |
+
const [lastAdd, setLastAdd] = useState<"condition" | "group" | null>(null);
|
| 431 |
+
const setNode = (i: number, next: FilterNode) =>
|
| 432 |
+
onNodes(nodes.map((n, j) => (j === i ? next : n)));
|
| 433 |
+
const removeNode = (i: number) => onNodes(nodes.filter((_, j) => j !== i));
|
| 434 |
+
|
| 435 |
+
const addCondition = () => {
|
| 436 |
+
const c = newCondition(ctx.fields);
|
| 437 |
+
if (c) onNodes([...nodes, c]);
|
| 438 |
+
};
|
| 439 |
+
// New groups default to "or" β matching Airtable, and the useful case (the
|
| 440 |
+
// root ANDs a set of alternatives together).
|
| 441 |
+
const addGroup = () => {
|
| 442 |
+
const c = newCondition(ctx.fields);
|
| 443 |
+
if (c) onNodes([...nodes, { conj: "or", children: [c] }]);
|
| 444 |
+
};
|
| 445 |
+
const canNest = depth < MAX_FILTER_DEPTH;
|
| 446 |
+
|
| 447 |
+
return (
|
| 448 |
+
<>
|
| 449 |
+
{nodes.map((node, i) => (
|
| 450 |
+
<div className="cg-cond-line" key={i}>
|
| 451 |
+
<RowPrefix index={i} conj={conj} onConj={onConj} />
|
| 452 |
+
{isFilterGroup(node) ? (
|
| 453 |
+
<GroupNode
|
| 454 |
+
group={node}
|
| 455 |
+
depth={depth + 1}
|
| 456 |
+
ctx={ctx}
|
| 457 |
+
onGroup={(g) => setNode(i, g)}
|
| 458 |
+
onRemove={() => removeNode(i)}
|
| 459 |
+
/>
|
| 460 |
+
) : (
|
| 461 |
+
<LeafRow
|
| 462 |
+
rule={node}
|
| 463 |
+
ctx={ctx}
|
| 464 |
+
onRule={(r) => setNode(i, r)}
|
| 465 |
+
onRemove={() => removeNode(i)}
|
| 466 |
+
/>
|
| 467 |
+
)}
|
| 468 |
+
</div>
|
| 469 |
+
))}
|
| 470 |
+
{/* Item 13 (owner, 2026-08-02) β the two add-links are BOLD, and the one you just used
|
| 471 |
+
holds a dark-blue state.
|
| 472 |
+
β R9 says that state lasts while the popover is open and a fresh open starts
|
| 473 |
+
neutral β which is exactly what a `useState` HERE gives for free, because `Popover`
|
| 474 |
+
renders its children only while open (`{open && <AnchoredOverlayβ¦>}`) and unmounts
|
| 475 |
+
them on close. Nothing persists it, nothing has to clear it, and there is no way for
|
| 476 |
+
it to survive a reopen. A `:active` pseudo-class would have been the wrong tool
|
| 477 |
+
twice over: it lasts for the duration of the mouse-down, and it says "you are
|
| 478 |
+
pressing this" rather than "this is the one you have been using". */}
|
| 479 |
+
<div className="cg-builder-add">
|
| 480 |
+
<button
|
| 481 |
+
type="button"
|
| 482 |
+
className={"cg-link-btn" + (lastAdd === "condition" ? " is-on" : "")}
|
| 483 |
+
onClick={() => {
|
| 484 |
+
setLastAdd("condition");
|
| 485 |
+
addCondition();
|
| 486 |
+
}}
|
| 487 |
+
>
|
| 488 |
+
+ Add condition
|
| 489 |
+
</button>
|
| 490 |
+
<button
|
| 491 |
+
type="button"
|
| 492 |
+
className={"cg-link-btn" + (lastAdd === "group" ? " is-on" : "")}
|
| 493 |
+
onClick={() => {
|
| 494 |
+
setLastAdd("group");
|
| 495 |
+
addGroup();
|
| 496 |
+
}}
|
| 497 |
+
disabled={!canNest}
|
| 498 |
+
title={
|
| 499 |
+
canNest
|
| 500 |
+
? "Group conditions so they evaluate together"
|
| 501 |
+
: `Condition groups can nest ${MAX_FILTER_DEPTH} levels deep`
|
| 502 |
+
}
|
| 503 |
+
>
|
| 504 |
+
+ Add condition group
|
| 505 |
+
</button>
|
| 506 |
+
</div>
|
| 507 |
+
</>
|
| 508 |
+
);
|
| 509 |
+
}
|
| 510 |
+
|
| 511 |
+
/**
|
| 512 |
+
* One leaf condition: field βΎ | (window βΎ) | operator βΎ | value | remove.
|
| 513 |
+
*
|
| 514 |
+
* The window bracket appears only for a MEASURE (CG-8) β a column already has its window baked
|
| 515 |
+
* in ("YTD $" IS year-to-date), and offering one there would imply it could be changed.
|
| 516 |
+
*/
|
| 517 |
+
function LeafRow({
|
| 518 |
+
rule,
|
| 519 |
+
ctx,
|
| 520 |
+
onRule,
|
| 521 |
+
onRemove,
|
| 522 |
+
}: {
|
| 523 |
+
rule: FilterRule;
|
| 524 |
+
ctx: LevelContext;
|
| 525 |
+
onRule: (r: FilterRule) => void;
|
| 526 |
+
onRemove: () => void;
|
| 527 |
+
}) {
|
| 528 |
+
const { fields, fieldByKey, measures, measureByKey, lists, choicesFor } = ctx;
|
| 529 |
+
const isMeasure = isMeasureRule(rule);
|
| 530 |
+
const isCohort = rule.colId === COHORT_FIELD;
|
| 531 |
+
const hasCohorts = lists.length > 0;
|
| 532 |
+
const t = isMeasure
|
| 533 |
+
? measureByKey.get(rule.colId)?.type ?? "currency"
|
| 534 |
+
: fieldByKey.get(rule.colId)?.type ?? "text";
|
| 535 |
+
const patch = (p: Partial<FilterRule>) => onRule({ ...rule, ...p });
|
| 536 |
+
// Switching field resets op to that type's default + clears the value(s), so a
|
| 537 |
+
// numeric op can never linger on a text column. Crossing the column/measure line also
|
| 538 |
+
// adds or removes the WINDOW and the rule ID, since those are what MAKE it a measure β
|
| 539 |
+
// leaving a window behind on a column condition would keep the server answering it. The
|
| 540 |
+
// rhs goes too: a comparand chosen for the old field is meaningless against the new one.
|
| 541 |
+
const onField = (key: string) => {
|
| 542 |
+
if (key === COHORT_FIELD) {
|
| 543 |
+
onRule({ colId: COHORT_FIELD, op: COHORT_OP_LIST[0], value: "" });
|
| 544 |
+
return;
|
| 545 |
+
}
|
| 546 |
+
const m = measureByKey.get(key);
|
| 547 |
+
if (m) {
|
| 548 |
+
onRule({
|
| 549 |
+
id: rule.id ?? newRuleId(),
|
| 550 |
+
colId: key,
|
| 551 |
+
op: MEASURE_OP_LIST[0],
|
| 552 |
+
value: "",
|
| 553 |
+
value2: undefined,
|
| 554 |
+
window: rule.window ?? DEFAULT_WINDOW,
|
| 555 |
+
});
|
| 556 |
+
return;
|
| 557 |
+
}
|
| 558 |
+
const nt = fieldByKey.get(key)?.type ?? "text";
|
| 559 |
+
onRule({ colId: key, op: opsForType(nt)[0], value: "", value2: undefined });
|
| 560 |
+
};
|
| 561 |
+
// Switching to/from a value-free op clears the stale value it can't show. `within` swaps the
|
| 562 |
+
// value for a RANGE and drops any field comparand, since a range has no other side.
|
| 563 |
+
const onOp = (op: FilterOp | CohortOp) => {
|
| 564 |
+
// A cohort keeps its SET when the set operator changes β "any of these three" and "none of
|
| 565 |
+
// these three" are the same three cohorts asked about differently, and clearing them would
|
| 566 |
+
// make the obvious edit destructive.
|
| 567 |
+
if (isCohort) return patch({ op });
|
| 568 |
+
// C-OPS: a rank op arrives with its N already chosen (see rankDefault) and drops every
|
| 569 |
+
// comparand β "the top 10 compared with another field" is not a question.
|
| 570 |
+
if (isRankOp(op as string))
|
| 571 |
+
return onRule({ ...rule, op: op as FilterOp, value: rankDefault(op as FilterOp),
|
| 572 |
+
value2: undefined, rhs: undefined, dateWindow: undefined });
|
| 573 |
+
if (VALUE_FREE_OPS.has(op as FilterOp))
|
| 574 |
+
return onRule({ ...rule, op, value: "", value2: undefined, rhs: undefined });
|
| 575 |
+
if (op === "within")
|
| 576 |
+
return onRule({ ...rule, op, value: "", value2: undefined, rhs: undefined,
|
| 577 |
+
dateWindow: rule.dateWindow ?? DEFAULT_DATE_WINDOW });
|
| 578 |
+
if (rule.op === "within")
|
| 579 |
+
return onRule({ ...rule, op, dateWindow: undefined, value: "" });
|
| 580 |
+
patch({ op });
|
| 581 |
+
};
|
| 582 |
+
// Owner item 4: the value box is REPLACEABLE by another attribute. Offered where a
|
| 583 |
+
// comparison between two of the same kind of thing means something β numbers against
|
| 584 |
+
// numbers, dates against dates, a measure against a measure over its own period.
|
| 585 |
+
const rhsOptions: FieldSelectItem[] = isMeasure
|
| 586 |
+
? measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK }))
|
| 587 |
+
: fields
|
| 588 |
+
.filter((f) => f.key !== rule.colId
|
| 589 |
+
&& (isNumericType(t) ? isNumericType(f.type) : f.type === t))
|
| 590 |
+
.map((f) => ({ key: f.key, label: f.label, type: f.type }));
|
| 591 |
+
const canCompareField = !isCohort && rule.op !== "within"
|
| 592 |
+
&& !VALUE_FREE_OPS.has(rule.op as FilterOp) && !isRankOp(rule.op)
|
| 593 |
+
&& rhsOptions.length > 0 && (isMeasure || isNumericType(t) || isDateFamily(t));
|
| 594 |
+
const usingStat = rule.rhs?.kind === "stat";
|
| 595 |
+
const usingField = rule.rhs != null && !usingStat;
|
| 596 |
+
// Explicitly widened: the cohort branch contributes a DISJOINT vocabulary, and without the
|
| 597 |
+
// annotation `withCurrent`'s `T extends string` infers from the first branch alone.
|
| 598 |
+
const opList: (FilterOp | CohortOp)[] = isMeasure
|
| 599 |
+
? (usingField || usingStat ? MEASURE_PAIR_OP_LIST : MEASURE_OP_LIST)
|
| 600 |
+
: isCohort ? COHORT_OP_LIST : opsForType(t);
|
| 601 |
+
const opValue: FilterOp | CohortOp = isCohort ? normalizeCohortOp(rule.op) : rule.op;
|
| 602 |
+
// C-OPS β the RANK family, offered under its own divider for the numeric family only.
|
| 603 |
+
//
|
| 604 |
+
// A COLUMN only: a measure leaf is answered by a server-side pid set over a window, so
|
| 605 |
+
// ranking one client-side would be a second answer to the same question (and the host's
|
| 606 |
+
// MEASURE_OPS deliberately does not carry these). A cohort leaf is set membership.
|
| 607 |
+
//
|
| 608 |
+
// Offered separately rather than appended to `opsForType` because these operators are a
|
| 609 |
+
// different KIND of question β every other one is about this row's value, these are about
|
| 610 |
+
// where the row sits among the others β and a divider is the cheapest way to say so.
|
| 611 |
+
const canRank = !isMeasure && !isCohort && isNumericType(t);
|
| 612 |
+
// `withCurrent` runs over the COMBINED offer so a rule already holding a rank op is not
|
| 613 |
+
// appended a second time; the split below then puts each op under the right heading. A
|
| 614 |
+
// rank op on a field that can no longer be ranked still renders β as itself, in the group,
|
| 615 |
+
// which is what lets somebody see it and change it.
|
| 616 |
+
const offeredOps = withCurrent(canRank ? [...opList, ...RANK_OPS] : opList, opValue);
|
| 617 |
+
const plainOps = offeredOps.filter((op) => !isRankOp(op));
|
| 618 |
+
const rankedOps = offeredOps.filter((op) => isRankOp(op));
|
| 619 |
+
|
| 620 |
+
return (
|
| 621 |
+
<span className={"cg-cond-row" + (isMeasure ? " is-measure" : "")
|
| 622 |
+
+ (isCohort ? " is-cohort" : "")
|
| 623 |
+
+ (rule.rhs != null ? " has-rhs" : "")}>
|
| 624 |
+
{/* ONE flat list (owner item 1). A measure used to sit under a "Measures (pick a
|
| 625 |
+
period)" optgroup; the owner's instruction was that it is all the same list β
|
| 626 |
+
you pick a thing to filter on, and a measure simply also asks for a period. Item 20
|
| 627 |
+
adds the type MARK to each row and takes NO grouping with it: the marks are what the
|
| 628 |
+
optgroup was reaching for, and they say it per row instead of splitting the list. */}
|
| 629 |
+
<FieldSelectButton
|
| 630 |
+
className="cg-cond-field"
|
| 631 |
+
ariaLabel="Field"
|
| 632 |
+
value={rule.colId}
|
| 633 |
+
onChange={onField}
|
| 634 |
+
fields={[
|
| 635 |
+
...withCurrentField(fields, rule.colId, fieldByKey, measureByKey, hasCohorts),
|
| 636 |
+
...measures.map((m) => ({ key: m.key, label: m.label, type: MEASURE_MARK })),
|
| 637 |
+
// A cohort leaf asks about membership of a SET, which is the multiselect mark's
|
| 638 |
+
// own meaning β the vocabulary already had the right glyph for it.
|
| 639 |
+
...(hasCohorts
|
| 640 |
+
? [{ key: COHORT_FIELD, label: "Cohort", type: "multiselect" as const }]
|
| 641 |
+
: []),
|
| 642 |
+
]}
|
| 643 |
+
/>
|
| 644 |
+
{isMeasure && (
|
| 645 |
+
<WindowPicker
|
| 646 |
+
window={rule.window ?? DEFAULT_WINDOW}
|
| 647 |
+
onWindow={(w) => patch({ window: w })}
|
| 648 |
+
/>
|
| 649 |
+
)}
|
| 650 |
+
<select
|
| 651 |
+
className="cg-select cg-cond-op"
|
| 652 |
+
// A view saved before 2026-07-27 holds `eq`/`neq`. Normalising HERE, rather than letting
|
| 653 |
+
// `withCurrent` append it, is the difference between a legacy rule reading "is any of"
|
| 654 |
+
// once and the list carrying two options with the same label and different values. The
|
| 655 |
+
// display stays truthful either way β `eq` IS `anyOf` β and the first edit writes the
|
| 656 |
+
// new op, matching what the validator already does on the server.
|
| 657 |
+
value={opValue}
|
| 658 |
+
aria-label="Condition"
|
| 659 |
+
onChange={(e) => onOp(e.target.value as FilterOp | CohortOp)}
|
| 660 |
+
>
|
| 661 |
+
{plainOps.map((op) => (
|
| 662 |
+
<option key={op} value={op}>
|
| 663 |
+
{/* `op as FilterOp` is safe by construction, not by hope: `opList` is the cohort
|
| 664 |
+
vocabulary EXACTLY when `isCohort`, so the other branch only ever sees the
|
| 665 |
+
column/measure one. The two lists are disjoint, so a wrong cast would show as a
|
| 666 |
+
raw key ("anyOf") on screen rather than as a plausible label. */}
|
| 667 |
+
{isCohort ? cohortOpLabel(op) : opLabel(op as FilterOp, t)}
|
| 668 |
+
</option>
|
| 669 |
+
))}
|
| 670 |
+
{rankedOps.length > 0 && (
|
| 671 |
+
<optgroup label="Ranked">
|
| 672 |
+
{rankedOps.map((op) => (
|
| 673 |
+
<option key={op} value={op}>
|
| 674 |
+
{opLabel(op as FilterOp, t)}
|
| 675 |
+
</option>
|
| 676 |
+
))}
|
| 677 |
+
</optgroup>
|
| 678 |
+
)}
|
| 679 |
+
</select>
|
| 680 |
+
{canCompareField && (
|
| 681 |
+
// The switch between "a value I type" and "another attribute". Two options rather than
|
| 682 |
+
// a checkbox, because the row already reads as a sentence and this is the noun slot.
|
| 683 |
+
<select
|
| 684 |
+
className="cg-select cg-cond-mode"
|
| 685 |
+
value={usingStat ? "stat" : usingField ? "field" : "value"}
|
| 686 |
+
aria-label="Compare against"
|
| 687 |
+
onChange={(e) => {
|
| 688 |
+
const mode = e.target.value;
|
| 689 |
+
if (mode === "stat")
|
| 690 |
+
return patch({ rhs: { kind: "stat", stat: "median" },
|
| 691 |
+
value: "", value2: undefined,
|
| 692 |
+
// a statistic is a computed cut-off: the same four ops a
|
| 693 |
+
// measure-vs-measure allows, for the same float reason
|
| 694 |
+
// reachable only via `canCompareField`, which excludes a cohort
|
| 695 |
+
op: MEASURE_PAIR_OP_LIST.includes(rule.op as FilterOp)
|
| 696 |
+
? rule.op : MEASURE_PAIR_OP_LIST[1] });
|
| 697 |
+
if (mode === "field") {
|
| 698 |
+
// An EMPTY comparand: incomplete, so the rule stays inactive until a field is
|
| 699 |
+
// picked. RhsPicker adds the window when the measure side gets one.
|
| 700 |
+
const empty: FilterRhs = isMeasure
|
| 701 |
+
? { kind: "measure", colId: "", window: DEFAULT_WINDOW }
|
| 702 |
+
: { kind: "field", colId: "" };
|
| 703 |
+
return patch({ rhs: empty, value: "", value2: undefined });
|
| 704 |
+
}
|
| 705 |
+
patch({ rhs: undefined, value: "" });
|
| 706 |
+
}}
|
| 707 |
+
>
|
| 708 |
+
<option value="value">a value</option>
|
| 709 |
+
<option value="field">another field</option>
|
| 710 |
+
{/* Statistics are computed SERVER-SIDE over the caller's pool, so they are offered
|
| 711 |
+
for a MEASURE only. A column statistic would need a second percentile
|
| 712 |
+
implementation on the client and the lock-step gate that goes with it. */}
|
| 713 |
+
{isMeasure && <option value="stat">a statistic</option>}
|
| 714 |
+
</select>
|
| 715 |
+
)}
|
| 716 |
+
{/* value-free ops render NO value control (Airtable does the same) */}
|
| 717 |
+
{VALUE_FREE_OPS.has(rule.op as FilterOp) ? (
|
| 718 |
+
<span className="cg-cond-val cg-cond-noval" aria-hidden />
|
| 719 |
+
) : usingStat ? (
|
| 720 |
+
<select
|
| 721 |
+
className="cg-select cg-cond-val cg-cond-stat"
|
| 722 |
+
value={rule.rhs?.kind === "stat" ? rule.rhs.stat : "median"}
|
| 723 |
+
aria-label="Statistic"
|
| 724 |
+
onChange={(e) => patch({ rhs: { kind: "stat", stat: e.target.value as StatKind } })}
|
| 725 |
+
>
|
| 726 |
+
{STAT_KINDS.map((k) => (
|
| 727 |
+
<option key={k} value={k}>
|
| 728 |
+
{STAT_LABELS[k]}
|
| 729 |
+
</option>
|
| 730 |
+
))}
|
| 731 |
+
</select>
|
| 732 |
+
) : usingField ? (
|
| 733 |
+
<RhsPicker rule={rule} options={rhsOptions} isMeasure={isMeasure} onPatch={patch} />
|
| 734 |
+
) : isCohort ? (
|
| 735 |
+
<CohortPicker rule={rule} lists={lists} onPatch={patch} />
|
| 736 |
+
) : isRankOp(rule.op) ? (
|
| 737 |
+
<RankValue rule={rule} onValue={(v) => patch({ value: v })} />
|
| 738 |
+
) : rule.op === "within" ? (
|
| 739 |
+
<WindowPicker
|
| 740 |
+
window={rule.dateWindow ?? DEFAULT_DATE_WINDOW}
|
| 741 |
+
onWindow={(w) => patch({ dateWindow: w })}
|
| 742 |
+
kinds={WITHIN_KINDS}
|
| 743 |
+
/>
|
| 744 |
+
) : isDateFamily(t) ? (
|
| 745 |
+
<AnchorValue rule={rule} onPatch={patch} />
|
| 746 |
+
) : (
|
| 747 |
+
<ConditionValue
|
| 748 |
+
rule={rule}
|
| 749 |
+
type={t}
|
| 750 |
+
statusOptions={choicesFor(rule.colId)}
|
| 751 |
+
onValue={(v) => patch({ value: v })}
|
| 752 |
+
onValue2={(v) => patch({ value2: v })}
|
| 753 |
+
/>
|
| 754 |
+
)}
|
| 755 |
+
<button
|
| 756 |
+
type="button"
|
| 757 |
+
className="cg-cond-x"
|
| 758 |
+
aria-label="Remove condition"
|
| 759 |
+
onClick={onRemove}
|
| 760 |
+
>
|
| 761 |
+
Γ
|
| 762 |
+
</button>
|
| 763 |
+
</span>
|
| 764 |
+
);
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
/** A nested condition group: summary line + its own recursive level. */
|
| 768 |
+
function GroupNode({
|
| 769 |
+
group,
|
| 770 |
+
depth,
|
| 771 |
+
ctx,
|
| 772 |
+
onGroup,
|
| 773 |
+
onRemove,
|
| 774 |
+
}: {
|
| 775 |
+
group: FilterGroup;
|
| 776 |
+
depth: number;
|
| 777 |
+
ctx: LevelContext;
|
| 778 |
+
onGroup: (g: FilterGroup) => void;
|
| 779 |
+
onRemove: () => void;
|
| 780 |
+
}) {
|
| 781 |
+
return (
|
| 782 |
+
<span className="cg-cond-group" data-depth={depth}>
|
| 783 |
+
<span className="cg-cond-group-head">
|
| 784 |
+
<span className="cg-cond-group-sum">
|
| 785 |
+
{group.children.length
|
| 786 |
+
? groupSummary(group.conj)
|
| 787 |
+
: "Add a condition to this groupβ¦"}
|
| 788 |
+
</span>
|
| 789 |
+
<button
|
| 790 |
+
type="button"
|
| 791 |
+
className="cg-cond-x"
|
| 792 |
+
aria-label="Remove condition group"
|
| 793 |
+
onClick={onRemove}
|
| 794 |
+
>
|
| 795 |
+
Γ
|
| 796 |
+
</button>
|
| 797 |
+
</span>
|
| 798 |
+
<span className="cg-cond-group-body">
|
| 799 |
+
<ConditionLevel
|
| 800 |
+
nodes={group.children}
|
| 801 |
+
conj={group.conj}
|
| 802 |
+
depth={depth}
|
| 803 |
+
ctx={ctx}
|
| 804 |
+
onNodes={(children) => onGroup({ ...group, children })}
|
| 805 |
+
onConj={(conj) => onGroup({ ...group, conj })}
|
| 806 |
+
/>
|
| 807 |
+
</span>
|
| 808 |
+
</span>
|
| 809 |
+
);
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
export interface FilterBuilderPanelProps {
|
| 813 |
+
/**
|
| 814 |
+
* The module's COMPLETE field list. The panel offers the FILTERABLE ones
|
| 815 |
+
* (`filterableFields`) and resolves labels from all of them β a condition saved against a
|
| 816 |
+
* column that has since stopped being filterable must still render its own name rather than
|
| 817 |
+
* turning into "?", so narrowing the picker may never orphan history.
|
| 818 |
+
*/
|
| 819 |
+
fields: Field[];
|
| 820 |
+
/** The whole filter as ONE value β nodes plus the root conjunction. See types.FilterTree. */
|
| 821 |
+
filters: FilterTree;
|
| 822 |
+
onChange: (next: FilterTree) => void;
|
| 823 |
+
/** Cohorts the subject of this filter has, for `Where [Cohort] [is any of] [β¦]`. */
|
| 824 |
+
cohorts?: { id: string; name: string }[];
|
| 825 |
+
/** CG-8 β measures the builder may offer alongside the columns. Absent = columns only. */
|
| 826 |
+
measures?: Measure[];
|
| 827 |
+
/** The people a `user` field may name. Never invented here β the host's real user list. */
|
| 828 |
+
userOptions?: string[];
|
| 829 |
+
/**
|
| 830 |
+
* The tenant's today, ISO. RESERVED: it is the anchor a relative window resolves against,
|
| 831 |
+
* and it is accepted so a caller that holds it (the grid, the admin editor) has one obvious
|
| 832 |
+
* place to put it. It renders NOTHING today β a "this resolves to <range>" hint under the
|
| 833 |
+
* date controls was considered for wave 15 and rejected as a visual delta C-KIT forbids.
|
| 834 |
+
* Kept in the signature rather than added later so no call site has to change to gain it.
|
| 835 |
+
*/
|
| 836 |
+
today?: string;
|
| 837 |
+
/**
|
| 838 |
+
* The per-field CHOICE VOCABULARY, by field key. The one prop that is not cosmetic when it
|
| 839 |
+
* is absent: some columns take their values from the DATA (the grid discovers them from its
|
| 840 |
+
* rows β `customer-grid/types.ts::choiceVocabulary`), which a panel over a module whose rows
|
| 841 |
+
* it has never loaded cannot do. Absent, choices fall back to the field's own declared
|
| 842 |
+
* `options` and, for `user`, to `userOptions`.
|
| 843 |
+
*
|
| 844 |
+
* β **Which columns those are is NOT decided by the type, and the old wording here saying
|
| 845 |
+
* "only Odoo `status` columns come up empty" was wrong in a way that shipped a live defect**
|
| 846 |
+
* (owner item 24, 2026-08-06). `stock_bucket` on the Product grid is declared `select` and
|
| 847 |
+
* declares no `options` β its vocabulary is computed server-side and rides the rows, exactly
|
| 848 |
+
* like a `status` column's. **Any column with no declared `options` needs this prop**, whatever
|
| 849 |
+
* its type says. A host that cannot enumerate one renders an empty picker; see `choicesFor`.
|
| 850 |
+
*/
|
| 851 |
+
statusValues?: Record<string, string[]>;
|
| 852 |
+
/**
|
| 853 |
+
* Item 12 (C-LOCK) β this view is LOCKED to a cohort. Present = say so, because the lock
|
| 854 |
+
* narrows the list and is not one of the conditions shown here: without the banner the count
|
| 855 |
+
* and the conditions disagree with no visible reason.
|
| 856 |
+
*
|
| 857 |
+
* `name`/`count` ABSENT means the reader cannot see the set β a shared view locked to
|
| 858 |
+
* somebody else's cohort. The engine matches nothing in that case (fail-closed, the
|
| 859 |
+
* cohort-leaf law), so the banner has to say which kind of nothing this is.
|
| 860 |
+
*/
|
| 861 |
+
cohortLock?: { name?: string; count?: number };
|
| 862 |
+
/**
|
| 863 |
+
* Extra footer content, rendered beside "Clear all" β the grid puts its "Copy from another
|
| 864 |
+
* view" door here. A SLOT rather than props, exactly like the toolbar's `cohortAction`: the
|
| 865 |
+
* door needs the other views and the view-patch plumbing, none of which a panel that also
|
| 866 |
+
* serves an admin editor should know about.
|
| 867 |
+
*/
|
| 868 |
+
footerExtra?: ReactNode;
|
| 869 |
+
}
|
| 870 |
+
|
| 871 |
+
export function FilterBuilderPanel({
|
| 872 |
+
fields,
|
| 873 |
+
filters,
|
| 874 |
+
onChange,
|
| 875 |
+
cohorts = [],
|
| 876 |
+
measures = [],
|
| 877 |
+
userOptions,
|
| 878 |
+
statusValues,
|
| 879 |
+
cohortLock,
|
| 880 |
+
footerExtra,
|
| 881 |
+
}: FilterBuilderPanelProps) {
|
| 882 |
+
const nodes = filterTreeNodes(filters);
|
| 883 |
+
const conj = filterTreeConj(filters);
|
| 884 |
+
// The COMPLETE map on purpose (see `fields` above): the picker is narrowed, the label
|
| 885 |
+
// resolution is not.
|
| 886 |
+
const fieldByKey = useMemo(() => {
|
| 887 |
+
const m = new Map<string, Field>();
|
| 888 |
+
for (const f of fields) m.set(f.key, f);
|
| 889 |
+
return m;
|
| 890 |
+
}, [fields]);
|
| 891 |
+
const condFields = useMemo(() => filterableFields(fields), [fields]);
|
| 892 |
+
const measureByKey = useMemo(() => {
|
| 893 |
+
const m = new Map<string, Measure>();
|
| 894 |
+
for (const item of measures) m.set(item.key, item);
|
| 895 |
+
return m;
|
| 896 |
+
}, [measures]);
|
| 897 |
+
const choicesFor = useCallback<ChoicesFor>(
|
| 898 |
+
(colId) => {
|
| 899 |
+
// A supplied map WINS, including when it holds an empty list: "this status column has no
|
| 900 |
+
// values in the data" is an answer, not a missing one.
|
| 901 |
+
const declared = statusValues?.[colId];
|
| 902 |
+
if (declared) return declared;
|
| 903 |
+
const f = fieldByKey.get(colId);
|
| 904 |
+
if (!f) return [];
|
| 905 |
+
return f.type === "user" ? userOptions ?? [] : choiceOptions(f);
|
| 906 |
+
},
|
| 907 |
+
[statusValues, fieldByKey, userOptions]
|
| 908 |
+
);
|
| 909 |
+
const ctx = useMemo<LevelContext>(
|
| 910 |
+
() => ({ fields: condFields, fieldByKey, measures, measureByKey, lists: cohorts,
|
| 911 |
+
choicesFor }),
|
| 912 |
+
[condFields, fieldByKey, measures, measureByKey, cohorts, choicesFor]
|
| 913 |
+
);
|
| 914 |
+
|
| 915 |
+
return (
|
| 916 |
+
<div className="cg-pop-body cg-builder">
|
| 917 |
+
<div className="cg-pop-title">Filter</div>
|
| 918 |
+
{/* C-LOCK (item 12) β the lock is NOT a condition row: it cannot be written here and
|
| 919 |
+
cannot be removed here (the view menu owns it). So it has to be SAID here, above
|
| 920 |
+
the conditions it bounds β otherwise the count and the conditions disagree and the
|
| 921 |
+
builder shows nothing that explains the gap. A reader who cannot see the set gets a
|
| 922 |
+
different sentence, because the engine shows them nobody and "no records" alone does
|
| 923 |
+
not distinguish a permission wall from a filter that genuinely matches nobody. */}
|
| 924 |
+
{cohortLock && (
|
| 925 |
+
<div className="cg-flt-lockchip">
|
| 926 |
+
{cohortLock.name
|
| 927 |
+
? `Customer list locked to ${cohortLock.name}` +
|
| 928 |
+
(typeof cohortLock.count === "number"
|
| 929 |
+
? ` Β· ${cohortLock.count.toLocaleString()} ${
|
| 930 |
+
cohortLock.count === 1 ? "customer" : "customers"
|
| 931 |
+
}`
|
| 932 |
+
: "")
|
| 933 |
+
: "Customer list locked to a set you cannot see"}
|
| 934 |
+
</div>
|
| 935 |
+
)}
|
| 936 |
+
{nodes.length === 0 ? (
|
| 937 |
+
<div className="cg-builder-empty">
|
| 938 |
+
No conditions. Add one to narrow the list.
|
| 939 |
+
</div>
|
| 940 |
+
) : (
|
| 941 |
+
<div className="cg-builder-caption">In this view, show records</div>
|
| 942 |
+
)}
|
| 943 |
+
<ConditionLevel
|
| 944 |
+
nodes={nodes}
|
| 945 |
+
conj={conj}
|
| 946 |
+
depth={1}
|
| 947 |
+
ctx={ctx}
|
| 948 |
+
onNodes={(next) => onChange({ conj, nodes: next })}
|
| 949 |
+
onConj={(next) => onChange({ conj: next, nodes })}
|
| 950 |
+
/>
|
| 951 |
+
{/* ββ THE RANK EXPLAINER WAS HERE AND IS DELETED (WAVE 34, R5). Owner, 2026-08-16:
|
| 952 |
+
*"Remove this stupid fucking explainer in our filter function⦠for our view why does it
|
| 953 |
+
show in the filter??"*
|
| 954 |
+
|
| 955 |
+
It was three sentences shown whenever a rank/quartile/decile condition entered the tree,
|
| 956 |
+
and the argument for it was not silly: a ranked condition genuinely answers a question
|
| 957 |
+
about the OTHER records, and nothing in the row says which others. What that argument
|
| 958 |
+
missed is that `DESIGN.md` Β§4's "never over-explain" law is not a style preference β a
|
| 959 |
+
paragraph that appears inside a control, every time you use that control, is read once
|
| 960 |
+
and then becomes furniture you have to look past forever. The person configuring a top-10
|
| 961 |
+
filter is not reading a tutorial; they are three clicks into a task.
|
| 962 |
+
|
| 963 |
+
β NOTHING WAS REPLACED WITH A TOOLTIP, and that is deliberate rather than an omission.
|
| 964 |
+
A tooltip here would be the same prose behind one more interaction, which is how "we
|
| 965 |
+
removed it" turns into "we hid it" β and the owner would find it again.
|
| 966 |
+
|
| 967 |
+
β The facts it carried are still TRUE and still matter (ranking is over the records the
|
| 968 |
+
other conditions keep; blanks are not ranked; quantiles are equal-size BY RANK, so two
|
| 969 |
+
identical values can land on opposite sides of a boundary). They belong in the docs, not
|
| 970 |
+
in the panel.
|
| 971 |
+
|
| 972 |
+
β AND `hasRankCondition` WENT WITH IT, WHICH THIS COMMENT INITIALLY GOT WRONG. The first
|
| 973 |
+
draft of this note said the predicate "KEEPS its other callers in `ops.ts`". It had none:
|
| 974 |
+
`tsc` (TS6133) named the import unread the moment the paragraph went, and a grep then
|
| 975 |
+
showed the only remaining reference was its OWN recursion. Deleted in the same edit, as
|
| 976 |
+
this comment's own rule demanded. Second time in one session that a confident claim about
|
| 977 |
+
who calls a symbol was refuted by the compiler within a minute. */}
|
| 978 |
+
{/* The footer carries "Clear all" (only when there is something to clear) and whatever
|
| 979 |
+
door the host mounts in `footerExtra`.
|
| 980 |
+
β The door is in the FOOTER rather than beside the add-links, where the reference
|
| 981 |
+
screenshot has it, for one structural reason: `.cg-builder-add` is emitted once per
|
| 982 |
+
NESTING LEVEL, so a link there would repeat inside every condition group and offer
|
| 983 |
+
to overwrite the whole view from inside one of its brackets. The footer is the one
|
| 984 |
+
place in this popover that means "the view", which is what the copy acts on. It is
|
| 985 |
+
also offered when there are NO conditions yet, which is when somebody most wants to
|
| 986 |
+
start from another view. */}
|
| 987 |
+
{(nodes.length > 0 || footerExtra) && (
|
| 988 |
+
<div className="cg-pop-footer cg-builder-footer">
|
| 989 |
+
{nodes.length > 0 && (
|
| 990 |
+
<button
|
| 991 |
+
type="button"
|
| 992 |
+
className="cg-link-btn"
|
| 993 |
+
onClick={() => onChange({ conj, nodes: [] })}
|
| 994 |
+
>
|
| 995 |
+
Clear all
|
| 996 |
+
</button>
|
| 997 |
+
)}
|
| 998 |
+
{footerExtra}
|
| 999 |
+
</div>
|
| 1000 |
+
)}
|
| 1001 |
+
</div>
|
| 1002 |
+
);
|
| 1003 |
+
}
|
web/src/filter-kit/ops.ts
CHANGED
|
@@ -1,400 +1,400 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// filter-kit / ops.ts
|
| 3 |
-
// The filter/sort operator VOCABULARY β which operators each field type offers,
|
| 4 |
-
// and how each one reads to a human.
|
| 5 |
-
//
|
| 6 |
-
// Extracted verbatim from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so
|
| 7 |
-
// the permission editor and the grid toolbar offer ONE vocabulary rather than two
|
| 8 |
-
// that drift. The matching engine (matchFilter/makeComparator in useVisibleRows)
|
| 9 |
-
// and its Python mirrors (filter_sql.py, filter_eval.py) are the other halves of
|
| 10 |
-
// the same contract; these are only the labels the popovers render.
|
| 11 |
-
// ---------------------------------------------------------------------------
|
| 12 |
-
|
| 13 |
-
import type {
|
| 14 |
-
Conjunction,
|
| 15 |
-
Field,
|
| 16 |
-
FieldType,
|
| 17 |
-
FilterNode,
|
| 18 |
-
FilterOp,
|
| 19 |
-
FilterRule,
|
| 20 |
-
Measure,
|
| 21 |
-
} from "../customer-grid/types";
|
| 22 |
-
import {
|
| 23 |
-
COHORT_FIELD, COHORT_OPS, MEASURE_OPS, MEASURE_PAIR_OPS, isFilterGroup,
|
| 24 |
-
isDateFamilyType,
|
| 25 |
-
} from "../customer-grid/types";
|
| 26 |
-
import type { CohortOp } from "../customer-grid/types";
|
| 27 |
-
import type { FieldSelectItem, FieldSelectType } from "../customer-grid/FieldSelect";
|
| 28 |
-
import type { WindowKind, WindowSpec } from "../customer-grid/windows";
|
| 29 |
-
|
| 30 |
-
// The type families live in types.ts (ONE definition for every UI surface); the engine keeps
|
| 31 |
-
// its own deliberately self-contained copies β see isNumericFieldType's doc.
|
| 32 |
-
export const isNumericType = isNumericFieldType;
|
| 33 |
-
export const isDateFamily = isDateFamilyType;
|
| 34 |
-
|
| 35 |
-
/**
|
| 36 |
-
* Operators offered for a field type (first = the default for new conditions).
|
| 37 |
-
* Mirrors Airtable's own per-type sets, verified against the live product
|
| 38 |
-
* (2026-07-25): a text field offers exactly contains / does not contain / is /
|
| 39 |
-
* is not / is empty / is not empty. Every type ends with the two value-free ops.
|
| 40 |
-
*
|
| 41 |
-
* NOTE on isEmpty over the Customer List contract: several Odoo-derived money
|
| 42 |
-
* columns (revenue_ytd, revenue_ly, at_risk, ltm_rev, orders_24m, est_missed) are
|
| 43 |
-
* built with 0.0/0 defaults in customer_list.pool(), so "is empty" correctly
|
| 44 |
-
* matches NOTHING there β 0 is a real value, not a blank. The columns where it
|
| 45 |
-
* genuinely bites are last_order (''), days_since, typical_gap_days, yoy_pct, and
|
| 46 |
-
* every overlay field (notes / custom) β "customers with no notes yet" is the
|
| 47 |
-
* common case. Offered on all types anyway, exactly as Airtable does.
|
| 48 |
-
*
|
| 49 |
-
* β AND the SENTINEL columns, which are a third case and the one that surprises:
|
| 50 |
-
* agent Β· city Β· state Β· country Β· zip Β· payment_terms Β· pricelist Β· tags all
|
| 51 |
-
* collapse a blank to the STRING '(none)' in `_partner_attrs`, so grouping has no
|
| 52 |
-
* null bucket. `isEmpty` tests `value === ""`, so on these it matches nothing even
|
| 53 |
-
* though 76 customers have no country and 79 no zip β the blanks are found with
|
| 54 |
-
* `is` `(none)`, which is also what the cell visibly says. Each of those fields
|
| 55 |
-
* carries that sentence as its column `note`, because a source comment is not
|
| 56 |
-
* where the person filtering is looking. DATE attributes keep '' instead
|
| 57 |
-
* (customer_since, last_order): '(none)' in a date column would sort and compare
|
| 58 |
-
* as text against ISO dates.
|
| 59 |
-
*/
|
| 60 |
-
export function opsForType(t: FieldType): FilterOp[] {
|
| 61 |
-
// Wave-5: a checkbox stores '1' or blank, so "is checked" IS `isNotEmpty` and "is
|
| 62 |
-
// unchecked" IS `isEmpty` β the honest mapping onto the existing vocabulary (no new
|
| 63 |
-
// FilterOp, the filter_sql lock-step untouched). Both are value-free, so no value control
|
| 64 |
-
// renders. First = the default: "is checked".
|
| 65 |
-
if (t === "checkbox") return ["isNotEmpty", "isEmpty"];
|
| 66 |
-
if (isNumericType(t))
|
| 67 |
-
return ["gte", "gt", "lte", "lt", "eq", "neq", "between", "isEmpty", "isNotEmpty"];
|
| 68 |
-
if (isDateFamily(t))
|
| 69 |
-
// Owner item 3, in the order they asked for it. `between` is GONE from the offer β "is
|
| 70 |
-
// within" plus a custom range says the same thing and reads better β but it stays in the
|
| 71 |
-
// ENGINE, because saved views hold it. `legacyOps` below is what keeps such a view
|
| 72 |
-
// rendering its own operator instead of silently showing the first one in this list.
|
| 73 |
-
return ["eq", "within", "lt", "gt", "lte", "gte", "neq", "isEmpty", "isNotEmpty"];
|
| 74 |
-
// `select` and `user` are picked from a fixed list, so they behave like `status`: is / is
|
| 75 |
-
// not / empty. `contains` on a chosen value is a substring test against a closed vocabulary β
|
| 76 |
-
// it would find "Done" inside "Not done" and read as a bug.
|
| 77 |
-
if (t === "status" || t === "select" || t === "user")
|
| 78 |
-
return ["eq", "neq", "isEmpty", "isNotEmpty"];
|
| 79 |
-
// A `multiselect` cell is a comma-joined SET, so the useful question is membership β "has" β
|
| 80 |
-
// which is the EXISTING `contains` op over the joined string (no new engine vocabulary; the
|
| 81 |
-
// ops are mirrored in filter_sql.py and inventing one here would break the lock-step). The
|
| 82 |
-
// value is still picked from the declared options, so the substring caveat above is bounded
|
| 83 |
-
// by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
|
| 84 |
-
if (t === "multiselect")
|
| 85 |
-
return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
|
| 86 |
-
// β Wave-23 C7 β `json` gets EXACTLY the two value-free ops, and the omissions are the
|
| 87 |
-
// decision. `contains` over a serialized document is a substring test against punctuation and
|
| 88 |
-
// key names: `contains "12"` would match a key `id_12`, a value 12, a timestamp and a
|
| 89 |
-
// fragment of 5120, and every one of those reads as a working filter. `eq` is worse β two
|
| 90 |
-
// documents that mean the same thing differ by key order and whitespace, so "is" would answer
|
| 91 |
-
// false for a record that plainly matches. "Has anything been captured here yet" is the
|
| 92 |
-
// question a json column can answer soundly, and it is the one people actually ask.
|
| 93 |
-
//
|
| 94 |
-
// β NO NEW OPERATOR ENTERS EITHER ENGINE. `isEmpty`/`isNotEmpty` already exist in
|
| 95 |
-
// `filter_sql.py` (:76, and :112's VALUE_FREE_OPS) and `filter_eval.py` (:260-262), and both
|
| 96 |
-
// are TYPE-INDEPENDENT
|
| 97 |
-
// untouched and nothing on the server had to move for this line. (The contract's prose spells
|
| 98 |
-
// them `is_empty`/`is_not_empty`; the runtimes do not. The names come from the code.)
|
| 99 |
-
if (t === "json") return ["isNotEmpty", "isEmpty"];
|
| 100 |
-
// Wave-18 C5-AUTOFIELD β `automation` lands HERE, on the text ops, and that is the whole
|
| 101 |
-
// v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
|
| 102 |
-
// `contains ok` and `contains error` are the two questions anyone actually asks of the
|
| 103 |
-
// column, and both are already sound over a string. Nothing new enters the operator
|
| 104 |
-
// vocabulary, so the `filter_sql.py` lock-step is untouched β a new op for "state is" would
|
| 105 |
-
// have to be mirrored in the Python engine, and the substring answer is the same answer.
|
| 106 |
-
return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"]; // text
|
| 107 |
-
}
|
| 108 |
-
|
| 109 |
-
/**
|
| 110 |
-
* C-OPS β the RANK operators, worded so the row still reads as a sentence.
|
| 111 |
-
*
|
| 112 |
-
* The unit lives in the OPERATOR, not beside the box: "top" followed by a bare 10 is ambiguous
|
| 113 |
-
* between ten records and ten percent, so `top N` and `top N%` are two operators and the box
|
| 114 |
-
* supplies N. The alternative β a "%" suffix element after the input β would put a second
|
| 115 |
-
* thing in the value slot that every gate locating `.cg-cond-val` would have to know about.
|
| 116 |
-
*
|
| 117 |
-
* β AND THEY ARE SHORT ON PURPOSE. A 136px `.cg-select` has ~97.9px of readable width (see
|
| 118 |
-
* `cohortOpLabel` below, and `withCurrentField` below) β about 15 characters of 12.5px Inter.
|
| 119 |
-
* "is in the bottom N%" is 19 and would have shipped CLIPPED, which no assertion here can see
|
| 120 |
-
* and no screenshot in this session could catch. Dropping the "is " costs nothing: the numeric
|
| 121 |
-
* operators beside these are bare glyphs (`β₯ > β€ <`), so the row already reads without one,
|
| 122 |
-
* and the `Ranked` divider carries the "these are a different kind of question" signal. Fifth
|
| 123 |
-
* time this control has outgrown a label; the label gives way, never the control.
|
| 124 |
-
*/
|
| 125 |
-
export const RANK_OP_LABELS: Record<string, string> = {
|
| 126 |
-
topN: "top N",
|
| 127 |
-
bottomN: "bottom N",
|
| 128 |
-
inTopPct: "top N%",
|
| 129 |
-
inBottomPct: "bottom N%",
|
| 130 |
-
aboveAvg: "above average",
|
| 131 |
-
belowAvg: "below average",
|
| 132 |
-
inQuartile: "in quartile",
|
| 133 |
-
inDecile: "in decile",
|
| 134 |
-
};
|
| 135 |
-
|
| 136 |
-
/**
|
| 137 |
-
* The value a rank op starts at when it is chosen.
|
| 138 |
-
*
|
| 139 |
-
* Seeded rather than left blank, and this is a deliberate break from how the other operators
|
| 140 |
-
* behave: a blank value makes a rule INACTIVE, so `is in the top N` with nothing typed would
|
| 141 |
-
* sit in the builder narrowing nothing, and "the filter I just added does nothing" reads as a
|
| 142 |
-
* broken control rather than as an unfinished sentence. Every rank op has an obvious default β
|
| 143 |
-
* ten, or the top slice β so there is a right answer to seed. The two averages take none.
|
| 144 |
-
*/
|
| 145 |
-
export function rankDefault(op: FilterOp): string {
|
| 146 |
-
switch (op) {
|
| 147 |
-
case "topN":
|
| 148 |
-
case "bottomN":
|
| 149 |
-
case "inTopPct":
|
| 150 |
-
case "inBottomPct":
|
| 151 |
-
case "inDecile":
|
| 152 |
-
return "10";
|
| 153 |
-
case "inQuartile":
|
| 154 |
-
return "4";
|
| 155 |
-
default:
|
| 156 |
-
return ""; // aboveAvg / belowAvg β value-free
|
| 157 |
-
}
|
| 158 |
-
}
|
| 159 |
-
|
| 160 |
-
/*
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
/** Human label for an operator, read naturally per field type. */
|
| 174 |
-
export function opLabel(op: FilterOp, t: FieldType): string {
|
| 175 |
-
// Rank first and type-independently: a rank op means the same thing on every numeric
|
| 176 |
-
// family, and a view whose column has since changed type must still SAY what it holds
|
| 177 |
-
// rather than falling through to a raw key.
|
| 178 |
-
if (isRankOp(op)) return RANK_OP_LABELS[op] ?? op;
|
| 179 |
-
// Checkbox first: its two value-free ops READ as states, not as blankness β the storage
|
| 180 |
-
// contract ('1' or blank) is an implementation detail the sentence must not leak.
|
| 181 |
-
if (t === "checkbox") {
|
| 182 |
-
if (op === "isNotEmpty") return "is checked";
|
| 183 |
-
if (op === "isEmpty") return "is unchecked";
|
| 184 |
-
}
|
| 185 |
-
// Type-independent: Airtable words these identically for every field type.
|
| 186 |
-
if (op === "isEmpty") return "is empty";
|
| 187 |
-
if (op === "isNotEmpty") return "is not empty";
|
| 188 |
-
if (isDateFamily(t)) {
|
| 189 |
-
switch (op) {
|
| 190 |
-
case "eq":
|
| 191 |
-
return "is";
|
| 192 |
-
case "neq":
|
| 193 |
-
return "is not";
|
| 194 |
-
case "within":
|
| 195 |
-
return "is within";
|
| 196 |
-
case "lt":
|
| 197 |
-
return "is before";
|
| 198 |
-
case "lte":
|
| 199 |
-
return "is on or before";
|
| 200 |
-
case "gt":
|
| 201 |
-
return "is after";
|
| 202 |
-
case "gte":
|
| 203 |
-
return "is on or after";
|
| 204 |
-
case "between":
|
| 205 |
-
return "is between"; // no longer offered; still rendered for a saved view
|
| 206 |
-
}
|
| 207 |
-
}
|
| 208 |
-
if (isNumericType(t)) {
|
| 209 |
-
switch (op) {
|
| 210 |
-
case "gte":
|
| 211 |
-
return "β₯"; // β₯
|
| 212 |
-
case "gt":
|
| 213 |
-
return ">";
|
| 214 |
-
case "lte":
|
| 215 |
-
return "β€"; // β€
|
| 216 |
-
case "lt":
|
| 217 |
-
return "<";
|
| 218 |
-
case "eq":
|
| 219 |
-
return "=";
|
| 220 |
-
case "neq":
|
| 221 |
-
return "β "; // β
|
| 222 |
-
case "between":
|
| 223 |
-
return "is between";
|
| 224 |
-
default:
|
| 225 |
-
return op;
|
| 226 |
-
}
|
| 227 |
-
}
|
| 228 |
-
if (t === "multiselect") {
|
| 229 |
-
// Membership wording over the joined-set cell. "has" IS `contains` (see opsForType) β only
|
| 230 |
-
// the label changes, so the engine and its Python mirror stay untouched.
|
| 231 |
-
switch (op) {
|
| 232 |
-
case "contains":
|
| 233 |
-
return "has";
|
| 234 |
-
case "doesNotContain":
|
| 235 |
-
return "does not have";
|
| 236 |
-
case "eq":
|
| 237 |
-
return "is exactly";
|
| 238 |
-
case "neq":
|
| 239 |
-
return "is not exactly";
|
| 240 |
-
default:
|
| 241 |
-
return op;
|
| 242 |
-
}
|
| 243 |
-
}
|
| 244 |
-
// text / status. (Airtable suffixes value-taking ops with "β¦" in the OPEN
|
| 245 |
-
// dropdown only; a native <select> shows one string in both states, so we use
|
| 246 |
-
// the closed-state wording β which is what's on screen almost all the time.)
|
| 247 |
-
switch (op) {
|
| 248 |
-
case "contains":
|
| 249 |
-
return "contains";
|
| 250 |
-
case "doesNotContain":
|
| 251 |
-
return "does not contain";
|
| 252 |
-
case "eq":
|
| 253 |
-
return "is";
|
| 254 |
-
case "neq":
|
| 255 |
-
return "is not";
|
| 256 |
-
default:
|
| 257 |
-
return op;
|
| 258 |
-
}
|
| 259 |
-
}
|
| 260 |
-
|
| 261 |
-
/**
|
| 262 |
-
* The offered list, PLUS whatever the rule is actually set to.
|
| 263 |
-
*
|
| 264 |
-
* A `<select>` whose `value` is not among its `<option>`s renders the FIRST option instead β
|
| 265 |
-
* so a saved view holding a dropped operator (a date `between`) or a column that has since left
|
| 266 |
-
* the filter list (`at_risk`, once its measure replacement shipped) would display something the
|
| 267 |
-
* rule does not say, and the first edit would silently rewrite it to that. Painted, plausible,
|
| 268 |
-
* and wrong: the class of failure a screenshot catches and an assertion does not.
|
| 269 |
-
*/
|
| 270 |
-
export function withCurrent<T extends string>(offered: readonly T[], current: T | undefined): T[] {
|
| 271 |
-
return current && !offered.includes(current) ? [...offered, current] : [...offered];
|
| 272 |
-
}
|
| 273 |
-
|
| 274 |
-
/** A group's summary line, mirroring Airtable's wording. */
|
| 275 |
-
export function groupSummary(conj: Conjunction): string {
|
| 276 |
-
return conj === "or"
|
| 277 |
-
? "Any of the following are trueβ¦"
|
| 278 |
-
: "All of the following are trueβ¦";
|
| 279 |
-
}
|
| 280 |
-
|
| 281 |
-
/**
|
| 282 |
-
* CG-8 β the operators a MEASURE condition may use, and why they are not `opsForType`.
|
| 283 |
-
*
|
| 284 |
-
* A measure is answered by a SQL `HAVING`, and `harness/measure_filter.OPS` has exactly six
|
| 285 |
-
* comparisons. `between` would need two, and `isEmpty`/`isNotEmpty` have no meaning for a sum
|
| 286 |
-
* that is defined to be 0 when there is nothing to add up β the resolver refuses all three by
|
| 287 |
-
* name. Offering them here would produce a condition the server can only reject.
|
| 288 |
-
*
|
| 289 |
-
* The owner's "dates between X and Y" is the WINDOW's between, not the value's, and that is the
|
| 290 |
-
* `custom` window kind β so nothing is lost.
|
| 291 |
-
*/
|
| 292 |
-
export const MEASURE_OP_LIST: FilterOp[] = [...MEASURE_OPS];
|
| 293 |
-
|
| 294 |
-
/** CG-9 β narrower still when the other side is a measure. `=` and `β ` between two sums of
|
| 295 |
-
* floats match nobody and everybody respectively; see types.ts MEASURE_PAIR_OPS. */
|
| 296 |
-
export const MEASURE_PAIR_OP_LIST: FilterOp[] = [...MEASURE_PAIR_OPS];
|
| 297 |
-
|
| 298 |
-
/**
|
| 299 |
-
* A cohort condition names a SET of cohorts (owner, 2026-07-27), so its operators are set
|
| 300 |
-
* operators. `eq`/`neq` are not offered β the validator rewrites them to `anyOf`/`noneOf`, and
|
| 301 |
-
* `withCurrent` would otherwise show a legacy view an operator it can never choose again.
|
| 302 |
-
*/
|
| 303 |
-
export const COHORT_OP_LIST: CohortOp[] = [...COHORT_OPS];
|
| 304 |
-
|
| 305 |
-
/**
|
| 306 |
-
* β "is part of any of" measures ~96px at 12.5px Inter against the 97.9px of readable width a
|
| 307 |
-
* 136px select actually has (136 - 20.1 arrow - 16 padding - 2 border) β inside the box only
|
| 308 |
-
* until a fallback font renders it. Airtable's own wording for a multi-select is shorter and
|
| 309 |
-
* says the same thing, so the LABEL gives way, not the control. Third time this rule has been
|
| 310 |
-
* applied; see the "(not filterable)" and "the 75th percentile" clippings before it.
|
| 311 |
-
*/
|
| 312 |
-
export function cohortOpLabel(op: FilterOp | CohortOp): string {
|
| 313 |
-
switch (normalizeCohortOp(op)) {
|
| 314 |
-
case "allOf":
|
| 315 |
-
return "is all of";
|
| 316 |
-
case "noneOf":
|
| 317 |
-
return "is none of";
|
| 318 |
-
default:
|
| 319 |
-
return "is any of";
|
| 320 |
-
}
|
| 321 |
-
}
|
| 322 |
-
|
| 323 |
-
/** Item 20 β the mark a MEASURE row wears in every picker on this surface. Named once so the
|
| 324 |
-
* three lists that offer measures cannot drift into two different glyphs for one concept. */
|
| 325 |
-
export const MEASURE_MARK: FieldSelectType = "measure";
|
| 326 |
-
|
| 327 |
-
/** A measure condition is identified by its window, so a new one needs a default window. */
|
| 328 |
-
export const DEFAULT_WINDOW: WindowSpec = { kind: "ltm" };
|
| 329 |
-
|
| 330 |
-
/** The default RANGE for `is within` β the owner's first example ("the past month"). */
|
| 331 |
-
export const DEFAULT_DATE_WINDOW: WindowSpec = { kind: "past_month" };
|
| 332 |
-
|
| 333 |
-
/**
|
| 334 |
-
* The ranges `is within` offers, in the owner's own order (item 3, 2026-07-26):
|
| 335 |
-
* the past number of days Β· the past week Β· the past month Β· the past year Β· this calendar
|
| 336 |
-
* week Β· this calendar month Β· this calendar year.
|
| 337 |
-
*
|
| 338 |
-
* Plus `custom`, which is not a nicety: dropping `between` from the date operators would
|
| 339 |
-
* otherwise remove the ability to ask for two exact dates at all. The remaining kinds
|
| 340 |
-
* (yesterday, last_quarter, ytd_last_year, the forward-looking onesβ¦) stay available to a
|
| 341 |
-
* MEASURE's window, where they make sense; "last order is within year to date, last year" is a
|
| 342 |
-
* question nobody asks.
|
| 343 |
-
*/
|
| 344 |
-
export const WITHIN_KINDS: WindowKind[] = [
|
| 345 |
-
"last_n_days", "past_week", "past_month", "past_year",
|
| 346 |
-
"this_week", "this_month", "this_year", "custom",
|
| 347 |
-
];
|
| 348 |
-
|
| 349 |
-
/**
|
| 350 |
-
* The field dropdown's COLUMN half, plus the column the rule is actually set to if the picker
|
| 351 |
-
* no longer offers it. See `withCurrent` β a select whose value is missing from its options
|
| 352 |
-
* silently displays a different field than the rule holds. This is not hypothetical: the
|
| 353 |
-
* shipped "Win-back" list filters `at_risk`, which left the filter list the day its measure
|
| 354 |
-
* replacement arrived.
|
| 355 |
-
*/
|
| 356 |
-
export function withCurrentField(
|
| 357 |
-
fields: Field[],
|
| 358 |
-
current: string,
|
| 359 |
-
fieldByKey: Map<string, Field>,
|
| 360 |
-
measureByKey: Map<string, Measure>,
|
| 361 |
-
hasCohorts: boolean
|
| 362 |
-
): FieldSelectItem[] {
|
| 363 |
-
// Item 20: the rows carry their TYPE now, because the picker paints the type mark. Same
|
| 364 |
-
// list, same order, same `withCurrent` rule β only the shape of a row grew a field.
|
| 365 |
-
const out: FieldSelectItem[] = fields.map((f) => ({
|
| 366 |
-
key: f.key, label: f.label, type: f.type,
|
| 367 |
-
}));
|
| 368 |
-
const known = new Set(out.map((o) => o.key));
|
| 369 |
-
if (!current || known.has(current) || measureByKey.has(current)) return out;
|
| 370 |
-
if (current === COHORT_FIELD && hasCohorts) return out;
|
| 371 |
-
// The field's OWN label, with no "(not filterable)" annotation: the select is ~170px and the
|
| 372 |
-
// suffix CLIPPED to "At risk $ (not filter" β a truncated parenthetical reads as a rendering
|
| 373 |
-
// bug, and the property that matters is that the condition says what it says. Found by
|
| 374 |
-
// reading the screenshot; every assertion in the run was green.
|
| 375 |
-
const f = fieldByKey.get(current);
|
| 376 |
-
out.push({ key: current, label: f ? f.label : current, type: f?.type });
|
| 377 |
-
return out;
|
| 378 |
-
}
|
| 379 |
-
|
| 380 |
-
/** Ids only have to be unique within one view's tree; they never leave this browser's view. */
|
| 381 |
-
let ruleSeq = 0;
|
| 382 |
-
export function newRuleId(): string {
|
| 383 |
-
ruleSeq += 1;
|
| 384 |
-
return `r${Date.now().toString(36)}${ruleSeq.toString(36)}`;
|
| 385 |
-
}
|
| 386 |
-
|
| 387 |
-
/** A fresh leaf condition on the first available field. */
|
| 388 |
-
export function newCondition(fields: Field[]): FilterRule | null {
|
| 389 |
-
const f0 = fields[0];
|
| 390 |
-
if (!f0) return null;
|
| 391 |
-
return { colId: f0.key, op: opsForType(f0.type)[0], value: "" };
|
| 392 |
-
}
|
| 393 |
-
|
| 394 |
-
/** Total LEAF conditions in a filter tree (groups contribute their contents). */
|
| 395 |
-
export function countConditions(nodes: FilterNode[]): number {
|
| 396 |
-
let n = 0;
|
| 397 |
-
for (const node of nodes)
|
| 398 |
-
n += isFilterGroup(node) ? countConditions(node.children) : 1;
|
| 399 |
-
return n;
|
| 400 |
-
}
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// filter-kit / ops.ts
|
| 3 |
+
// The filter/sort operator VOCABULARY β which operators each field type offers,
|
| 4 |
+
// and how each one reads to a human.
|
| 5 |
+
//
|
| 6 |
+
// Extracted verbatim from customer-grid/Toolbar.tsx (wave 15, contract C-KIT) so
|
| 7 |
+
// the permission editor and the grid toolbar offer ONE vocabulary rather than two
|
| 8 |
+
// that drift. The matching engine (matchFilter/makeComparator in useVisibleRows)
|
| 9 |
+
// and its Python mirrors (filter_sql.py, filter_eval.py) are the other halves of
|
| 10 |
+
// the same contract; these are only the labels the popovers render.
|
| 11 |
+
// ---------------------------------------------------------------------------
|
| 12 |
+
|
| 13 |
+
import type {
|
| 14 |
+
Conjunction,
|
| 15 |
+
Field,
|
| 16 |
+
FieldType,
|
| 17 |
+
FilterNode,
|
| 18 |
+
FilterOp,
|
| 19 |
+
FilterRule,
|
| 20 |
+
Measure,
|
| 21 |
+
} from "../customer-grid/types";
|
| 22 |
+
import {
|
| 23 |
+
COHORT_FIELD, COHORT_OPS, MEASURE_OPS, MEASURE_PAIR_OPS, isFilterGroup,
|
| 24 |
+
isDateFamilyType, isNumericFieldType, isRankOp, normalizeCohortOp,
|
| 25 |
+
} from "../customer-grid/types";
|
| 26 |
+
import type { CohortOp } from "../customer-grid/types";
|
| 27 |
+
import type { FieldSelectItem, FieldSelectType } from "../customer-grid/FieldSelect";
|
| 28 |
+
import type { WindowKind, WindowSpec } from "../customer-grid/windows";
|
| 29 |
+
|
| 30 |
+
// The type families live in types.ts (ONE definition for every UI surface); the engine keeps
|
| 31 |
+
// its own deliberately self-contained copies β see isNumericFieldType's doc.
|
| 32 |
+
export const isNumericType = isNumericFieldType;
|
| 33 |
+
export const isDateFamily = isDateFamilyType;
|
| 34 |
+
|
| 35 |
+
/**
|
| 36 |
+
* Operators offered for a field type (first = the default for new conditions).
|
| 37 |
+
* Mirrors Airtable's own per-type sets, verified against the live product
|
| 38 |
+
* (2026-07-25): a text field offers exactly contains / does not contain / is /
|
| 39 |
+
* is not / is empty / is not empty. Every type ends with the two value-free ops.
|
| 40 |
+
*
|
| 41 |
+
* NOTE on isEmpty over the Customer List contract: several Odoo-derived money
|
| 42 |
+
* columns (revenue_ytd, revenue_ly, at_risk, ltm_rev, orders_24m, est_missed) are
|
| 43 |
+
* built with 0.0/0 defaults in customer_list.pool(), so "is empty" correctly
|
| 44 |
+
* matches NOTHING there β 0 is a real value, not a blank. The columns where it
|
| 45 |
+
* genuinely bites are last_order (''), days_since, typical_gap_days, yoy_pct, and
|
| 46 |
+
* every overlay field (notes / custom) β "customers with no notes yet" is the
|
| 47 |
+
* common case. Offered on all types anyway, exactly as Airtable does.
|
| 48 |
+
*
|
| 49 |
+
* β AND the SENTINEL columns, which are a third case and the one that surprises:
|
| 50 |
+
* agent Β· city Β· state Β· country Β· zip Β· payment_terms Β· pricelist Β· tags all
|
| 51 |
+
* collapse a blank to the STRING '(none)' in `_partner_attrs`, so grouping has no
|
| 52 |
+
* null bucket. `isEmpty` tests `value === ""`, so on these it matches nothing even
|
| 53 |
+
* though 76 customers have no country and 79 no zip β the blanks are found with
|
| 54 |
+
* `is` `(none)`, which is also what the cell visibly says. Each of those fields
|
| 55 |
+
* carries that sentence as its column `note`, because a source comment is not
|
| 56 |
+
* where the person filtering is looking. DATE attributes keep '' instead
|
| 57 |
+
* (customer_since, last_order): '(none)' in a date column would sort and compare
|
| 58 |
+
* as text against ISO dates.
|
| 59 |
+
*/
|
| 60 |
+
export function opsForType(t: FieldType): FilterOp[] {
|
| 61 |
+
// Wave-5: a checkbox stores '1' or blank, so "is checked" IS `isNotEmpty` and "is
|
| 62 |
+
// unchecked" IS `isEmpty` β the honest mapping onto the existing vocabulary (no new
|
| 63 |
+
// FilterOp, the filter_sql lock-step untouched). Both are value-free, so no value control
|
| 64 |
+
// renders. First = the default: "is checked".
|
| 65 |
+
if (t === "checkbox") return ["isNotEmpty", "isEmpty"];
|
| 66 |
+
if (isNumericType(t))
|
| 67 |
+
return ["gte", "gt", "lte", "lt", "eq", "neq", "between", "isEmpty", "isNotEmpty"];
|
| 68 |
+
if (isDateFamily(t))
|
| 69 |
+
// Owner item 3, in the order they asked for it. `between` is GONE from the offer β "is
|
| 70 |
+
// within" plus a custom range says the same thing and reads better β but it stays in the
|
| 71 |
+
// ENGINE, because saved views hold it. `legacyOps` below is what keeps such a view
|
| 72 |
+
// rendering its own operator instead of silently showing the first one in this list.
|
| 73 |
+
return ["eq", "within", "lt", "gt", "lte", "gte", "neq", "isEmpty", "isNotEmpty"];
|
| 74 |
+
// `select` and `user` are picked from a fixed list, so they behave like `status`: is / is
|
| 75 |
+
// not / empty. `contains` on a chosen value is a substring test against a closed vocabulary β
|
| 76 |
+
// it would find "Done" inside "Not done" and read as a bug.
|
| 77 |
+
if (t === "status" || t === "select" || t === "user")
|
| 78 |
+
return ["eq", "neq", "isEmpty", "isNotEmpty"];
|
| 79 |
+
// A `multiselect` cell is a comma-joined SET, so the useful question is membership β "has" β
|
| 80 |
+
// which is the EXISTING `contains` op over the joined string (no new engine vocabulary; the
|
| 81 |
+
// ops are mirrored in filter_sql.py and inventing one here would break the lock-step). The
|
| 82 |
+
// value is still picked from the declared options, so the substring caveat above is bounded
|
| 83 |
+
// by the user's own choice list. `eq` reads "is exactly": the whole set is that one choice.
|
| 84 |
+
if (t === "multiselect")
|
| 85 |
+
return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"];
|
| 86 |
+
// β Wave-23 C7 β `json` gets EXACTLY the two value-free ops, and the omissions are the
|
| 87 |
+
// decision. `contains` over a serialized document is a substring test against punctuation and
|
| 88 |
+
// key names: `contains "12"` would match a key `id_12`, a value 12, a timestamp and a
|
| 89 |
+
// fragment of 5120, and every one of those reads as a working filter. `eq` is worse β two
|
| 90 |
+
// documents that mean the same thing differ by key order and whitespace, so "is" would answer
|
| 91 |
+
// false for a record that plainly matches. "Has anything been captured here yet" is the
|
| 92 |
+
// question a json column can answer soundly, and it is the one people actually ask.
|
| 93 |
+
//
|
| 94 |
+
// β NO NEW OPERATOR ENTERS EITHER ENGINE. `isEmpty`/`isNotEmpty` already exist in
|
| 95 |
+
// `filter_sql.py` (:76, and :112's VALUE_FREE_OPS) and `filter_eval.py` (:260-262), and both
|
| 96 |
+
// are TYPE-INDEPENDENT οΏ½οΏ½ they test blankness before any type dispatch β so the lock-step is
|
| 97 |
+
// untouched and nothing on the server had to move for this line. (The contract's prose spells
|
| 98 |
+
// them `is_empty`/`is_not_empty`; the runtimes do not. The names come from the code.)
|
| 99 |
+
if (t === "json") return ["isNotEmpty", "isEmpty"];
|
| 100 |
+
// Wave-18 C5-AUTOFIELD β `automation` lands HERE, on the text ops, and that is the whole
|
| 101 |
+
// v1 answer. Its cell is one machine-written line (`ok Β· 2026-08-03 14:10 Β· 12 posts`), so
|
| 102 |
+
// `contains ok` and `contains error` are the two questions anyone actually asks of the
|
| 103 |
+
// column, and both are already sound over a string. Nothing new enters the operator
|
| 104 |
+
// vocabulary, so the `filter_sql.py` lock-step is untouched β a new op for "state is" would
|
| 105 |
+
// have to be mirrored in the Python engine, and the substring answer is the same answer.
|
| 106 |
+
return ["contains", "doesNotContain", "eq", "neq", "isEmpty", "isNotEmpty"]; // text
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
/**
|
| 110 |
+
* C-OPS β the RANK operators, worded so the row still reads as a sentence.
|
| 111 |
+
*
|
| 112 |
+
* The unit lives in the OPERATOR, not beside the box: "top" followed by a bare 10 is ambiguous
|
| 113 |
+
* between ten records and ten percent, so `top N` and `top N%` are two operators and the box
|
| 114 |
+
* supplies N. The alternative β a "%" suffix element after the input β would put a second
|
| 115 |
+
* thing in the value slot that every gate locating `.cg-cond-val` would have to know about.
|
| 116 |
+
*
|
| 117 |
+
* β AND THEY ARE SHORT ON PURPOSE. A 136px `.cg-select` has ~97.9px of readable width (see
|
| 118 |
+
* `cohortOpLabel` below, and `withCurrentField` below) β about 15 characters of 12.5px Inter.
|
| 119 |
+
* "is in the bottom N%" is 19 and would have shipped CLIPPED, which no assertion here can see
|
| 120 |
+
* and no screenshot in this session could catch. Dropping the "is " costs nothing: the numeric
|
| 121 |
+
* operators beside these are bare glyphs (`β₯ > β€ <`), so the row already reads without one,
|
| 122 |
+
* and the `Ranked` divider carries the "these are a different kind of question" signal. Fifth
|
| 123 |
+
* time this control has outgrown a label; the label gives way, never the control.
|
| 124 |
+
*/
|
| 125 |
+
export const RANK_OP_LABELS: Record<string, string> = {
|
| 126 |
+
topN: "top N",
|
| 127 |
+
bottomN: "bottom N",
|
| 128 |
+
inTopPct: "top N%",
|
| 129 |
+
inBottomPct: "bottom N%",
|
| 130 |
+
aboveAvg: "above average",
|
| 131 |
+
belowAvg: "below average",
|
| 132 |
+
inQuartile: "in quartile",
|
| 133 |
+
inDecile: "in decile",
|
| 134 |
+
};
|
| 135 |
+
|
| 136 |
+
/**
|
| 137 |
+
* The value a rank op starts at when it is chosen.
|
| 138 |
+
*
|
| 139 |
+
* Seeded rather than left blank, and this is a deliberate break from how the other operators
|
| 140 |
+
* behave: a blank value makes a rule INACTIVE, so `is in the top N` with nothing typed would
|
| 141 |
+
* sit in the builder narrowing nothing, and "the filter I just added does nothing" reads as a
|
| 142 |
+
* broken control rather than as an unfinished sentence. Every rank op has an obvious default β
|
| 143 |
+
* ten, or the top slice β so there is a right answer to seed. The two averages take none.
|
| 144 |
+
*/
|
| 145 |
+
export function rankDefault(op: FilterOp): string {
|
| 146 |
+
switch (op) {
|
| 147 |
+
case "topN":
|
| 148 |
+
case "bottomN":
|
| 149 |
+
case "inTopPct":
|
| 150 |
+
case "inBottomPct":
|
| 151 |
+
case "inDecile":
|
| 152 |
+
return "10";
|
| 153 |
+
case "inQuartile":
|
| 154 |
+
return "4";
|
| 155 |
+
default:
|
| 156 |
+
return ""; // aboveAvg / belowAvg β value-free
|
| 157 |
+
}
|
| 158 |
+
}
|
| 159 |
+
|
| 160 |
+
/* β `hasRankCondition` LIVED HERE AND IS DELETED WITH THE PARAGRAPH IT GATED (wave 34, R5).
|
| 161 |
+
Its whole job was to decide whether to draw the builder's rank explainer; the owner has
|
| 162 |
+
removed that paragraph, and the predicate had exactly one caller besides its own recursion.
|
| 163 |
+
β `isRankOp` stays: `opLabel` below still calls it. `isMeasureRule` was `hasRankCondition`'s
|
| 164 |
+
ONLY use in this module, so THIS FILE'S IMPORT of it went too β the symbol itself lives on in
|
| 165 |
+
`types.ts` with plenty of callers, including `FilterBuilderPanel` two files over. The
|
| 166 |
+
distinction matters: an unused IMPORT is dead weight here, an unused EXPORT would be dead code
|
| 167 |
+
there, and only one of those was true.
|
| 168 |
+
β β THIS COMMENT'S FIRST DRAFT SAID BOTH SYMBOLS "STAY, they have many callers" β and `tsc`
|
| 169 |
+
refuted it in the next run. That is the THIRD time in one session a confident claim about who
|
| 170 |
+
calls a symbol was wrong within a minute of writing it. The habit worth keeping is not "be more
|
| 171 |
+
careful"; it is that the compiler answers this question for free, so ask it before asserting. */
|
| 172 |
+
|
| 173 |
+
/** Human label for an operator, read naturally per field type. */
|
| 174 |
+
export function opLabel(op: FilterOp, t: FieldType): string {
|
| 175 |
+
// Rank first and type-independently: a rank op means the same thing on every numeric
|
| 176 |
+
// family, and a view whose column has since changed type must still SAY what it holds
|
| 177 |
+
// rather than falling through to a raw key.
|
| 178 |
+
if (isRankOp(op)) return RANK_OP_LABELS[op] ?? op;
|
| 179 |
+
// Checkbox first: its two value-free ops READ as states, not as blankness β the storage
|
| 180 |
+
// contract ('1' or blank) is an implementation detail the sentence must not leak.
|
| 181 |
+
if (t === "checkbox") {
|
| 182 |
+
if (op === "isNotEmpty") return "is checked";
|
| 183 |
+
if (op === "isEmpty") return "is unchecked";
|
| 184 |
+
}
|
| 185 |
+
// Type-independent: Airtable words these identically for every field type.
|
| 186 |
+
if (op === "isEmpty") return "is empty";
|
| 187 |
+
if (op === "isNotEmpty") return "is not empty";
|
| 188 |
+
if (isDateFamily(t)) {
|
| 189 |
+
switch (op) {
|
| 190 |
+
case "eq":
|
| 191 |
+
return "is";
|
| 192 |
+
case "neq":
|
| 193 |
+
return "is not";
|
| 194 |
+
case "within":
|
| 195 |
+
return "is within";
|
| 196 |
+
case "lt":
|
| 197 |
+
return "is before";
|
| 198 |
+
case "lte":
|
| 199 |
+
return "is on or before";
|
| 200 |
+
case "gt":
|
| 201 |
+
return "is after";
|
| 202 |
+
case "gte":
|
| 203 |
+
return "is on or after";
|
| 204 |
+
case "between":
|
| 205 |
+
return "is between"; // no longer offered; still rendered for a saved view
|
| 206 |
+
}
|
| 207 |
+
}
|
| 208 |
+
if (isNumericType(t)) {
|
| 209 |
+
switch (op) {
|
| 210 |
+
case "gte":
|
| 211 |
+
return "β₯"; // β₯
|
| 212 |
+
case "gt":
|
| 213 |
+
return ">";
|
| 214 |
+
case "lte":
|
| 215 |
+
return "β€"; // β€
|
| 216 |
+
case "lt":
|
| 217 |
+
return "<";
|
| 218 |
+
case "eq":
|
| 219 |
+
return "=";
|
| 220 |
+
case "neq":
|
| 221 |
+
return "β "; // β
|
| 222 |
+
case "between":
|
| 223 |
+
return "is between";
|
| 224 |
+
default:
|
| 225 |
+
return op;
|
| 226 |
+
}
|
| 227 |
+
}
|
| 228 |
+
if (t === "multiselect") {
|
| 229 |
+
// Membership wording over the joined-set cell. "has" IS `contains` (see opsForType) β only
|
| 230 |
+
// the label changes, so the engine and its Python mirror stay untouched.
|
| 231 |
+
switch (op) {
|
| 232 |
+
case "contains":
|
| 233 |
+
return "has";
|
| 234 |
+
case "doesNotContain":
|
| 235 |
+
return "does not have";
|
| 236 |
+
case "eq":
|
| 237 |
+
return "is exactly";
|
| 238 |
+
case "neq":
|
| 239 |
+
return "is not exactly";
|
| 240 |
+
default:
|
| 241 |
+
return op;
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
// text / status. (Airtable suffixes value-taking ops with "β¦" in the OPEN
|
| 245 |
+
// dropdown only; a native <select> shows one string in both states, so we use
|
| 246 |
+
// the closed-state wording β which is what's on screen almost all the time.)
|
| 247 |
+
switch (op) {
|
| 248 |
+
case "contains":
|
| 249 |
+
return "contains";
|
| 250 |
+
case "doesNotContain":
|
| 251 |
+
return "does not contain";
|
| 252 |
+
case "eq":
|
| 253 |
+
return "is";
|
| 254 |
+
case "neq":
|
| 255 |
+
return "is not";
|
| 256 |
+
default:
|
| 257 |
+
return op;
|
| 258 |
+
}
|
| 259 |
+
}
|
| 260 |
+
|
| 261 |
+
/**
|
| 262 |
+
* The offered list, PLUS whatever the rule is actually set to.
|
| 263 |
+
*
|
| 264 |
+
* A `<select>` whose `value` is not among its `<option>`s renders the FIRST option instead β
|
| 265 |
+
* so a saved view holding a dropped operator (a date `between`) or a column that has since left
|
| 266 |
+
* the filter list (`at_risk`, once its measure replacement shipped) would display something the
|
| 267 |
+
* rule does not say, and the first edit would silently rewrite it to that. Painted, plausible,
|
| 268 |
+
* and wrong: the class of failure a screenshot catches and an assertion does not.
|
| 269 |
+
*/
|
| 270 |
+
export function withCurrent<T extends string>(offered: readonly T[], current: T | undefined): T[] {
|
| 271 |
+
return current && !offered.includes(current) ? [...offered, current] : [...offered];
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
/** A group's summary line, mirroring Airtable's wording. */
|
| 275 |
+
export function groupSummary(conj: Conjunction): string {
|
| 276 |
+
return conj === "or"
|
| 277 |
+
? "Any of the following are trueβ¦"
|
| 278 |
+
: "All of the following are trueβ¦";
|
| 279 |
+
}
|
| 280 |
+
|
| 281 |
+
/**
|
| 282 |
+
* CG-8 β the operators a MEASURE condition may use, and why they are not `opsForType`.
|
| 283 |
+
*
|
| 284 |
+
* A measure is answered by a SQL `HAVING`, and `harness/measure_filter.OPS` has exactly six
|
| 285 |
+
* comparisons. `between` would need two, and `isEmpty`/`isNotEmpty` have no meaning for a sum
|
| 286 |
+
* that is defined to be 0 when there is nothing to add up β the resolver refuses all three by
|
| 287 |
+
* name. Offering them here would produce a condition the server can only reject.
|
| 288 |
+
*
|
| 289 |
+
* The owner's "dates between X and Y" is the WINDOW's between, not the value's, and that is the
|
| 290 |
+
* `custom` window kind β so nothing is lost.
|
| 291 |
+
*/
|
| 292 |
+
export const MEASURE_OP_LIST: FilterOp[] = [...MEASURE_OPS];
|
| 293 |
+
|
| 294 |
+
/** CG-9 β narrower still when the other side is a measure. `=` and `β ` between two sums of
|
| 295 |
+
* floats match nobody and everybody respectively; see types.ts MEASURE_PAIR_OPS. */
|
| 296 |
+
export const MEASURE_PAIR_OP_LIST: FilterOp[] = [...MEASURE_PAIR_OPS];
|
| 297 |
+
|
| 298 |
+
/**
|
| 299 |
+
* A cohort condition names a SET of cohorts (owner, 2026-07-27), so its operators are set
|
| 300 |
+
* operators. `eq`/`neq` are not offered β the validator rewrites them to `anyOf`/`noneOf`, and
|
| 301 |
+
* `withCurrent` would otherwise show a legacy view an operator it can never choose again.
|
| 302 |
+
*/
|
| 303 |
+
export const COHORT_OP_LIST: CohortOp[] = [...COHORT_OPS];
|
| 304 |
+
|
| 305 |
+
/**
|
| 306 |
+
* β "is part of any of" measures ~96px at 12.5px Inter against the 97.9px of readable width a
|
| 307 |
+
* 136px select actually has (136 - 20.1 arrow - 16 padding - 2 border) β inside the box only
|
| 308 |
+
* until a fallback font renders it. Airtable's own wording for a multi-select is shorter and
|
| 309 |
+
* says the same thing, so the LABEL gives way, not the control. Third time this rule has been
|
| 310 |
+
* applied; see the "(not filterable)" and "the 75th percentile" clippings before it.
|
| 311 |
+
*/
|
| 312 |
+
export function cohortOpLabel(op: FilterOp | CohortOp): string {
|
| 313 |
+
switch (normalizeCohortOp(op)) {
|
| 314 |
+
case "allOf":
|
| 315 |
+
return "is all of";
|
| 316 |
+
case "noneOf":
|
| 317 |
+
return "is none of";
|
| 318 |
+
default:
|
| 319 |
+
return "is any of";
|
| 320 |
+
}
|
| 321 |
+
}
|
| 322 |
+
|
| 323 |
+
/** Item 20 β the mark a MEASURE row wears in every picker on this surface. Named once so the
|
| 324 |
+
* three lists that offer measures cannot drift into two different glyphs for one concept. */
|
| 325 |
+
export const MEASURE_MARK: FieldSelectType = "measure";
|
| 326 |
+
|
| 327 |
+
/** A measure condition is identified by its window, so a new one needs a default window. */
|
| 328 |
+
export const DEFAULT_WINDOW: WindowSpec = { kind: "ltm" };
|
| 329 |
+
|
| 330 |
+
/** The default RANGE for `is within` β the owner's first example ("the past month"). */
|
| 331 |
+
export const DEFAULT_DATE_WINDOW: WindowSpec = { kind: "past_month" };
|
| 332 |
+
|
| 333 |
+
/**
|
| 334 |
+
* The ranges `is within` offers, in the owner's own order (item 3, 2026-07-26):
|
| 335 |
+
* the past number of days Β· the past week Β· the past month Β· the past year Β· this calendar
|
| 336 |
+
* week Β· this calendar month Β· this calendar year.
|
| 337 |
+
*
|
| 338 |
+
* Plus `custom`, which is not a nicety: dropping `between` from the date operators would
|
| 339 |
+
* otherwise remove the ability to ask for two exact dates at all. The remaining kinds
|
| 340 |
+
* (yesterday, last_quarter, ytd_last_year, the forward-looking onesβ¦) stay available to a
|
| 341 |
+
* MEASURE's window, where they make sense; "last order is within year to date, last year" is a
|
| 342 |
+
* question nobody asks.
|
| 343 |
+
*/
|
| 344 |
+
export const WITHIN_KINDS: WindowKind[] = [
|
| 345 |
+
"last_n_days", "past_week", "past_month", "past_year",
|
| 346 |
+
"this_week", "this_month", "this_year", "custom",
|
| 347 |
+
];
|
| 348 |
+
|
| 349 |
+
/**
|
| 350 |
+
* The field dropdown's COLUMN half, plus the column the rule is actually set to if the picker
|
| 351 |
+
* no longer offers it. See `withCurrent` β a select whose value is missing from its options
|
| 352 |
+
* silently displays a different field than the rule holds. This is not hypothetical: the
|
| 353 |
+
* shipped "Win-back" list filters `at_risk`, which left the filter list the day its measure
|
| 354 |
+
* replacement arrived.
|
| 355 |
+
*/
|
| 356 |
+
export function withCurrentField(
|
| 357 |
+
fields: Field[],
|
| 358 |
+
current: string,
|
| 359 |
+
fieldByKey: Map<string, Field>,
|
| 360 |
+
measureByKey: Map<string, Measure>,
|
| 361 |
+
hasCohorts: boolean
|
| 362 |
+
): FieldSelectItem[] {
|
| 363 |
+
// Item 20: the rows carry their TYPE now, because the picker paints the type mark. Same
|
| 364 |
+
// list, same order, same `withCurrent` rule β only the shape of a row grew a field.
|
| 365 |
+
const out: FieldSelectItem[] = fields.map((f) => ({
|
| 366 |
+
key: f.key, label: f.label, type: f.type,
|
| 367 |
+
}));
|
| 368 |
+
const known = new Set(out.map((o) => o.key));
|
| 369 |
+
if (!current || known.has(current) || measureByKey.has(current)) return out;
|
| 370 |
+
if (current === COHORT_FIELD && hasCohorts) return out;
|
| 371 |
+
// The field's OWN label, with no "(not filterable)" annotation: the select is ~170px and the
|
| 372 |
+
// suffix CLIPPED to "At risk $ (not filter" β a truncated parenthetical reads as a rendering
|
| 373 |
+
// bug, and the property that matters is that the condition says what it says. Found by
|
| 374 |
+
// reading the screenshot; every assertion in the run was green.
|
| 375 |
+
const f = fieldByKey.get(current);
|
| 376 |
+
out.push({ key: current, label: f ? f.label : current, type: f?.type });
|
| 377 |
+
return out;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
/** Ids only have to be unique within one view's tree; they never leave this browser's view. */
|
| 381 |
+
let ruleSeq = 0;
|
| 382 |
+
export function newRuleId(): string {
|
| 383 |
+
ruleSeq += 1;
|
| 384 |
+
return `r${Date.now().toString(36)}${ruleSeq.toString(36)}`;
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
/** A fresh leaf condition on the first available field. */
|
| 388 |
+
export function newCondition(fields: Field[]): FilterRule | null {
|
| 389 |
+
const f0 = fields[0];
|
| 390 |
+
if (!f0) return null;
|
| 391 |
+
return { colId: f0.key, op: opsForType(f0.type)[0], value: "" };
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
/** Total LEAF conditions in a filter tree (groups contribute their contents). */
|
| 395 |
+
export function countConditions(nodes: FilterNode[]): number {
|
| 396 |
+
let n = 0;
|
| 397 |
+
for (const node of nodes)
|
| 398 |
+
n += isFilterGroup(node) ? countConditions(node.children) : 1;
|
| 399 |
+
return n;
|
| 400 |
+
}
|
web/src/inbox/InboxPage.tsx
CHANGED
|
@@ -46,7 +46,6 @@ import {
|
|
| 46 |
UNOPENABLE_NOTE,
|
| 47 |
canOpen,
|
| 48 |
inboxSections,
|
| 49 |
-
kindLabel,
|
| 50 |
previewOf,
|
| 51 |
senderOf,
|
| 52 |
subjectOf,
|
|
@@ -538,9 +537,15 @@ export function InboxSurface({
|
|
| 538 |
onClick={() => onOpen(selected)}
|
| 539 |
>
|
| 540 |
{/* Item 19 (T29): the verb names the DESTINATION rather than promising
|
| 541 |
-
"open" generically β two kinds go two places.
|
| 542 |
-
|
| 543 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 544 |
: "Open the database"}
|
| 545 |
</button>
|
| 546 |
) : (
|
|
|
|
| 46 |
UNOPENABLE_NOTE,
|
| 47 |
canOpen,
|
| 48 |
inboxSections,
|
|
|
|
| 49 |
previewOf,
|
| 50 |
senderOf,
|
| 51 |
subjectOf,
|
|
|
|
| 537 |
onClick={() => onOpen(selected)}
|
| 538 |
>
|
| 539 |
{/* Item 19 (T29): the verb names the DESTINATION rather than promising
|
| 540 |
+
"open" generically β two kinds go two places.
|
| 541 |
+
β WAVE 34 Β· R12 β THIS COMPARED A DISPLAY STRING, AND THAT IS WHY IT IS
|
| 542 |
+
WRITTEN THIS WAY NOW. It read `kindLabel(selected.kind) === "Automation"`:
|
| 543 |
+
a second copy of a caption, in a file the rename had no reason to open. The
|
| 544 |
+
moment `kindLabel` returned "Agents" the test went false in silence and
|
| 545 |
+
every automation notification offered to open a DATABASE. Compare the KIND,
|
| 546 |
+
which is a stored wire value nobody renames for a caption. */}
|
| 547 |
+
{selected.kind === NOTIF_KIND_AUTOMATION
|
| 548 |
+
? "Open the agent"
|
| 549 |
: "Open the database"}
|
| 550 |
</button>
|
| 551 |
) : (
|
web/src/inbox/inboxModel.ts
CHANGED
|
@@ -115,10 +115,21 @@ export function previewOf(n: Notification): string {
|
|
| 115 |
return label && label !== subjectOf(n) ? label : "";
|
| 116 |
}
|
| 117 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
/** The kind, in the reader's words. Unknown kinds fall to the alert wording rather than to a raw
|
| 119 |
* server token β a row is never allowed to print a vocabulary word at somebody. */
|
| 120 |
export function kindLabel(kind: string | undefined): string {
|
| 121 |
-
if (kind === NOTIF_KIND_AUTOMATION) return
|
| 122 |
if (kind === NOTIF_KIND_SHARE) return "Shared with you";
|
| 123 |
return "Alert";
|
| 124 |
}
|
|
@@ -138,7 +149,7 @@ export function kindLabel(kind: string | undefined): string {
|
|
| 138 |
export function senderOf(n: Notification): string {
|
| 139 |
const sent = String(n.sender || "").trim();
|
| 140 |
if (sent) return sent;
|
| 141 |
-
if (n.kind === NOTIF_KIND_AUTOMATION) return
|
| 142 |
if (n.kind === NOTIF_KIND_SHARE) return "A teammate";
|
| 143 |
return "Alerts";
|
| 144 |
}
|
|
|
|
| 115 |
return label && label !== subjectOf(n) ? label : "";
|
| 116 |
}
|
| 117 |
|
| 118 |
+
/**
|
| 119 |
+
* β WAVE 34 Β· R12 β THE MODULE'S NAME, IN ONE PLACE ON THE CLIENT.
|
| 120 |
+
*
|
| 121 |
+
* The owner renamed the Automation module to **Agents**. The WIRE kind stays `automation`
|
| 122 |
+
* ({@link NOTIF_KIND_AUTOMATION}) because it is a stored value on every queued notification;
|
| 123 |
+
* only what a reader sees moved. Both display sites below take it from here rather than
|
| 124 |
+
* repeating the word, because the last rename left the literal in three files and a fourth
|
| 125 |
+
* comparing against it.
|
| 126 |
+
*/
|
| 127 |
+
export const AGENTS_MODULE_LABEL = "Agents";
|
| 128 |
+
|
| 129 |
/** The kind, in the reader's words. Unknown kinds fall to the alert wording rather than to a raw
|
| 130 |
* server token β a row is never allowed to print a vocabulary word at somebody. */
|
| 131 |
export function kindLabel(kind: string | undefined): string {
|
| 132 |
+
if (kind === NOTIF_KIND_AUTOMATION) return AGENTS_MODULE_LABEL;
|
| 133 |
if (kind === NOTIF_KIND_SHARE) return "Shared with you";
|
| 134 |
return "Alert";
|
| 135 |
}
|
|
|
|
| 149 |
export function senderOf(n: Notification): string {
|
| 150 |
const sent = String(n.sender || "").trim();
|
| 151 |
if (sent) return sent;
|
| 152 |
+
if (n.kind === NOTIF_KIND_AUTOMATION) return AGENTS_MODULE_LABEL;
|
| 153 |
if (n.kind === NOTIF_KIND_SHARE) return "A teammate";
|
| 154 |
return "Alerts";
|
| 155 |
}
|
web/src/index.css
CHANGED
|
@@ -171,6 +171,23 @@
|
|
| 171 |
--lp-purple-tint: #f1eefb; /* the GROUP chip wash + its column header */
|
| 172 |
--lp-purple-deep: #6b57a8; /* 5.17:1 on its tint β group chip text */
|
| 173 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
/* --- Wave-15 C-BOLD (owner ruling R7) β THE PRIMARY ACTION -----------------
|
| 175 |
"Key buttons bold (dark purple)". A THIRD role in the palette, and it is
|
| 176 |
worth naming why it is not one of the five hues above: `--lp-purple` is the
|
|
@@ -2639,12 +2656,29 @@ body {
|
|
| 2639 |
a nav that came back empty said the same thing. A mark that spins says only
|
| 2640 |
"still working", which is the one true statement available.
|
| 2641 |
|
| 2642 |
-
TWO COLOURS, ZERO LITERALS. The
|
| 2643 |
-
|
| 2644 |
-
|
| 2645 |
-
|
| 2646 |
-
|
| 2647 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2648 |
|
| 2649 |
Markup contract (C-SPIN): <span class="lp-spin" role="status" aria-label="Loading" />
|
| 2650 |
β an empty element, sized by CSS. `aria-label` carries the word so the screen
|
|
@@ -2655,7 +2689,7 @@ body {
|
|
| 2655 |
width: 14px;
|
| 2656 |
height: 14px;
|
| 2657 |
border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
|
| 2658 |
-
border-top-color:
|
| 2659 |
border-radius: 50%;
|
| 2660 |
animation: lp-spin-rot 0.7s linear infinite;
|
| 2661 |
}
|
|
@@ -2744,7 +2778,7 @@ body {
|
|
| 2744 |
padding: 26px 28px 18px;
|
| 2745 |
box-shadow: var(--shell-shadow-overlay);
|
| 2746 |
}
|
| 2747 |
-
.login-brand { display: flex; align-items: center; gap: 12px; margin-bottom:
|
| 2748 |
.login-brand .lp-wordmark {
|
| 2749 |
font-size: var(--lp-fs-lg);
|
| 2750 |
font-weight: 600;
|
|
@@ -2752,6 +2786,27 @@ body {
|
|
| 2752 |
line-height: 1.15;
|
| 2753 |
}
|
| 2754 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2755 |
/* ---- the 8-STATE INPUT DISCIPLINE ----------------------------------------
|
| 2756 |
rest Β· placeholder Β· hover Β· focus Β· filled Β· error Β· disabled Β· autofill.
|
| 2757 |
border-WIDTH is 1px in EVERY state β state is carried by colour, background
|
|
@@ -4673,6 +4728,26 @@ a.cg-map-ctl-b { text-decoration: none; }
|
|
| 4673 |
/* Wave-8 I11c (C4) β folders in the Views rail. */
|
| 4674 |
.cg-fold-new { padding: 2px 8px 4px; }
|
| 4675 |
.cg-fold, .cg-fold-root { border-radius: 6px; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4676 |
.cg-fold.is-drop, .cg-fold-root.is-drop {
|
| 4677 |
background: var(--lp-blue-tint);
|
| 4678 |
outline: 1px dashed var(--lp-blue-deep);
|
|
@@ -12194,16 +12269,33 @@ textarea.cg-json-raw:focus {
|
|
| 12194 |
auto` pushes it to the right edge so the badge lines up with the per-view badges directly
|
| 12195 |
below it β the total and its parts read as one column, which is the whole point of putting it
|
| 12196 |
here rather than in the shell's database header. */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12197 |
.cg-views-important {
|
| 12198 |
margin-left: auto;
|
| 12199 |
display: inline-flex;
|
| 12200 |
align-items: center;
|
| 12201 |
-
gap: 6px;
|
| 12202 |
-
font-size: var(--lp-fs-2xs);
|
| 12203 |
-
font-weight: 600;
|
| 12204 |
-
color: var(--lp-muted);
|
| 12205 |
white-space: nowrap;
|
| 12206 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12207 |
/* A PARTIAL total wears the same purple as a whole one β the number it shows IS real, it is just
|
| 12208 |
not all of them β and says so with a `+` and its title. Muting it would read as "less
|
| 12209 |
important" rather than "less complete". */
|
|
|
|
| 171 |
--lp-purple-tint: #f1eefb; /* the GROUP chip wash + its column header */
|
| 172 |
--lp-purple-deep: #6b57a8; /* 5.17:1 on its tint β group chip text */
|
| 173 |
|
| 174 |
+
/* --- WAVE 34 R2 β THE BRAND MARK'S PURPLE, NOW A TOKEN --------------------
|
| 175 |
+
Owner, 2026-08-16: *"The loading screen loop, the color should be purple as
|
| 176 |
+
well."* "As well" is the operative phrase: the loop mark in the logo is this
|
| 177 |
+
purple, and the spinner is the same loop, so it should be the same colour.
|
| 178 |
+
|
| 179 |
+
β THIS IS THE THIRD PURPLE AND IT IS NOT INTERCHANGEABLE WITH THE OTHER TWO.
|
| 180 |
+
`--lp-purple` (#c3b3ee) is a 1.91:1 FILL and would be invisible as a 2px arc;
|
| 181 |
+
`--lp-purple-deep` (#6b57a8) is the GROUP control's hue, and a spinner painted
|
| 182 |
+
from it would say "this wait is about grouping". This one is the mark's own
|
| 183 |
+
colour, previously a literal in `favicon.svg` and `_brandmark.LP_PURPLE` with
|
| 184 |
+
no CSS token at all β which is exactly why the comment three lines up had to
|
| 185 |
+
spell the hex out in prose to warn people off it.
|
| 186 |
+
β The comment above still stands and is not contradicted: the brand purple is
|
| 187 |
+
deliberately OUTSIDE the UI palette. This token does not admit it to the
|
| 188 |
+
palette; it names it so the ONE surface that is the brand mark can use it. */
|
| 189 |
+
--lp-brand: #6e56cf; /* the loop mark (favicon.svg, _brandmark) */
|
| 190 |
+
|
| 191 |
/* --- Wave-15 C-BOLD (owner ruling R7) β THE PRIMARY ACTION -----------------
|
| 192 |
"Key buttons bold (dark purple)". A THIRD role in the palette, and it is
|
| 193 |
worth naming why it is not one of the five hues above: `--lp-purple` is the
|
|
|
|
| 2656 |
a nav that came back empty said the same thing. A mark that spins says only
|
| 2657 |
"still working", which is the one true statement available.
|
| 2658 |
|
| 2659 |
+
TWO COLOURS, ZERO LITERALS. The TRACK is `currentColor` at 22%, so it reads as
|
| 2660 |
+
the muted grey the contract asks for WITHOUT hard-coding a grey that would
|
| 2661 |
+
disappear on a tinted panel.
|
| 2662 |
+
|
| 2663 |
+
β WAVE 34 R2 β THE LEADING ARC IS THE BRAND PURPLE NOW, not `currentColor`.
|
| 2664 |
+
Owner: *"The loading screen loop, the color should be purple as well."* The
|
| 2665 |
+
spinner IS the logo's loop, so it wears the logo's colour.
|
| 2666 |
+
β WHAT THAT COSTS, stated rather than discovered later: the arc no longer
|
| 2667 |
+
adapts to its container's ink. That adaptation was the original design and it
|
| 2668 |
+
was a good one β it is why no caller has ever had to pick a colour. Pinning it
|
| 2669 |
+
is safe TODAY because it was MEASURED, not assumed. All 27 `.lp-spin` call sites
|
| 2670 |
+
sit on light panels, and #6e56cf against each of them is:
|
| 2671 |
+
white 5.39:1 Β· --lp-surface-2 #f7f7f6 5.03:1 Β· --lp-purple-tint 4.71:1
|
| 2672 |
+
--lp-wash #fafbfc 5.20:1
|
| 2673 |
+
NONE of them sits inside a filled or dark button, which is the case that breaks.
|
| 2674 |
+
(These are the same numbers the palette block above quotes for its own hues, by
|
| 2675 |
+
the same formula β a contrast claim written as "clears 4.5:1" would have been a
|
| 2676 |
+
threshold restated as a fact [[no-unverifiable-aggregates]].)
|
| 2677 |
+
β SO THE RULE FOR THE NEXT PERSON: if you mount `.lp-spin` on a dark or
|
| 2678 |
+
saturated surface, it will be nearly invisible and the fix is a modifier class
|
| 2679 |
+
that restores `currentColor` for that surface, NOT reverting this line.
|
| 2680 |
+
The TRACK deliberately stays `currentColor` so the ring still seats itself on
|
| 2681 |
+
whatever it sits on.
|
| 2682 |
|
| 2683 |
Markup contract (C-SPIN): <span class="lp-spin" role="status" aria-label="Loading" />
|
| 2684 |
β an empty element, sized by CSS. `aria-label` carries the word so the screen
|
|
|
|
| 2689 |
width: 14px;
|
| 2690 |
height: 14px;
|
| 2691 |
border: 2px solid color-mix(in srgb, currentColor 22%, transparent);
|
| 2692 |
+
border-top-color: var(--lp-brand);
|
| 2693 |
border-radius: 50%;
|
| 2694 |
animation: lp-spin-rot 0.7s linear infinite;
|
| 2695 |
}
|
|
|
|
| 2778 |
padding: 26px 28px 18px;
|
| 2779 |
box-shadow: var(--shell-shadow-overlay);
|
| 2780 |
}
|
| 2781 |
+
.login-brand { display: flex; align-items: center; gap: 12px; margin-bottom: 6px; }
|
| 2782 |
.login-brand .lp-wordmark {
|
| 2783 |
font-size: var(--lp-fs-lg);
|
| 2784 |
font-weight: 600;
|
|
|
|
| 2786 |
line-height: 1.15;
|
| 2787 |
}
|
| 2788 |
|
| 2789 |
+
/* β WAVE 34 R9 β the sign-in card's one line of sell.
|
| 2790 |
+
β IT MUST NOT COMPETE WITH THE WORDMARK. `--lp-fs-sm` and the muted ink put it a clear step
|
| 2791 |
+
below the brand line, which is what keeps the card reading as a door rather than a landing
|
| 2792 |
+
page. The 18px bottom margin replaces `.login-brand`'s 12px as the gap before the form, so the
|
| 2793 |
+
form does not move when this line is present: measured from the same total, not added to it.
|
| 2794 |
+
β NO gold accent line and no rule above the form. The owner rejected exactly that once already
|
| 2795 |
+
("there's an orange line at the top, it's ugly"), and a slogan is the most tempting place to
|
| 2796 |
+
reach for one. */
|
| 2797 |
+
/* β `--lp-muted`, NOT `--lp-ink-muted`. The first draft of this rule used the second name, which
|
| 2798 |
+
does not exist -- and a ghost token does not fail loudly, it paints the INHERITED colour, so the
|
| 2799 |
+
tagline would have rendered at full `--lp-ink` weight and competed with the wordmark. The token
|
| 2800 |
+
block above carries a scar from the same class (`--lp-wash: var(--lp-wash)`, a self-reference
|
| 2801 |
+
that silently painted nothing everywhere it was consumed) and notes that the ghost-token gate
|
| 2802 |
+
only checks a consumed name is DECLARED. Measured: `--lp-muted` #6b7280 is 4.83:1 on white. */
|
| 2803 |
+
.login-tagline {
|
| 2804 |
+
margin: 0 0 18px;
|
| 2805 |
+
font-size: var(--lp-fs-sm);
|
| 2806 |
+
color: var(--lp-muted);
|
| 2807 |
+
letter-spacing: -0.005em;
|
| 2808 |
+
}
|
| 2809 |
+
|
| 2810 |
/* ---- the 8-STATE INPUT DISCIPLINE ----------------------------------------
|
| 2811 |
rest Β· placeholder Β· hover Β· focus Β· filled Β· error Β· disabled Β· autofill.
|
| 2812 |
border-WIDTH is 1px in EVERY state β state is carried by colour, background
|
|
|
|
| 4728 |
/* Wave-8 I11c (C4) β folders in the Views rail. */
|
| 4729 |
.cg-fold-new { padding: 2px 8px 4px; }
|
| 4730 |
.cg-fold, .cg-fold-root { border-radius: 6px; }
|
| 4731 |
+
/* ββ WAVE 34 Β· T22 (ruling R4) β WHILE A VIEW IS BEING DRAGGED, THE ROOT SECTION HAS A BODY.
|
| 4732 |
+
Off-drag this rule does not apply at all, so the rail keeps the exact layout it has today; the
|
| 4733 |
+
class is only on the root group and only while `viewDrag !== null` (ViewSidebar).
|
| 4734 |
+
Β· `min-height` is the EMPTY case: a user who filed every view into folders had a zero-pixel
|
| 4735 |
+
drop target and no way to unfile anything.
|
| 4736 |
+
Β· `padding-bottom` is the POPULATED case, which is the one nobody had noticed. This element has
|
| 4737 |
+
no padding of its own, so its box is exactly the union of its rows, and every row claims the
|
| 4738 |
+
drag for reordering via `stopPropagation`. Without a strip BELOW the last row there is no
|
| 4739 |
+
pixel anywhere that means "file this at the top level".
|
| 4740 |
+
β IT SETS NO PAINT, DELIBERATELY. `.cg-fold-root.is-drop` immediately below is the hover state
|
| 4741 |
+
and must keep winning; declaring a background or outline here would fight it at equal
|
| 4742 |
+
specificity and the "you are about to drop here" tint would depend on rule order. The area is
|
| 4743 |
+
this rule's job, the picture is that one's.
|
| 4744 |
+
β COST, STATED: the "Shared with me" group (the only thing rendered after root) shifts down by
|
| 4745 |
+
this padding for the duration of a drag. That is the price of not reserving the space
|
| 4746 |
+
permanently, and a transient shift under the cursor reads as the target opening up. */
|
| 4747 |
+
.cg-fold-root.is-viewdrop {
|
| 4748 |
+
min-height: 30px;
|
| 4749 |
+
padding-bottom: 20px;
|
| 4750 |
+
}
|
| 4751 |
.cg-fold.is-drop, .cg-fold-root.is-drop {
|
| 4752 |
background: var(--lp-blue-tint);
|
| 4753 |
outline: 1px dashed var(--lp-blue-deep);
|
|
|
|
| 12269 |
auto` pushes it to the right edge so the badge lines up with the per-view badges directly
|
| 12270 |
below it β the total and its parts read as one column, which is the whole point of putting it
|
| 12271 |
here rather than in the shell's database header. */
|
| 12272 |
+
/* ββ WAVE 34 Β· T20 (R1, contract C1) β THE WORD IS GONE, SO ITS TYPOGRAPHY GOES WITH IT.
|
| 12273 |
+
`gap`, `font-size`, `font-weight` and `color` here only ever painted the literal "Important";
|
| 12274 |
+
with the label removed they style nothing (the badge sets its own), and left in place they are
|
| 12275 |
+
the kind of inert rule a later reader restores a label to fit. What survives is the LAYOUT this
|
| 12276 |
+
wrapper is actually for: `margin-left: auto` pushes it to the right edge so the total lines up
|
| 12277 |
+
with the per-view badges directly below it, and the class name itself is contract C1's hook
|
| 12278 |
+
(`.cg-views-important.is-partial` hangs the dashed-purple partial mark off it, and B's flyout
|
| 12279 |
+
row is named against it). */
|
| 12280 |
.cg-views-important {
|
| 12281 |
margin-left: auto;
|
| 12282 |
display: inline-flex;
|
| 12283 |
align-items: center;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12284 |
white-space: nowrap;
|
| 12285 |
}
|
| 12286 |
+
/* R1's "SMALLER", as a box rather than as type. β THE FONT CANNOT SHRINK AND THAT IS A REAL
|
| 12287 |
+
CONSTRAINT, NOT AN OMISSION: `--lp-fs-3xs` (11px) is the smallest size the scale declares, and
|
| 12288 |
+
`verify_ui`'s R5 leg reds ANY literal `font-size` in `web/src` CSS, so the only way to go below
|
| 12289 |
+
it is to add a fourth micro size to the type scale for one badge. 20x20 -> 16x16 is a 36% cut
|
| 12290 |
+
in area on top of losing the label, which is what the ruling asked for; inventing a 10px step
|
| 12291 |
+
under the scale's own floor is not. β SCOPED BY PARENT on purpose: `.cg-view-count` is shared
|
| 12292 |
+
with the per-view badges (and with `.is-unknown`), and shrinking it unscoped would resize every
|
| 12293 |
+
count in the rail from a ruling about the header. */
|
| 12294 |
+
.cg-views-important .cg-view-count {
|
| 12295 |
+
min-width: 16px;
|
| 12296 |
+
height: 16px;
|
| 12297 |
+
padding: 0 2px;
|
| 12298 |
+
}
|
| 12299 |
/* A PARTIAL total wears the same purple as a whole one β the number it shows IS real, it is just
|
| 12300 |
not all of them β and says so with a `+` and its title. Muting it would read as "less
|
| 12301 |
important" rather than "less complete". */
|
web/src/query/QueryPage.tsx
CHANGED
|
@@ -1,24 +1,41 @@
|
|
| 1 |
-
import { useCallback, useEffect, useMemo,
|
| 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 {
|
| 19 |
-
|
| 20 |
-
/**
|
| 21 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
/**
|
| 24 |
* Query is a database frame over a virtual artefact. Its public contract for B is the
|
|
@@ -28,43 +45,17 @@ export const VIEW_SELECT_RETRY_MS = [0, 250, 700, 1500, 2600, 5000, 9000, 14000,
|
|
| 28 |
* ββ OWNER ITEM 3 (2026-08-15) β THE RAIL IS THE NAVIGATION. See `queryParts.QueryRail`: the
|
| 29 |
* top-right dropdown is gone AND replaced, rather than gone and left as an implicit `views[0]`.
|
| 30 |
*/
|
| 31 |
-
export default function QueryPage({ granted }: QueryPageProps) {
|
| 32 |
const [views, setViews] = useState<SavedQuery[]>([]);
|
| 33 |
const [citations, setCitations] = useState<QueryCitation[]>([]);
|
| 34 |
const [activeId, setActiveId] = useState("");
|
| 35 |
const [loaded, setLoaded] = useState(false);
|
| 36 |
-
const emitCancel = useRef<(() => void) | null>(null);
|
| 37 |
const entries = databaseEntries(granted);
|
| 38 |
const entryOf = (key: string) => entries.find((entry) => entry.key === key);
|
| 39 |
const known = views.filter((view) => entryOf(view.source.database));
|
| 40 |
const active = known.find((view) => view.id === activeId) ?? known[0] ?? null;
|
| 41 |
|
| 42 |
-
|
| 43 |
-
* β GROUPED IN THE ORDER THE DATABASES ARE GRANTED, not by artefact age. The rail is a list of
|
| 44 |
-
* DATABASES first (owner: "each View correspond to the relevant database"), so a new answer
|
| 45 |
-
* about Customers must not move the Customers heading β it appears under it. Within a database
|
| 46 |
-
* the newest is first, which is the order the server already sends.
|
| 47 |
-
*/
|
| 48 |
-
const groups = useMemo<QueryGroup[]>(() => {
|
| 49 |
-
const byDatabase = new Map<string, SavedQuery[]>();
|
| 50 |
-
for (const view of known) {
|
| 51 |
-
const list = byDatabase.get(view.source.database);
|
| 52 |
-
if (list) list.push(view);
|
| 53 |
-
else byDatabase.set(view.source.database, [view]);
|
| 54 |
-
}
|
| 55 |
-
const out: QueryGroup[] = [];
|
| 56 |
-
for (const entry of entries) {
|
| 57 |
-
const list = byDatabase.get(entry.key);
|
| 58 |
-
if (!list || entry.kind === "group") continue;
|
| 59 |
-
out.push({
|
| 60 |
-
database: entry.key,
|
| 61 |
-
label: list[0].source.label || entry.label,
|
| 62 |
-
...(entry.icon ? { icon: entry.icon } : {}),
|
| 63 |
-
views: list,
|
| 64 |
-
});
|
| 65 |
-
}
|
| 66 |
-
return out;
|
| 67 |
-
}, [known, entries]);
|
| 68 |
|
| 69 |
const reload = useCallback(async () => {
|
| 70 |
const result = await fetchQueries();
|
|
@@ -77,18 +68,42 @@ export default function QueryPage({ granted }: QueryPageProps) {
|
|
| 77 |
|
| 78 |
useEffect(() => { void reload(); }, [reload]);
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
useEffect(() => {
|
| 81 |
if (known.length && !known.some((view) => view.id === activeId)) setActiveId(known[0].id);
|
| 82 |
}, [known, activeId]);
|
| 83 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
useEffect(() => {
|
| 85 |
-
if (!
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
}, [active, citations]);
|
| 92 |
|
| 93 |
useEffect(() => {
|
| 94 |
const onOpen = (event: Event) => {
|
|
@@ -105,6 +120,11 @@ export default function QueryPage({ granted }: QueryPageProps) {
|
|
| 105 |
return () => window.removeEventListener(QUERY_OPEN_EVENT, onOpen);
|
| 106 |
}, [views, reload]);
|
| 107 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
const remove = useCallback(async (qid: string) => {
|
| 109 |
const result = await deleteQuery(qid);
|
| 110 |
if (!result.ok) return;
|
|
@@ -122,12 +142,16 @@ export default function QueryPage({ granted }: QueryPageProps) {
|
|
| 122 |
<DbHead label={active.source.label} {...(entry?.icon ? { icon: entry.icon } : {})} />
|
| 123 |
<div className="shell-grid-host">
|
| 124 |
<div className="qy-workspace">
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
| 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)}
|
|
|
|
| 131 |
</OverlayProvider>
|
| 132 |
</div>
|
| 133 |
</div>
|
|
|
|
| 1 |
+
import { useCallback, useEffect, useMemo, useState } from "react";
|
| 2 |
|
| 3 |
import type { QueryOpenDetail } from "../apiContract";
|
| 4 |
import { QUERY_OPEN_EVENT } from "../apiContract";
|
| 5 |
import CustomerGrid from "../customer-grid/CustomerGrid";
|
| 6 |
import { OverlayProvider } from "../customer-grid/OverlaySurface";
|
| 7 |
import { queryCitationLabel } from "../customer-grid/queryPreview";
|
|
|
|
| 8 |
import { DbHead, gridScopeFor } from "../shell/dbFrame";
|
| 9 |
import { databaseEntries } from "../shell/nav";
|
| 10 |
import type { NavEntry } from "../shell/nav";
|
| 11 |
import { bindingFor, deleteQuery, fetchQueries, QUERY_BINDING_EVENT } from "./queryApi";
|
| 12 |
import type { QueryCitation, SavedQuery } from "./queryApi";
|
| 13 |
+
import { QueryEmpty, queryGroups, QueryProvenance, QueryRail } from "./queryParts";
|
| 14 |
import type { QueryGroup } from "./queryParts";
|
| 15 |
import "./query.css";
|
| 16 |
|
| 17 |
+
export interface QueryPageProps {
|
| 18 |
+
granted: NavEntry[];
|
| 19 |
+
/**
|
| 20 |
+
* β R14 β the merged Assistant surface hosts the view list in its OWN left panel, so Query
|
| 21 |
+
* renders no rail here. It is a flag rather than a second component because the SELECTION
|
| 22 |
+
* contract does not change: the host emits `QUERY_OPEN_EVENT` through `openBuiltView`, which is
|
| 23 |
+
* the same public qid hand-off an external link uses, and the listener below answers both.
|
| 24 |
+
*/
|
| 25 |
+
hostedRail?: boolean;
|
| 26 |
+
/** Bumped by the host after it deletes a view, so the two lists cannot drift apart. */
|
| 27 |
+
refreshToken?: number;
|
| 28 |
+
/**
|
| 29 |
+
* β THE HOST'S SELECTION, AND IT IS A GUARANTEE THE EVENT CANNOT GIVE. `QUERY_OPEN_EVENT` stays
|
| 30 |
+
* the published qid contract and still answers external callers, but it is a RACE: `retryEmit`'s
|
| 31 |
+
* default ladder tops out at 2,600 ms, tuned for a click into a grid that is usually warm, while
|
| 32 |
+
* the hosted workspace is a LAZY chunk over a cold grid that CLAUDE.md measures painting at
|
| 33 |
+
* ~12 s. Every dispatch lands before the listener exists, none is acked, and the rail then
|
| 34 |
+
* highlights a view the main column is not showing, 200 OK and silent. A prop arrives in the
|
| 35 |
+
* same commit as the mount.
|
| 36 |
+
*/
|
| 37 |
+
selectedId?: string;
|
| 38 |
+
}
|
| 39 |
|
| 40 |
/**
|
| 41 |
* Query is a database frame over a virtual artefact. Its public contract for B is the
|
|
|
|
| 45 |
* ββ OWNER ITEM 3 (2026-08-15) β THE RAIL IS THE NAVIGATION. See `queryParts.QueryRail`: the
|
| 46 |
* top-right dropdown is gone AND replaced, rather than gone and left as an implicit `views[0]`.
|
| 47 |
*/
|
| 48 |
+
export default function QueryPage({ granted, hostedRail, refreshToken, selectedId }: QueryPageProps) {
|
| 49 |
const [views, setViews] = useState<SavedQuery[]>([]);
|
| 50 |
const [citations, setCitations] = useState<QueryCitation[]>([]);
|
| 51 |
const [activeId, setActiveId] = useState("");
|
| 52 |
const [loaded, setLoaded] = useState(false);
|
|
|
|
| 53 |
const entries = databaseEntries(granted);
|
| 54 |
const entryOf = (key: string) => entries.find((entry) => entry.key === key);
|
| 55 |
const known = views.filter((view) => entryOf(view.source.database));
|
| 56 |
const active = known.find((view) => view.id === activeId) ?? known[0] ?? null;
|
| 57 |
|
| 58 |
+
const groups = useMemo<QueryGroup[]>(() => queryGroups(views, entries), [views, entries]);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
|
| 60 |
const reload = useCallback(async () => {
|
| 61 |
const result = await fetchQueries();
|
|
|
|
| 68 |
|
| 69 |
useEffect(() => { void reload(); }, [reload]);
|
| 70 |
|
| 71 |
+
/** The host owns the rail in the merged surface; a delete there must not leave this list stale. */
|
| 72 |
+
useEffect(() => { if (refreshToken) void reload(); }, [refreshToken, reload]);
|
| 73 |
+
|
| 74 |
+
/**
|
| 75 |
+
* Set BEFORE the fetch lands, deliberately: `known` is empty until then, so the fallback below
|
| 76 |
+
* does not fire, and by the time it can the id it would replace is already the right one.
|
| 77 |
+
*/
|
| 78 |
+
useEffect(() => { if (selectedId) setActiveId(selectedId); }, [selectedId]);
|
| 79 |
+
|
| 80 |
useEffect(() => {
|
| 81 |
if (known.length && !known.some((view) => view.id === activeId)) setActiveId(known[0].id);
|
| 82 |
}, [known, activeId]);
|
| 83 |
|
| 84 |
+
/**
|
| 85 |
+
* ββ THE BINDING GOES DOWN AS A PROP, AND THAT IS NOT A TIDY-UP.
|
| 86 |
+
* `CustomerGrid` registers its `QUERY_BINDING_EVENT` listener only when `isQueryPreviewRoute()`
|
| 87 |
+
* is true, i.e. only while the hash starts `#/query`. R14 moves this surface INSIDE
|
| 88 |
+
* `#/assistant`, where that predicate is false β so on the event path alone the grid would
|
| 89 |
+
* receive no binding, `isQueryPreview` would be false, `previewReadOnly` with it, and the
|
| 90 |
+
* merged surface would render a fully EDITABLE database grid over an immutable AI artefact,
|
| 91 |
+
* 200 OK, with the immutability contract intact on paper. Every WRITE guard in the grid keys on
|
| 92 |
+
* `queryBinding`; only the listener keys on the route. A prop is gated on neither.
|
| 93 |
+
*
|
| 94 |
+
* β Memoised because `CustomerGrid`'s preview effect lists `queryBinding` in its deps: a fresh
|
| 95 |
+
* object per render re-enters it on every unrelated state change on this page.
|
| 96 |
+
*/
|
| 97 |
+
const binding = useMemo(
|
| 98 |
+
() => (active ? bindingFor(active, citations) : undefined), [active, citations]);
|
| 99 |
+
|
| 100 |
useEffect(() => {
|
| 101 |
+
if (!binding) return;
|
| 102 |
+
// Still published for B, which is what `QUERY_BINDING_EVENT` is: one dispatch, not a ladder.
|
| 103 |
+
// The 21 s retry ladder that used to be here existed for ONE failure β the grid mounting
|
| 104 |
+
// after the event was fired β and a prop delivered in the same commit cannot have it.
|
| 105 |
+
window.dispatchEvent(new CustomEvent(QUERY_BINDING_EVENT, { detail: binding }));
|
| 106 |
+
}, [binding]);
|
|
|
|
| 107 |
|
| 108 |
useEffect(() => {
|
| 109 |
const onOpen = (event: Event) => {
|
|
|
|
| 120 |
return () => window.removeEventListener(QUERY_OPEN_EVENT, onOpen);
|
| 121 |
}, [views, reload]);
|
| 122 |
|
| 123 |
+
/** A renamed or duplicated artefact, merged in place: the rail owns the call, the page the list. */
|
| 124 |
+
const upsert = useCallback((view: SavedQuery) => {
|
| 125 |
+
setViews((current) => [view, ...current.filter((row) => row.id !== view.id)]);
|
| 126 |
+
}, []);
|
| 127 |
+
|
| 128 |
const remove = useCallback(async (qid: string) => {
|
| 129 |
const result = await deleteQuery(qid);
|
| 130 |
if (!result.ok) return;
|
|
|
|
| 142 |
<DbHead label={active.source.label} {...(entry?.icon ? { icon: entry.icon } : {})} />
|
| 143 |
<div className="shell-grid-host">
|
| 144 |
<div className="qy-workspace">
|
| 145 |
+
{hostedRail ? null : (
|
| 146 |
+
<QueryRail groups={groups} activeId={active.id} onSelect={setActiveId}
|
| 147 |
+
onDelete={(id) => void remove(id)} onChanged={upsert} />
|
| 148 |
+
)}
|
| 149 |
<div className="qy-surface">
|
| 150 |
<QueryProvenance view={active} detail={provenance} />
|
| 151 |
<div className="qy-grid-host">
|
| 152 |
<OverlayProvider>
|
| 153 |
+
<CustomerGrid key={gridScopeFor(active.source.database)}
|
| 154 |
+
scope={gridScopeFor(active.source.database)} queryBinding={binding} />
|
| 155 |
</OverlayProvider>
|
| 156 |
</div>
|
| 157 |
</div>
|
web/src/query/query.css
CHANGED
|
@@ -64,6 +64,114 @@
|
|
| 64 |
|
| 65 |
.qy-rail-group { margin-bottom: 10px; }
|
| 66 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
.qy-rail-db {
|
| 68 |
display: flex;
|
| 69 |
align-items: center;
|
|
@@ -103,6 +211,9 @@
|
|
| 103 |
.qy-rail-row {
|
| 104 |
position: relative;
|
| 105 |
display: flex;
|
|
|
|
|
|
|
|
|
|
| 106 |
align-items: center;
|
| 107 |
min-width: 0;
|
| 108 |
border-radius: var(--lp-r-md);
|
|
|
|
| 64 |
|
| 65 |
.qy-rail-group { margin-bottom: 10px; }
|
| 66 |
|
| 67 |
+
/* ββ R20: the per-view menu, the same vocabulary a database view offers βββββ */
|
| 68 |
+
|
| 69 |
+
.qy-rail-more {
|
| 70 |
+
flex: 0 0 auto;
|
| 71 |
+
width: 24px;
|
| 72 |
+
height: 24px;
|
| 73 |
+
margin-right: 4px;
|
| 74 |
+
display: inline-flex;
|
| 75 |
+
align-items: center;
|
| 76 |
+
justify-content: center;
|
| 77 |
+
border: 0;
|
| 78 |
+
border-radius: 6px;
|
| 79 |
+
background: transparent;
|
| 80 |
+
color: var(--lp-muted);
|
| 81 |
+
opacity: 0;
|
| 82 |
+
cursor: pointer;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
.qy-rail-more svg { fill: currentColor; stroke: none; }
|
| 86 |
+
|
| 87 |
+
.qy-rail-row:hover .qy-rail-more,
|
| 88 |
+
.qy-rail-row.is-active .qy-rail-more,
|
| 89 |
+
.qy-rail-more:focus-visible,
|
| 90 |
+
.qy-rail-more[aria-expanded="true"] { opacity: 1; }
|
| 91 |
+
|
| 92 |
+
/* Sits above the rows, under the menu, so a click anywhere else closes it without every row
|
| 93 |
+
needing its own outside-click listener. */
|
| 94 |
+
.qy-rail-scrim {
|
| 95 |
+
position: fixed;
|
| 96 |
+
inset: 0;
|
| 97 |
+
z-index: 20;
|
| 98 |
+
border: 0;
|
| 99 |
+
padding: 0;
|
| 100 |
+
background: transparent;
|
| 101 |
+
cursor: default;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
/* FIXED, not absolute: its natural parent `.qy-rail-scroll` is `overflow-y: auto`, which clips
|
| 105 |
+
both axes, so a menu opened on a row near the bottom of the list would render cut off or
|
| 106 |
+
invisible. `top`/`right` are supplied inline from the button's own rect. */
|
| 107 |
+
.qy-rail-menu {
|
| 108 |
+
position: fixed;
|
| 109 |
+
z-index: 21;
|
| 110 |
+
min-width: 168px;
|
| 111 |
+
margin-top: 2px;
|
| 112 |
+
padding: 4px;
|
| 113 |
+
display: flex;
|
| 114 |
+
flex-direction: column;
|
| 115 |
+
border: 1px solid var(--lp-line);
|
| 116 |
+
border-radius: var(--lp-r-md);
|
| 117 |
+
background: var(--lp-surface);
|
| 118 |
+
box-shadow: 0 6px 18px rgba(20, 24, 40, 0.12);
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
.qy-rail-item {
|
| 122 |
+
display: block;
|
| 123 |
+
padding: 6px 10px;
|
| 124 |
+
border: 0;
|
| 125 |
+
border-radius: 6px;
|
| 126 |
+
background: transparent;
|
| 127 |
+
color: var(--lp-ink);
|
| 128 |
+
font: inherit;
|
| 129 |
+
font-size: var(--lp-fs-2xs);
|
| 130 |
+
text-align: left;
|
| 131 |
+
text-decoration: none;
|
| 132 |
+
cursor: pointer;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.qy-rail-item:hover { background: var(--lp-surface-2); }
|
| 136 |
+
.qy-rail-item.is-danger { color: var(--lp-red-deep); }
|
| 137 |
+
|
| 138 |
+
.qy-rail-edit { flex: 1 1 auto; min-width: 0; padding: 2px 4px; }
|
| 139 |
+
|
| 140 |
+
.qy-rail-input {
|
| 141 |
+
width: 100%;
|
| 142 |
+
box-sizing: border-box;
|
| 143 |
+
padding: 5px 8px;
|
| 144 |
+
border: 1px solid var(--lp-blue-solid);
|
| 145 |
+
border-radius: 6px;
|
| 146 |
+
background: var(--lp-surface);
|
| 147 |
+
color: var(--lp-ink);
|
| 148 |
+
font: inherit;
|
| 149 |
+
font-size: var(--lp-fs-2xs);
|
| 150 |
+
}
|
| 151 |
+
|
| 152 |
+
.qy-rail-note {
|
| 153 |
+
flex: 1 0 100%;
|
| 154 |
+
margin: 0 0 4px;
|
| 155 |
+
padding: 0 10px;
|
| 156 |
+
color: var(--lp-muted);
|
| 157 |
+
font-size: var(--lp-fs-2xs);
|
| 158 |
+
line-height: var(--lp-lh);
|
| 159 |
+
}
|
| 160 |
+
|
| 161 |
+
.qy-rail-problem {
|
| 162 |
+
margin: 0 6px 6px;
|
| 163 |
+
color: var(--lp-red-deep);
|
| 164 |
+
font-size: var(--lp-fs-2xs);
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/* A confirmation, not a refusal. Same slot, different voice. */
|
| 168 |
+
.qy-rail-notice {
|
| 169 |
+
margin: 0 6px 6px;
|
| 170 |
+
color: var(--lp-muted);
|
| 171 |
+
font-size: var(--lp-fs-2xs);
|
| 172 |
+
line-height: var(--lp-lh);
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
.qy-rail-db {
|
| 176 |
display: flex;
|
| 177 |
align-items: center;
|
|
|
|
| 211 |
.qy-rail-row {
|
| 212 |
position: relative;
|
| 213 |
display: flex;
|
| 214 |
+
/* R20's description sits on its own line under the name, so the row wraps. `position: relative`
|
| 215 |
+
was already here and is what the menu anchors to. */
|
| 216 |
+
flex-wrap: wrap;
|
| 217 |
align-items: center;
|
| 218 |
min-width: 0;
|
| 219 |
border-radius: var(--lp-r-md);
|
web/src/query/queryApi.ts
CHANGED
|
@@ -35,6 +35,8 @@ export interface SavedQuery {
|
|
| 35 |
viewId: string;
|
| 36 |
scope: string;
|
| 37 |
name: string;
|
|
|
|
|
|
|
| 38 |
kind: string;
|
| 39 |
question: string;
|
| 40 |
explain: string;
|
|
@@ -71,6 +73,9 @@ export interface QueryMessage {
|
|
| 71 |
viewId?: string | null;
|
| 72 |
citationIds?: string[];
|
| 73 |
numeric?: SavedQuery["numeric"] | null;
|
|
|
|
|
|
|
|
|
|
| 74 |
}
|
| 75 |
|
| 76 |
/**
|
|
@@ -88,12 +93,26 @@ export interface QuerySourceStatus {
|
|
| 88 |
reason: string;
|
| 89 |
}
|
| 90 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
export interface QueryIndex {
|
| 92 |
threads: QueryThread[];
|
| 93 |
messages: QueryMessage[];
|
| 94 |
views: SavedQuery[];
|
| 95 |
citations: QueryCitation[];
|
| 96 |
models: string[];
|
|
|
|
| 97 |
sources: QuerySourceStatus[];
|
| 98 |
}
|
| 99 |
|
|
@@ -229,9 +248,10 @@ async function call<T>(path: string, init: RequestInit, read: (body: unknown) =>
|
|
| 229 |
return { ok: true, value: read(body) };
|
| 230 |
}
|
| 231 |
|
| 232 |
-
const
|
| 233 |
-
method
|
| 234 |
});
|
|
|
|
| 235 |
const str = (value: unknown): string => (typeof value === "string" ? value : "");
|
| 236 |
const rows = (value: unknown): Record<string, unknown>[] =>
|
| 237 |
Array.isArray(value) ? value.filter((row): row is Record<string, unknown> => !!row && typeof row === "object") : [];
|
|
@@ -255,7 +275,7 @@ function saved(value: unknown): SavedQuery | null {
|
|
| 255 |
if (!row || !src || !str(row.id) || !str(row.viewId) || !str(row.threadId) || row.virtual !== true) return null;
|
| 256 |
return {
|
| 257 |
id: str(row.id), viewId: str(row.viewId), scope: str(row.scope) || src.database,
|
| 258 |
-
name: str(row.name) || "Query", kind: str(row.kind) || "grid",
|
| 259 |
question: str(row.question), explain: str(row.explain), threadId: str(row.threadId),
|
| 260 |
createdAt: str(row.createdAt), virtual: true, source: src,
|
| 261 |
view: (row.view && typeof row.view === "object" ? row.view : {}) as Record<string, unknown>,
|
|
@@ -276,6 +296,8 @@ function message(value: unknown): QueryMessage | null {
|
|
| 276 |
reason: str(row.reason) || null, viewId: str(row.viewId) || null,
|
| 277 |
citationIds: (row.citationIds as unknown[] || []).filter((v): v is string => typeof v === "string"),
|
| 278 |
numeric: (row.numeric && typeof row.numeric === "object" ? row.numeric : null) as QueryMessage["numeric"],
|
|
|
|
|
|
|
| 279 |
};
|
| 280 |
}
|
| 281 |
|
|
@@ -307,6 +329,13 @@ export function parseIndex(body: unknown): QueryIndex {
|
|
| 307 |
views: rows(row?.views).map(saved).filter((v): v is SavedQuery => !!v),
|
| 308 |
citations: rows(row?.citations).map(citation).filter((v): v is QueryCitation => !!v),
|
| 309 |
models: (row?.models as unknown[] || []).filter((v): v is string => typeof v === "string"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
sources: rows(row?.sources).filter((item) => !!str(item.database)).map((item) => ({
|
| 311 |
database: str(item.database), answerable: item.answerable === true, reason: str(item.reason),
|
| 312 |
})),
|
|
@@ -341,6 +370,70 @@ export function deleteThread(id: string): Promise<Result<true>> {
|
|
| 341 |
return call(`/query/threads/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const);
|
| 342 |
}
|
| 343 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 344 |
/**
|
| 345 |
* The sole mutation path for a virtual Query workspace. This intentionally never falls through
|
| 346 |
* to the native grid event transport and never sends `dataScope`: the opaque Query key is the authority.
|
|
|
|
| 35 |
viewId: string;
|
| 36 |
scope: string;
|
| 37 |
name: string;
|
| 38 |
+
/** R20: a label the reader owns. The SPEC beside it stays immutable, which is the whole split. */
|
| 39 |
+
description: string;
|
| 40 |
kind: string;
|
| 41 |
question: string;
|
| 42 |
explain: string;
|
|
|
|
| 73 |
viewId?: string | null;
|
| 74 |
citationIds?: string[];
|
| 75 |
numeric?: SavedQuery["numeric"] | null;
|
| 76 |
+
/** R20's thumbs. Read by the server's own replay, not only written. */
|
| 77 |
+
rating?: "up" | "down" | null;
|
| 78 |
+
ratingReason?: string;
|
| 79 |
}
|
| 80 |
|
| 81 |
/**
|
|
|
|
| 93 |
reason: string;
|
| 94 |
}
|
| 95 |
|
| 96 |
+
/**
|
| 97 |
+
* A model this deployment OFFERS, and whether it can actually be called.
|
| 98 |
+
*
|
| 99 |
+
* β The same shape `QuerySourceStatus` uses, for the same reason one control to the left: a
|
| 100 |
+
* picker that lists a model whose key is not set spends a click and a turn to be told
|
| 101 |
+
* "unavailable" by a server that knew before the click.
|
| 102 |
+
*/
|
| 103 |
+
export interface QueryModelStatus {
|
| 104 |
+
model: string;
|
| 105 |
+
available: boolean;
|
| 106 |
+
reason: string;
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
export interface QueryIndex {
|
| 110 |
threads: QueryThread[];
|
| 111 |
messages: QueryMessage[];
|
| 112 |
views: SavedQuery[];
|
| 113 |
citations: QueryCitation[];
|
| 114 |
models: string[];
|
| 115 |
+
modelStatus: QueryModelStatus[];
|
| 116 |
sources: QuerySourceStatus[];
|
| 117 |
}
|
| 118 |
|
|
|
|
| 248 |
return { ok: true, value: read(body) };
|
| 249 |
}
|
| 250 |
|
| 251 |
+
const sends = (method: string, data: unknown): RequestInit => ({
|
| 252 |
+
method, headers: { "Content-Type": "application/json" }, body: JSON.stringify(data),
|
| 253 |
});
|
| 254 |
+
const json = (data: unknown): RequestInit => sends("POST", data);
|
| 255 |
const str = (value: unknown): string => (typeof value === "string" ? value : "");
|
| 256 |
const rows = (value: unknown): Record<string, unknown>[] =>
|
| 257 |
Array.isArray(value) ? value.filter((row): row is Record<string, unknown> => !!row && typeof row === "object") : [];
|
|
|
|
| 275 |
if (!row || !src || !str(row.id) || !str(row.viewId) || !str(row.threadId) || row.virtual !== true) return null;
|
| 276 |
return {
|
| 277 |
id: str(row.id), viewId: str(row.viewId), scope: str(row.scope) || src.database,
|
| 278 |
+
name: str(row.name) || "Query", description: str(row.description), kind: str(row.kind) || "grid",
|
| 279 |
question: str(row.question), explain: str(row.explain), threadId: str(row.threadId),
|
| 280 |
createdAt: str(row.createdAt), virtual: true, source: src,
|
| 281 |
view: (row.view && typeof row.view === "object" ? row.view : {}) as Record<string, unknown>,
|
|
|
|
| 296 |
reason: str(row.reason) || null, viewId: str(row.viewId) || null,
|
| 297 |
citationIds: (row.citationIds as unknown[] || []).filter((v): v is string => typeof v === "string"),
|
| 298 |
numeric: (row.numeric && typeof row.numeric === "object" ? row.numeric : null) as QueryMessage["numeric"],
|
| 299 |
+
rating: row.rating === "up" || row.rating === "down" ? row.rating : null,
|
| 300 |
+
ratingReason: str(row.ratingReason),
|
| 301 |
};
|
| 302 |
}
|
| 303 |
|
|
|
|
| 329 |
views: rows(row?.views).map(saved).filter((v): v is SavedQuery => !!v),
|
| 330 |
citations: rows(row?.citations).map(citation).filter((v): v is QueryCitation => !!v),
|
| 331 |
models: (row?.models as unknown[] || []).filter((v): v is string => typeof v === "string"),
|
| 332 |
+
// β ABSENT β TREATED AS AVAILABLE, the same way an absent source row is treated as answerable:
|
| 333 |
+
// the server is the authority and its refusal is still the wall. A client that greyed out
|
| 334 |
+
// every model it had not heard about would disable the whole picker the moment this field
|
| 335 |
+
// failed to arrive.
|
| 336 |
+
modelStatus: rows(row?.modelStatus).filter((item) => !!str(item.model)).map((item) => ({
|
| 337 |
+
model: str(item.model), available: item.available !== false, reason: str(item.reason),
|
| 338 |
+
})),
|
| 339 |
sources: rows(row?.sources).filter((item) => !!str(item.database)).map((item) => ({
|
| 340 |
database: str(item.database), answerable: item.answerable === true, reason: str(item.reason),
|
| 341 |
})),
|
|
|
|
| 370 |
return call(`/query/threads/${encodeURIComponent(id)}`, { method: "DELETE" }, () => true as const);
|
| 371 |
}
|
| 372 |
|
| 373 |
+
/**
|
| 374 |
+
* ββ R20 β THE THREE DOORS THAT ARE **NOT** `mutateQueryWorkspace`, and the distinction is the
|
| 375 |
+
* whole design rather than a routing detail.
|
| 376 |
+
*
|
| 377 |
+
* `QUERY_MUTATION_POLICY` above is unchanged and still refuses create and update: the generated
|
| 378 |
+
* SPEC and its provenance are immutable, because a cited number is only worth citing if the thing
|
| 379 |
+
* it was computed from cannot be edited underneath it. What R20 opens is the LABELS beside it,
|
| 380 |
+
* and a copy, and a read. Each gets its own named endpoint with a server-side allow-list, because
|
| 381 |
+
* `routeQueryViewMutation` refuses `view_upsert` on a query binding LOCALLY, before any request
|
| 382 |
+
* leaves the browser: opening that transport would give one question two answers.
|
| 383 |
+
*/
|
| 384 |
+
export function renameQuery(id: string, patch: { name?: string; description?: string }): Promise<Result<SavedQuery>> {
|
| 385 |
+
return call(`/query/${encodeURIComponent(id)}`, sends("PATCH", patch),
|
| 386 |
+
(raw) => saved(raw) as SavedQuery);
|
| 387 |
+
}
|
| 388 |
+
|
| 389 |
+
export function duplicateQuery(id: string): Promise<Result<SavedQuery>> {
|
| 390 |
+
return call(`/query/${encodeURIComponent(id)}/duplicate`, json({}), (raw) => saved(raw) as SavedQuery);
|
| 391 |
+
}
|
| 392 |
+
|
| 393 |
+
/**
|
| 394 |
+
* A DOWNLOAD, not a fetch: an ordinary same-origin link carries the session cookie, and the server
|
| 395 |
+
* names the file through `Content-Disposition`. Building a Blob here would mean holding the whole
|
| 396 |
+
* export in memory to hand it straight back to the browser.
|
| 397 |
+
*/
|
| 398 |
+
export function exportQueryHref(id: string): string {
|
| 399 |
+
return `${API_V1}/query/${encodeURIComponent(id)}/export`;
|
| 400 |
+
}
|
| 401 |
+
|
| 402 |
+
/**
|
| 403 |
+
* β R20's thumbs. `null` CLEARS the rating: a mis-click that cannot be taken back is worse than
|
| 404 |
+
* no control, and the server reads a thumbs-down back into the next turn of the same thread, so
|
| 405 |
+
* an uncorrected one keeps steering.
|
| 406 |
+
*/
|
| 407 |
+
/** What the database's own validator would not take, named rather than quietly missing. */
|
| 408 |
+
export interface QuerySaveResult {
|
| 409 |
+
scope: string;
|
| 410 |
+
viewId: string;
|
| 411 |
+
name: string;
|
| 412 |
+
dropped: string[];
|
| 413 |
+
}
|
| 414 |
+
|
| 415 |
+
/**
|
| 416 |
+
* β R20: the answer becomes an ordinary view of its own database.
|
| 417 |
+
*
|
| 418 |
+
* β The server does NOT trust the write's own 200: `view_upsert` returns the same shape when it
|
| 419 |
+
* saved and when it silently refused, so it reads the store back and refuses out loud instead.
|
| 420 |
+
* There is nothing for this client to retry, which is the point.
|
| 421 |
+
*/
|
| 422 |
+
export function saveQueryToDatabase(id: string): Promise<Result<QuerySaveResult>> {
|
| 423 |
+
return call(`/query/${encodeURIComponent(id)}/save-to-database`, json({}), (raw) => {
|
| 424 |
+
const row = raw as Record<string, unknown> | null;
|
| 425 |
+
return {
|
| 426 |
+
scope: str(row?.scope), viewId: str(row?.viewId), name: str(row?.name),
|
| 427 |
+
dropped: (row?.dropped as unknown[] || []).filter((v): v is string => typeof v === "string"),
|
| 428 |
+
};
|
| 429 |
+
});
|
| 430 |
+
}
|
| 431 |
+
|
| 432 |
+
export function rateMessage(id: string, rating: "up" | "down" | null, reason?: string): Promise<Result<QueryMessage>> {
|
| 433 |
+
return call(`/query/messages/${encodeURIComponent(id)}/rating`,
|
| 434 |
+
json({ rating, ...(reason ? { reason } : {}) }), (raw) => message(raw) as QueryMessage);
|
| 435 |
+
}
|
| 436 |
+
|
| 437 |
/**
|
| 438 |
* The sole mutation path for a virtual Query workspace. This intentionally never falls through
|
| 439 |
* to the native grid event transport and never sends `dataScope`: the opaque Query key is the authority.
|
web/src/query/queryParts.tsx
CHANGED
|
@@ -4,8 +4,33 @@ import { FolderMark } from "../customer-grid/icons";
|
|
| 4 |
import { DbIcon } from "../shell/dbFrame";
|
| 5 |
import { ASSISTANT_ROUTE } from "../shell/nav";
|
| 6 |
import type { NavEntry } from "../shell/nav";
|
|
|
|
| 7 |
import type { SavedQuery } from "./queryApi";
|
| 8 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
/** Query has no top-right view dropdown: the rail below IS the navigation (owner item 3). */
|
| 10 |
export function QueryEmpty({ loaded, unnamed = 0 }: { loaded: boolean; unnamed?: number }) {
|
| 11 |
return (
|
|
@@ -39,6 +64,43 @@ export interface QueryGroup {
|
|
| 39 |
views: SavedQuery[];
|
| 40 |
}
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
/**
|
| 43 |
* ββ OWNER ITEM 3 (2026-08-15) β GROUPED BY SOURCE DATABASE, WHICH IS THE WHOLE INSTRUCTION.
|
| 44 |
* Verbatim: *"these AI generated query should live under the query module. And that each View
|
|
@@ -59,18 +121,75 @@ export interface QueryGroup {
|
|
| 59 |
* because THIS list spans several databases and the grid's rail, by construction, cannot: the
|
| 60 |
* grid is mounted at one scope.
|
| 61 |
*/
|
| 62 |
-
export function QueryRail({ groups, activeId, onSelect, onDelete }: {
|
| 63 |
groups: QueryGroup[];
|
| 64 |
activeId: string;
|
| 65 |
onSelect: (id: string) => void;
|
| 66 |
onDelete: (id: string) => void;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
}) {
|
| 68 |
// Two-click delete, in the row. A modal for "remove one of my own saved answers" is heavier
|
| 69 |
// than the act; a single click with no confirm loses work to a mis-click on a hover control.
|
| 70 |
const [confirming, setConfirming] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
return (
|
| 72 |
<aside className="qy-rail" aria-label="Query views">
|
| 73 |
<p className="qy-rail-head">Views</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
<div className="qy-rail-scroll">
|
| 75 |
{groups.map((group) => (
|
| 76 |
<section className="qy-rail-group" key={group.database}>
|
|
@@ -82,29 +201,83 @@ export function QueryRail({ groups, activeId, onSelect, onDelete }: {
|
|
| 82 |
</p>
|
| 83 |
{group.views.map((view) => (
|
| 84 |
<div className={"qy-rail-row" + (view.id === activeId ? " is-active" : "")} key={view.id}>
|
| 85 |
-
|
| 86 |
-
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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-
|
| 102 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
<svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13">
|
| 104 |
-
<
|
|
|
|
| 105 |
</svg>
|
| 106 |
</button>
|
| 107 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 108 |
</div>
|
| 109 |
))}
|
| 110 |
</section>
|
|
|
|
| 4 |
import { DbIcon } from "../shell/dbFrame";
|
| 5 |
import { ASSISTANT_ROUTE } from "../shell/nav";
|
| 6 |
import type { NavEntry } from "../shell/nav";
|
| 7 |
+
import { duplicateQuery, exportQueryHref, renameQuery, saveQueryToDatabase } from "./queryApi";
|
| 8 |
import type { SavedQuery } from "./queryApi";
|
| 9 |
|
| 10 |
+
/** Which label of which row is being typed into. One at a time, by construction. */
|
| 11 |
+
interface Editing { id: string; field: "name" | "description"; value: string; }
|
| 12 |
+
|
| 13 |
+
/**
|
| 14 |
+
* β THE MENU IS `position: fixed`, AND THAT IS NOT A STYLE PREFERENCE. Its natural home,
|
| 15 |
+
* `.qy-rail-scroll`, is `overflow-y: auto`, which clips BOTH axes: an absolutely positioned menu
|
| 16 |
+
* on a row near the bottom of the list renders cut off or not at all, so the reader clicks and
|
| 17 |
+
* nothing appears. It fails worst on the rows most likely to be used, the ones you scrolled to.
|
| 18 |
+
* Fixed positioning takes it out of the scroll box entirely, which costs one measurement.
|
| 19 |
+
*
|
| 20 |
+
* β `MENU_H` is an estimate used ONLY to decide whether to flip upward near the viewport floor. It
|
| 21 |
+
* does not size anything, so being a little wrong shifts the menu rather than breaking it.
|
| 22 |
+
*/
|
| 23 |
+
const MENU_H = 190;
|
| 24 |
+
|
| 25 |
+
function menuAnchor(button: HTMLElement): { top: number; right: number } {
|
| 26 |
+
const rect = button.getBoundingClientRect();
|
| 27 |
+
const below = rect.bottom + 4;
|
| 28 |
+
return {
|
| 29 |
+
top: below + MENU_H > window.innerHeight ? Math.max(8, rect.top - MENU_H - 4) : below,
|
| 30 |
+
right: Math.max(8, window.innerWidth - rect.right),
|
| 31 |
+
};
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
/** Query has no top-right view dropdown: the rail below IS the navigation (owner item 3). */
|
| 35 |
export function QueryEmpty({ loaded, unnamed = 0 }: { loaded: boolean; unnamed?: number }) {
|
| 36 |
return (
|
|
|
|
| 64 |
views: SavedQuery[];
|
| 65 |
}
|
| 66 |
|
| 67 |
+
/**
|
| 68 |
+
* β GROUPED IN THE ORDER THE DATABASES ARE GRANTED, not by artefact age. The rail is a list of
|
| 69 |
+
* DATABASES first (owner: "each View correspond to the relevant database"), so a new answer about
|
| 70 |
+
* Customers must not move the Customers heading β it appears under it. Within a database the
|
| 71 |
+
* newest is first, which is the order the server already sends.
|
| 72 |
+
*
|
| 73 |
+
* β R14 GAVE THIS LIST A SECOND HOST, which is why the loop is a function and lives HERE rather
|
| 74 |
+
* than inside `QueryPage`. The merged Assistant surface renders the same rail in its own left
|
| 75 |
+
* panel; two hosts computing "which views belong to which database" from two copies of one loop
|
| 76 |
+
* is one question with two normalizers, and the copy that drifts is the one nobody is looking at.
|
| 77 |
+
* It sits beside `QueryGroup`, the type it builds, and in a module that does NOT import the grid
|
| 78 |
+
* β a static import from `QueryPage.tsx` would pull `CustomerGrid` into the chat bundle and undo
|
| 79 |
+
* the lazy boundary the Assistant keeps around it.
|
| 80 |
+
*
|
| 81 |
+
* A view whose source database is not in `entries` is dropped: the caller's grant is the wall.
|
| 82 |
+
*/
|
| 83 |
+
export function queryGroups(views: SavedQuery[], entries: NavEntry[]): QueryGroup[] {
|
| 84 |
+
const byDatabase = new Map<string, SavedQuery[]>();
|
| 85 |
+
for (const view of views) {
|
| 86 |
+
const list = byDatabase.get(view.source.database);
|
| 87 |
+
if (list) list.push(view);
|
| 88 |
+
else byDatabase.set(view.source.database, [view]);
|
| 89 |
+
}
|
| 90 |
+
const out: QueryGroup[] = [];
|
| 91 |
+
for (const entry of entries) {
|
| 92 |
+
const list = byDatabase.get(entry.key);
|
| 93 |
+
if (!list || entry.kind === "group") continue;
|
| 94 |
+
out.push({
|
| 95 |
+
database: entry.key,
|
| 96 |
+
label: list[0].source.label || entry.label,
|
| 97 |
+
...(entry.icon ? { icon: entry.icon } : {}),
|
| 98 |
+
views: list,
|
| 99 |
+
});
|
| 100 |
+
}
|
| 101 |
+
return out;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
/**
|
| 105 |
* ββ OWNER ITEM 3 (2026-08-15) β GROUPED BY SOURCE DATABASE, WHICH IS THE WHOLE INSTRUCTION.
|
| 106 |
* Verbatim: *"these AI generated query should live under the query module. And that each View
|
|
|
|
| 121 |
* because THIS list spans several databases and the grid's rail, by construction, cannot: the
|
| 122 |
* grid is mounted at one scope.
|
| 123 |
*/
|
| 124 |
+
export function QueryRail({ groups, activeId, onSelect, onDelete, onChanged }: {
|
| 125 |
groups: QueryGroup[];
|
| 126 |
activeId: string;
|
| 127 |
onSelect: (id: string) => void;
|
| 128 |
onDelete: (id: string) => void;
|
| 129 |
+
/**
|
| 130 |
+
* β The rail OWNS the rename/duplicate calls and hands the host the resulting artefact to
|
| 131 |
+
* upsert. Two hosts render this list (the standalone page and the merged Assistant surface), so
|
| 132 |
+
* putting the calls in the hosts would be one question with two implementations, and the copy
|
| 133 |
+
* that drifts is the one nobody is looking at.
|
| 134 |
+
*/
|
| 135 |
+
onChanged?: (view: SavedQuery) => void;
|
| 136 |
}) {
|
| 137 |
// Two-click delete, in the row. A modal for "remove one of my own saved answers" is heavier
|
| 138 |
// than the act; a single click with no confirm loses work to a mis-click on a hover control.
|
| 139 |
const [confirming, setConfirming] = useState("");
|
| 140 |
+
const [menuFor, setMenuFor] = useState("");
|
| 141 |
+
const [anchor, setAnchor] = useState<{ top: number; right: number } | null>(null);
|
| 142 |
+
const [editing, setEditing] = useState<Editing | null>(null);
|
| 143 |
+
// β TWO STATES, NOT ONE. A refusal and a confirmation read differently and are coloured
|
| 144 |
+
// differently; sharing one slot painted "Saved as ..." in the refusal red.
|
| 145 |
+
const [problem, setProblem] = useState("");
|
| 146 |
+
const [notice, setNotice] = useState("");
|
| 147 |
+
const said = (message: string) => { setNotice(message); setProblem(""); };
|
| 148 |
+
const failed = (message: string) => { setProblem(message); setNotice(""); };
|
| 149 |
+
|
| 150 |
+
const commit = async () => {
|
| 151 |
+
if (!editing) return;
|
| 152 |
+
const current = editing;
|
| 153 |
+
setEditing(null);
|
| 154 |
+
const result = await renameQuery(current.id, current.field === "name"
|
| 155 |
+
? { name: current.value } : { description: current.value });
|
| 156 |
+
if (!result.ok) { failed(result.message); return; }
|
| 157 |
+
said("");
|
| 158 |
+
onChanged?.(result.value);
|
| 159 |
+
};
|
| 160 |
+
|
| 161 |
+
/**
|
| 162 |
+
* β THE REPLY IS SHOWN WHETHER OR NOT ANYTHING WAS DROPPED, and the dropped half is the useful
|
| 163 |
+
* one: a database view carries no aggregation, so "sum of deal value" lands as the rows and not
|
| 164 |
+
* the sum. Saying so beats a view that looks complete and answers a different question.
|
| 165 |
+
*/
|
| 166 |
+
const saveToDatabase = async (view: SavedQuery) => {
|
| 167 |
+
const result = await saveQueryToDatabase(view.id);
|
| 168 |
+
if (!result.ok) { failed(result.message); return; }
|
| 169 |
+
said(result.value.dropped.length
|
| 170 |
+
? `Saved as "${result.value.name}". Not carried over: ${result.value.dropped.join(", ")}.`
|
| 171 |
+
: `Saved as "${result.value.name}" in this database's own views.`);
|
| 172 |
+
};
|
| 173 |
+
|
| 174 |
+
const duplicate = async (id: string) => {
|
| 175 |
+
const result = await duplicateQuery(id);
|
| 176 |
+
// β The refusal is the useful half: MAX_ARTIFACTS names its cause and the remedy, and a copy
|
| 177 |
+
// that silently did not happen is the failure mode this whole module is written against.
|
| 178 |
+
if (!result.ok) { failed(result.message); return; }
|
| 179 |
+
said("");
|
| 180 |
+
onChanged?.(result.value);
|
| 181 |
+
onSelect(result.value.id);
|
| 182 |
+
};
|
| 183 |
+
|
| 184 |
return (
|
| 185 |
<aside className="qy-rail" aria-label="Query views">
|
| 186 |
<p className="qy-rail-head">Views</p>
|
| 187 |
+
{menuFor ? (
|
| 188 |
+
<button type="button" className="qy-rail-scrim" aria-label="Close menu"
|
| 189 |
+
onClick={() => setMenuFor("")} />
|
| 190 |
+
) : null}
|
| 191 |
+
{problem ? <p className="qy-rail-problem" role="status">{problem}</p> : null}
|
| 192 |
+
{notice ? <p className="qy-rail-notice" role="status">{notice}</p> : null}
|
| 193 |
<div className="qy-rail-scroll">
|
| 194 |
{groups.map((group) => (
|
| 195 |
<section className="qy-rail-group" key={group.database}>
|
|
|
|
| 201 |
</p>
|
| 202 |
{group.views.map((view) => (
|
| 203 |
<div className={"qy-rail-row" + (view.id === activeId ? " is-active" : "")} key={view.id}>
|
| 204 |
+
{editing && editing.id === view.id ? (
|
| 205 |
+
/* Inline, like the database sidebar's own rename. A window.prompt would block
|
| 206 |
+
every other event on the page and cannot carry a placeholder or a cancel. */
|
| 207 |
+
<form className="qy-rail-edit" onSubmit={(event) => { event.preventDefault(); void commit(); }}>
|
| 208 |
+
<input className="qy-rail-input" autoFocus value={editing.value}
|
| 209 |
+
aria-label={editing.field === "name" ? "View name" : "View description"}
|
| 210 |
+
placeholder={editing.field === "name" ? "Name" : "Description"}
|
| 211 |
+
onChange={(event) => setEditing({ ...editing, value: event.currentTarget.value })}
|
| 212 |
+
onBlur={() => void commit()}
|
| 213 |
+
onKeyDown={(event) => { if (event.key === "Escape") setEditing(null); }} />
|
| 214 |
+
</form>
|
| 215 |
+
) : (
|
| 216 |
+
<button
|
| 217 |
+
type="button"
|
| 218 |
+
className="qy-rail-view"
|
| 219 |
+
aria-current={view.id === activeId ? "true" : undefined}
|
| 220 |
+
onClick={() => { setConfirming(""); onSelect(view.id); }}
|
| 221 |
+
title={view.description || view.question}
|
| 222 |
+
>
|
| 223 |
+
<span className="qy-rail-name">{view.name}</span>
|
| 224 |
+
<span className="qy-rail-kind">{KIND_MARK[view.kind] ?? view.kind}</span>
|
| 225 |
+
</button>
|
| 226 |
+
)}
|
| 227 |
{confirming === view.id ? (
|
| 228 |
<button type="button" className="qy-rail-confirm"
|
| 229 |
onClick={() => { setConfirming(""); onDelete(view.id); }}>
|
| 230 |
Delete
|
| 231 |
</button>
|
| 232 |
) : (
|
| 233 |
+
<button type="button" className="qy-rail-more" aria-haspopup="menu"
|
| 234 |
+
aria-expanded={menuFor === view.id}
|
| 235 |
+
aria-label={`Actions for ${view.name}`}
|
| 236 |
+
onClick={(event) => {
|
| 237 |
+
if (menuFor === view.id) { setMenuFor(""); return; }
|
| 238 |
+
setAnchor(menuAnchor(event.currentTarget));
|
| 239 |
+
setMenuFor(view.id);
|
| 240 |
+
}}>
|
| 241 |
<svg viewBox="0 0 16 16" aria-hidden="true" width="13" height="13">
|
| 242 |
+
<circle cx="3.2" cy="8" r="1.1" /><circle cx="8" cy="8" r="1.1" />
|
| 243 |
+
<circle cx="12.8" cy="8" r="1.1" />
|
| 244 |
</svg>
|
| 245 |
</button>
|
| 246 |
)}
|
| 247 |
+
{menuFor === view.id ? (
|
| 248 |
+
/* R20: the same vocabulary a database view offers, minus the ones that mean
|
| 249 |
+
nothing here. Import writes rows; Share and Mark important belong to a view a
|
| 250 |
+
team navigates; the lock family describes a cohort. What is left is what a
|
| 251 |
+
person actually does with an answer they kept. */
|
| 252 |
+
<div className="qy-rail-menu" role="menu"
|
| 253 |
+
style={anchor ? { top: anchor.top, right: anchor.right } : undefined}>
|
| 254 |
+
{/* "Export", the database sidebar's own word. Theirs then opens a format
|
| 255 |
+
submenu; a submenu holding one row would be chrome for its own sake, so
|
| 256 |
+
this downloads the CSV the server names. */}
|
| 257 |
+
<a className="qy-rail-item" role="menuitem" href={exportQueryHref(view.id)}
|
| 258 |
+
download onClick={() => setMenuFor("")}>Export</a>
|
| 259 |
+
<button type="button" className="qy-rail-item" role="menuitem"
|
| 260 |
+
onClick={() => { setMenuFor(""); setEditing({ id: view.id, field: "name", value: view.name }); }}>
|
| 261 |
+
Rename
|
| 262 |
+
</button>
|
| 263 |
+
<button type="button" className="qy-rail-item" role="menuitem"
|
| 264 |
+
onClick={() => { setMenuFor(""); setEditing({ id: view.id, field: "description", value: view.description }); }}>
|
| 265 |
+
{view.description ? "Edit description" : "Add description"}
|
| 266 |
+
</button>
|
| 267 |
+
<button type="button" className="qy-rail-item" role="menuitem"
|
| 268 |
+
onClick={() => { setMenuFor(""); void duplicate(view.id); }}>Duplicate</button>
|
| 269 |
+
{/* R20's one difference from a database view's menu: this one can BECOME one. */}
|
| 270 |
+
<button type="button" className="qy-rail-item" role="menuitem"
|
| 271 |
+
onClick={() => { setMenuFor(""); void saveToDatabase(view); }}>
|
| 272 |
+
Save into the database
|
| 273 |
+
</button>
|
| 274 |
+
<button type="button" className="qy-rail-item is-danger" role="menuitem"
|
| 275 |
+
onClick={() => { setMenuFor(""); setConfirming(view.id); }}>Delete</button>
|
| 276 |
+
</div>
|
| 277 |
+
) : null}
|
| 278 |
+
{view.description && !(editing && editing.id === view.id) ? (
|
| 279 |
+
<p className="qy-rail-note">{view.description}</p>
|
| 280 |
+
) : null}
|
| 281 |
</div>
|
| 282 |
))}
|
| 283 |
</section>
|
web/src/settings/permsModel.ts
CHANGED
|
@@ -211,6 +211,14 @@ const FIELD_TYPE_TABLE: Record<FieldType, true> = {
|
|
| 211 |
// and this listing cannot land in two changes. Nothing about the permissions wall treats a
|
| 212 |
// code column specially β it is granted and hidden like any other column.
|
| 213 |
code: true,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
};
|
| 215 |
|
| 216 |
export const KNOWN_FIELD_TYPES: ReadonlySet<string> = new Set(Object.keys(FIELD_TYPE_TABLE));
|
|
|
|
| 211 |
// and this listing cannot land in two changes. Nothing about the permissions wall treats a
|
| 212 |
// code column specially β it is granted and hidden like any other column.
|
| 213 |
code: true,
|
| 214 |
+
// ββ Wave-34 (owner ruling R13) β `ai_enrich`, the same one-key edit for the same reason the
|
| 215 |
+
// four notes above give: the exhaustiveness alarm is a COMPILE error, so the union and this
|
| 216 |
+
// listing cannot land in two changes. `settings/**` is in NO lane's fence this wave, which
|
| 217 |
+
// makes it less of an exception than `json` and `code` were, not more.
|
| 218 |
+
// β Nothing about the permissions wall treats an enrichment column specially: it is granted
|
| 219 |
+
// and hidden like any other column. The thing that IS special about it (a human-edited cell is
|
| 220 |
+
// never overwritten by the agent) is a WRITE law in `core.user_tables`, not a grant.
|
| 221 |
+
ai_enrich: true,
|
| 222 |
};
|
| 223 |
|
| 224 |
export const KNOWN_FIELD_TYPES: ReadonlySet<string> = new Set(Object.keys(FIELD_TYPE_TABLE));
|
web/src/shell/LoginPage.tsx
CHANGED
|
@@ -1,145 +1,161 @@
|
|
| 1 |
-
// ---------------------------------------------------------------------------
|
| 2 |
-
// shell/LoginPage.tsx β X5: the branded door.
|
| 3 |
-
//
|
| 4 |
-
// THE CARD LANGUAGE IS `gate()`'s, DELIBERATELY (app.py:1228). Same lockup
|
| 5 |
-
// (mark + "Loopable" wordmark, side by side β wave-9 I5), same 410px card with
|
| 6 |
-
// ONE hairline closing all four sides (wave-10 I25a removed the gold top-rule:
|
| 7 |
-
// "there is an orange line at the top of the sign-in placeβ¦ it's ugly" β do not
|
| 8 |
-
// reintroduce an accent edge here), same two fields and one primary button.
|
| 9 |
-
// Two doors into one product must not look like two products, and this is the
|
| 10 |
-
// door that outlives the other: `gate()` dies with app.py at EXIT-6.
|
| 11 |
-
//
|
| 12 |
-
// WHAT IT DOES NOT INHERIT: `gate()`'s `st.empty()` slot dance is a workaround
|
| 13 |
-
// for Streamlit replacing elements POSITIONALLY, labelled in its own docstring
|
| 14 |
-
// as something "the successor shell deletes". This is that successor. React
|
| 15 |
-
// unmounts what it unmounts; there is no ghost card to chase.
|
| 16 |
-
//
|
| 17 |
-
// NO CLIENT-SIDE BACKOFF (X5: the server owns it). The only local rule is
|
| 18 |
-
// single-flight β a second submit while one is in the air is dropped, which is
|
| 19 |
-
// double-click protection, not rate limiting.
|
| 20 |
-
// ---------------------------------------------------------------------------
|
| 21 |
-
|
| 22 |
-
import { useState } from "react";
|
| 23 |
-
import type { FormEvent } from "react";
|
| 24 |
-
import { Brand } from "./Brand";
|
| 25 |
-
import { login } from "./session";
|
| 26 |
-
import type { SessionUser } from "./session";
|
| 27 |
-
|
| 28 |
-
/** The reveal toggle the host's password field ships natively (BaseWeb's eye).
|
| 29 |
-
* Two doors, one affordance. */
|
| 30 |
-
function EyeIcon({ off }: { off: boolean }) {
|
| 31 |
-
return (
|
| 32 |
-
<svg viewBox="0 0 20 20" aria-hidden="true">
|
| 33 |
-
<path d="M2.2 10s2.8-4.6 7.8-4.6S17.8 10 17.8 10 15 14.6 10 14.6 2.2 10 2.2 10z" />
|
| 34 |
-
<circle cx="10" cy="10" r="2.3" />
|
| 35 |
-
{off ? <path d="M4 16 16 4" /> : null}
|
| 36 |
-
</svg>
|
| 37 |
-
);
|
| 38 |
-
}
|
| 39 |
-
|
| 40 |
-
export default function LoginPage({ onSignedIn }: { onSignedIn: (user: SessionUser) => void }) {
|
| 41 |
-
const [username, setUsername] = useState("");
|
| 42 |
-
const [password, setPassword] = useState("");
|
| 43 |
-
const [error, setError] = useState("");
|
| 44 |
-
const [busy, setBusy] = useState(false);
|
| 45 |
-
const [reveal, setReveal] = useState(false);
|
| 46 |
-
|
| 47 |
-
async function submit(e: FormEvent) {
|
| 48 |
-
e.preventDefault();
|
| 49 |
-
if (busy) return;
|
| 50 |
-
setBusy(true);
|
| 51 |
-
setError("");
|
| 52 |
-
const result = await login(username, password);
|
| 53 |
-
if (result.ok) {
|
| 54 |
-
// No setBusy(false): the parent unmounts this tree on success, and
|
| 55 |
-
// flipping state on the way out is how "update on an unmounted
|
| 56 |
-
// component" warnings are born.
|
| 57 |
-
onSignedIn(result.user);
|
| 58 |
-
return;
|
| 59 |
-
}
|
| 60 |
-
setBusy(false);
|
| 61 |
-
setError(result.message);
|
| 62 |
-
setPassword("");
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
// An error that survives the correction is an error that reads as sticky.
|
| 66 |
-
function edit(set: (v: string) => void) {
|
| 67 |
-
return (e: { target: { value: string } }) => {
|
| 68 |
-
if (error) setError("");
|
| 69 |
-
set(e.target.value);
|
| 70 |
-
};
|
| 71 |
-
}
|
| 72 |
-
|
| 73 |
-
return (
|
| 74 |
-
<div className="login-root">
|
| 75 |
-
<div className="login-card">
|
| 76 |
-
<Brand size={44} className="login-brand" />
|
| 77 |
-
|
| 78 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// ---------------------------------------------------------------------------
|
| 2 |
+
// shell/LoginPage.tsx β X5: the branded door.
|
| 3 |
+
//
|
| 4 |
+
// THE CARD LANGUAGE IS `gate()`'s, DELIBERATELY (app.py:1228). Same lockup
|
| 5 |
+
// (mark + "Loopable" wordmark, side by side β wave-9 I5), same 410px card with
|
| 6 |
+
// ONE hairline closing all four sides (wave-10 I25a removed the gold top-rule:
|
| 7 |
+
// "there is an orange line at the top of the sign-in placeβ¦ it's ugly" β do not
|
| 8 |
+
// reintroduce an accent edge here), same two fields and one primary button.
|
| 9 |
+
// Two doors into one product must not look like two products, and this is the
|
| 10 |
+
// door that outlives the other: `gate()` dies with app.py at EXIT-6.
|
| 11 |
+
//
|
| 12 |
+
// WHAT IT DOES NOT INHERIT: `gate()`'s `st.empty()` slot dance is a workaround
|
| 13 |
+
// for Streamlit replacing elements POSITIONALLY, labelled in its own docstring
|
| 14 |
+
// as something "the successor shell deletes". This is that successor. React
|
| 15 |
+
// unmounts what it unmounts; there is no ghost card to chase.
|
| 16 |
+
//
|
| 17 |
+
// NO CLIENT-SIDE BACKOFF (X5: the server owns it). The only local rule is
|
| 18 |
+
// single-flight β a second submit while one is in the air is dropped, which is
|
| 19 |
+
// double-click protection, not rate limiting.
|
| 20 |
+
// ---------------------------------------------------------------------------
|
| 21 |
+
|
| 22 |
+
import { useState } from "react";
|
| 23 |
+
import type { FormEvent } from "react";
|
| 24 |
+
import { Brand } from "./Brand";
|
| 25 |
+
import { login } from "./session";
|
| 26 |
+
import type { SessionUser } from "./session";
|
| 27 |
+
|
| 28 |
+
/** The reveal toggle the host's password field ships natively (BaseWeb's eye).
|
| 29 |
+
* Two doors, one affordance. */
|
| 30 |
+
function EyeIcon({ off }: { off: boolean }) {
|
| 31 |
+
return (
|
| 32 |
+
<svg viewBox="0 0 20 20" aria-hidden="true">
|
| 33 |
+
<path d="M2.2 10s2.8-4.6 7.8-4.6S17.8 10 17.8 10 15 14.6 10 14.6 2.2 10 2.2 10z" />
|
| 34 |
+
<circle cx="10" cy="10" r="2.3" />
|
| 35 |
+
{off ? <path d="M4 16 16 4" /> : null}
|
| 36 |
+
</svg>
|
| 37 |
+
);
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
export default function LoginPage({ onSignedIn }: { onSignedIn: (user: SessionUser) => void }) {
|
| 41 |
+
const [username, setUsername] = useState("");
|
| 42 |
+
const [password, setPassword] = useState("");
|
| 43 |
+
const [error, setError] = useState("");
|
| 44 |
+
const [busy, setBusy] = useState(false);
|
| 45 |
+
const [reveal, setReveal] = useState(false);
|
| 46 |
+
|
| 47 |
+
async function submit(e: FormEvent) {
|
| 48 |
+
e.preventDefault();
|
| 49 |
+
if (busy) return;
|
| 50 |
+
setBusy(true);
|
| 51 |
+
setError("");
|
| 52 |
+
const result = await login(username, password);
|
| 53 |
+
if (result.ok) {
|
| 54 |
+
// No setBusy(false): the parent unmounts this tree on success, and
|
| 55 |
+
// flipping state on the way out is how "update on an unmounted
|
| 56 |
+
// component" warnings are born.
|
| 57 |
+
onSignedIn(result.user);
|
| 58 |
+
return;
|
| 59 |
+
}
|
| 60 |
+
setBusy(false);
|
| 61 |
+
setError(result.message);
|
| 62 |
+
setPassword("");
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
// An error that survives the correction is an error that reads as sticky.
|
| 66 |
+
function edit(set: (v: string) => void) {
|
| 67 |
+
return (e: { target: { value: string } }) => {
|
| 68 |
+
if (error) setError("");
|
| 69 |
+
set(e.target.value);
|
| 70 |
+
};
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
return (
|
| 74 |
+
<div className="login-root">
|
| 75 |
+
<div className="login-card">
|
| 76 |
+
<Brand size={44} className="login-brand" />
|
| 77 |
+
|
| 78 |
+
{/* β WAVE 34 R9 β THE ONE LINE OF SELL ON THIS SCREEN. Owner, 2026-08-16:
|
| 79 |
+
*"Come up with selling points: Automate all business workflows, add it to the login
|
| 80 |
+
page. Don't make Royal Imports think im selling to other businesses though, so just
|
| 81 |
+
add a catchy slogan."*
|
| 82 |
+
|
| 83 |
+
β THE SECOND SENTENCE IS THE HARD CONSTRAINT AND IT IS EASY TO MISS. Royal Imports is
|
| 84 |
+
tenant #0 and their team signs in here every day; a line that reads like marketing
|
| 85 |
+
copy aimed at OTHER companies tells them, correctly, that they are looking at a demo
|
| 86 |
+
of a product being sold elsewhere. So this is a PROMISE ABOUT THEIR OWN TOOL, phrased
|
| 87 |
+
with no audience in it: no "for teams", no "businesses", no "your company", no plan
|
| 88 |
+
names, no call to action. "Every workflow, automated." is a claim the app makes about
|
| 89 |
+
itself, which happens to also be the selling point.
|
| 90 |
+
|
| 91 |
+
β No dash of any kind (R6). A slogan is exactly where an em dash wants to appear. */}
|
| 92 |
+
<p className="login-tagline">Every workflow, automated.</p>
|
| 93 |
+
|
| 94 |
+
<form className="login-form" onSubmit={submit} noValidate>
|
| 95 |
+
{/* Labels are present and hidden, not absent. `gate()` collapses them
|
| 96 |
+
because Streamlit gives no other way to get a placeholder-only
|
| 97 |
+
field; here the label exists for assistive tech and the visual is
|
| 98 |
+
identical. */}
|
| 99 |
+
{/* No autoFocus: the other door cannot autofocus (Streamlit), and the
|
| 100 |
+
focus ring it would paint on load reads as a highlighted error on
|
| 101 |
+
an untouched form. */}
|
| 102 |
+
<label className="lp-sr-only" htmlFor="login-username">Username</label>
|
| 103 |
+
<input
|
| 104 |
+
id="login-username"
|
| 105 |
+
className="login-input"
|
| 106 |
+
name="username"
|
| 107 |
+
type="text"
|
| 108 |
+
autoComplete="username"
|
| 109 |
+
placeholder="Username"
|
| 110 |
+
value={username}
|
| 111 |
+
onChange={edit(setUsername)}
|
| 112 |
+
disabled={busy}
|
| 113 |
+
aria-invalid={error ? true : undefined}
|
| 114 |
+
/>
|
| 115 |
+
|
| 116 |
+
<label className="lp-sr-only" htmlFor="login-password">Password</label>
|
| 117 |
+
<div className="login-field">
|
| 118 |
+
<input
|
| 119 |
+
id="login-password"
|
| 120 |
+
className="login-input"
|
| 121 |
+
name="password"
|
| 122 |
+
type={reveal ? "text" : "password"}
|
| 123 |
+
autoComplete="current-password"
|
| 124 |
+
placeholder="Password"
|
| 125 |
+
value={password}
|
| 126 |
+
onChange={edit(setPassword)}
|
| 127 |
+
disabled={busy}
|
| 128 |
+
aria-invalid={error ? true : undefined}
|
| 129 |
+
/>
|
| 130 |
+
{/* tabIndex β1: Tab goes Username β Password β Log in; the reveal is
|
| 131 |
+
a mouse affordance, same as the host's. */}
|
| 132 |
+
<button
|
| 133 |
+
type="button"
|
| 134 |
+
className="login-eye"
|
| 135 |
+
aria-label={reveal ? "Hide password" : "Show password"}
|
| 136 |
+
onClick={() => setReveal((v) => !v)}
|
| 137 |
+
disabled={busy}
|
| 138 |
+
tabIndex={-1}
|
| 139 |
+
>
|
| 140 |
+
<EyeIcon off={reveal} />
|
| 141 |
+
</button>
|
| 142 |
+
</div>
|
| 143 |
+
|
| 144 |
+
{/* Disabled through THREE channels (the real attribute, the cursor and
|
| 145 |
+
the opacity), never opacity alone β a control that only looks dead
|
| 146 |
+
is a control that still takes the click. */}
|
| 147 |
+
<button className="login-submit" type="submit" disabled={busy}>
|
| 148 |
+
{busy ? "Signing inβ¦" : "Log in"}
|
| 149 |
+
</button>
|
| 150 |
+
</form>
|
| 151 |
+
|
| 152 |
+
{/* The message line is ALWAYS in the layout (min-height: 1lh) so an
|
| 153 |
+
arriving error does not shove the card. `role="status"` rather than
|
| 154 |
+
"alert": polite announcement, no interruption. */}
|
| 155 |
+
<p className="login-msg" role="status" aria-live="polite">
|
| 156 |
+
{error}
|
| 157 |
+
</p>
|
| 158 |
+
</div>
|
| 159 |
+
</div>
|
| 160 |
+
);
|
| 161 |
+
}
|
web/src/shell/NavExtras.tsx
CHANGED
|
@@ -12,8 +12,8 @@
|
|
| 12 |
|
| 13 |
import { useEffect, useRef, useState } from "react";
|
| 14 |
import type { ReactNode } from "react";
|
| 15 |
-
import { fetchSchema } from "./nav";
|
| 16 |
-
import type { NavFolder, SchemaPayload, TableFootprint } from "./nav";
|
| 17 |
// β WAVE 19 R8 / C1 β IMPORTED, NEVER REDEFINED, and the contract says so in as
|
| 18 |
// many words ("Shapes/tones = the grid's existing vocabulary verbatim (12
|
| 19 |
// shapes, 5 tones); B imports, never redefines"). A second copy of this
|
|
@@ -735,6 +735,7 @@ export function CreateNewRow({
|
|
| 735 |
export function FolderHead({
|
| 736 |
folder,
|
| 737 |
count,
|
|
|
|
| 738 |
open,
|
| 739 |
collapsed,
|
| 740 |
onToggle,
|
|
@@ -745,6 +746,10 @@ export function FolderHead({
|
|
| 745 |
}: {
|
| 746 |
folder: NavFolder;
|
| 747 |
count: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 748 |
open: boolean;
|
| 749 |
collapsed: boolean;
|
| 750 |
onToggle: () => void;
|
|
@@ -785,6 +790,22 @@ export function FolderHead({
|
|
| 785 |
<FolderIcon />
|
| 786 |
<span className="shell-nav-label">{folder.name}</span>
|
| 787 |
<span className="shell-nav-foldercount">{count}</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 788 |
</button>
|
| 789 |
<button
|
| 790 |
type="button"
|
|
|
|
| 12 |
|
| 13 |
import { useEffect, useRef, useState } from "react";
|
| 14 |
import type { ReactNode } from "react";
|
| 15 |
+
import { fetchSchema, importantBadge } from "./nav";
|
| 16 |
+
import type { NavFolder, NavImportant, SchemaPayload, TableFootprint } from "./nav";
|
| 17 |
// β WAVE 19 R8 / C1 β IMPORTED, NEVER REDEFINED, and the contract says so in as
|
| 18 |
// many words ("Shapes/tones = the grid's existing vocabulary verbatim (12
|
| 19 |
// shapes, 5 tones); B imports, never redefines"). A second copy of this
|
|
|
|
| 735 |
export function FolderHead({
|
| 736 |
folder,
|
| 737 |
count,
|
| 738 |
+
important,
|
| 739 |
open,
|
| 740 |
collapsed,
|
| 741 |
onToggle,
|
|
|
|
| 746 |
}: {
|
| 747 |
folder: NavFolder;
|
| 748 |
count: number;
|
| 749 |
+
/** ββ WAVE 34 R1 / C1 β the SUM of this folder's members' mark-important numbers, set by
|
| 750 |
+
* `foldNav` for a CLOSED folder only. Absent β draw nothing, which is both the open case and
|
| 751 |
+
* the far more common "nobody marked anything in here" case. */
|
| 752 |
+
important?: NavImportant;
|
| 753 |
open: boolean;
|
| 754 |
collapsed: boolean;
|
| 755 |
onToggle: () => void;
|
|
|
|
| 790 |
<FolderIcon />
|
| 791 |
<span className="shell-nav-label">{folder.name}</span>
|
| 792 |
<span className="shell-nav-foldercount">{count}</span>
|
| 793 |
+
{/* ββ WAVE 34 R1 / C1 β *"a minimized folder should show the SUM of the numbers"*.
|
| 794 |
+
β BESIDE `count`, NEVER INSTEAD OF IT. `count` is how many BLOCKS the folder holds and
|
| 795 |
+
has meant that since wave 14; this is a sum of RECORDS. Collapsing them into one slot
|
| 796 |
+
would make the same numeral mean two things depending on whether anything inside had
|
| 797 |
+
ever been marked, which is worse than two numbers.
|
| 798 |
+
β The `title` is what tells them apart for a reader who sees two numerals; the glyph
|
| 799 |
+
cannot, and DESIGN.md's "never over-explain" law is about copy on screen, not about a
|
| 800 |
+
tooltip that only appears on hover. */}
|
| 801 |
+
{importantBadge(important) ? (
|
| 802 |
+
<span
|
| 803 |
+
className="shell-nav-count"
|
| 804 |
+
title={`${importantBadge(important)} in the important views inside this folder`}
|
| 805 |
+
>
|
| 806 |
+
{importantBadge(important)}
|
| 807 |
+
</span>
|
| 808 |
+
) : null}
|
| 809 |
</button>
|
| 810 |
<button
|
| 811 |
type="button"
|
web/src/shell/Shell.tsx
CHANGED
|
The diff for this file is too large to render.
See raw diff
|
|
|
web/src/shell/nav.ts
CHANGED
|
@@ -113,6 +113,80 @@ export interface NavPage {
|
|
| 113 |
* never a control that silently disappears.
|
| 114 |
*/
|
| 115 |
locked?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
}
|
| 117 |
|
| 118 |
/**
|
|
@@ -190,6 +264,8 @@ export function parsePages(body: unknown): NavPage[] {
|
|
| 190 |
// β WAVE 27 item 3 / C9 β same `=== true` strictness. A locked database is a padlock
|
| 191 |
// beside a name, so a drifted server sending a truthy string must not paint one.
|
| 192 |
...(p.locked === true ? { locked: true } : {}),
|
|
|
|
|
|
|
| 193 |
});
|
| 194 |
}
|
| 195 |
return out;
|
|
@@ -583,9 +659,22 @@ export async function deleteTable(key: string): Promise<{ ok: boolean; error?: s
|
|
| 583 |
}
|
| 584 |
}
|
| 585 |
|
| 586 |
-
/** One rendered row of the folded nav: a folder head, or an entry (optionally inside one).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 587 |
export type NavRow =
|
| 588 |
-
| {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
| { kind: "entry"; entry: NavEntry; folderId?: string };
|
| 590 |
|
| 591 |
/**
|
|
@@ -628,10 +717,40 @@ export function foldNav(
|
|
| 628 |
for (const c of b.children)
|
| 629 |
out.push({ kind: "entry", entry: c, ...(folderId ? { folderId } : {}) });
|
| 630 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 631 |
for (const f of prefs.folders) {
|
| 632 |
const members = byFolder.get(f.id) ?? [];
|
| 633 |
const open = !closed.has(f.id);
|
| 634 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 635 |
if (open) for (const b of members) emit(b, f.id);
|
| 636 |
}
|
| 637 |
for (const b of loose) emit(b);
|
|
@@ -738,6 +857,11 @@ export interface NavEntry {
|
|
| 738 |
/** β WAVE 27 item 3 / C9 β a LOCKED database: no new records, fields still fine (see the
|
| 739 |
* long note on `NavPage.locked`). Absent β unlocked. */
|
| 740 |
locked?: boolean;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 741 |
/**
|
| 742 |
* β WAVE 25 (D-54) β THIS ROW IS A SURFACE, NOT A DATABASE.
|
| 743 |
*
|
|
@@ -897,6 +1021,13 @@ export function shapeNav(pages: NavPage[], appBase: string): NavEntry[] {
|
|
| 897 |
// β WAVE 27 item 3 / C9 β THE SECOND HALF, for the reason stated directly above: a flag
|
| 898 |
// parsed onto the payload and dropped here reaches no row anybody renders.
|
| 899 |
...(p.locked ? { locked: true } : {}),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 900 |
// β D-54 β the databases/surfaces split, stamped at the ONE place rows are built.
|
| 901 |
// Not `kind`: a surface is `native` (this tree renders it) and so are the grids, so the
|
| 902 |
// existing discriminator cannot answer this question and widening it would have made
|
|
@@ -1057,8 +1188,59 @@ export const LEGACY_ROUTES: Readonly<Record<string, string>> = {
|
|
| 1057 |
* Home β a worse answer to the same question.
|
| 1058 |
*/
|
| 1059 |
"ai-agent": ASSISTANT_ROUTE,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1060 |
};
|
| 1061 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1062 |
/** The current key for a hash that may name a retired one. Identity for everything else. */
|
| 1063 |
export function canonicalRoute(route: string): string {
|
| 1064 |
return LEGACY_ROUTES[route] ?? route;
|
|
|
|
| 113 |
* never a control that silently disappears.
|
| 114 |
*/
|
| 115 |
locked?: boolean;
|
| 116 |
+
/**
|
| 117 |
+
* ββ WAVE 34 (ruling R1, contract C1) β HOW MANY RECORDS THIS DATABASE'S **IMPORTANT** VIEWS
|
| 118 |
+
* RESOLVE TO.
|
| 119 |
+
*
|
| 120 |
+
* R1 moves the mark-important number OFF the view sidebar's header and INTO the database
|
| 121 |
+
* navigation, and that move is what forces this onto the wire: the flyout has no grid, so the
|
| 122 |
+
* client cannot compute it the way `CustomerGrid::importantTotal` does.
|
| 123 |
+
*
|
| 124 |
+
* β THE THREE FIELDS ARE NOT REDUNDANT AND `partial` IS THE LOAD-BEARING ONE. `marked` counts
|
| 125 |
+
* VIEWS, `counted` counts RECORDS, and `partial` says at least one marked view could not be
|
| 126 |
+
* counted at all. `/nav` holds a rows-free projection, so a FILTERED view is uncountable there
|
| 127 |
+
* by construction; a sum that silently skipped those would be short by an unknown amount, which
|
| 128 |
+
* is exactly the silent absence `D-205` exists to end, one level up.
|
| 129 |
+
*
|
| 130 |
+
* β PER USER, never tenant-wide. Views live in a per-database bucket split by username, so the
|
| 131 |
+
* same database answers `{marked: 1, counted: 13}` for one account and `{marked: 0}` for
|
| 132 |
+
* another. Measured on tenant #0: `leadership` vs `admin` on `customer_data`.
|
| 133 |
+
*
|
| 134 |
+
* β ABSENT means "not a database, or the server could not read its views" β the second case is
|
| 135 |
+
* named in `degraded`, exactly like the `ut_*` merge. It does NOT mean zero: a database with
|
| 136 |
+
* nothing marked ships `{marked: 0, counted: 0, partial: false}`, because a key a consumer has
|
| 137 |
+
* to test for is a key a consumer forgets to test for.
|
| 138 |
+
*/
|
| 139 |
+
important?: NavImportant;
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
/** C1's per-database important payload. See `NavPage.important` for why all three fields exist. */
|
| 143 |
+
export interface NavImportant {
|
| 144 |
+
marked: number;
|
| 145 |
+
counted: number;
|
| 146 |
+
partial: boolean;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
/**
|
| 150 |
+
* The wire's important block, validated like every other flag on this payload: prune, never
|
| 151 |
+
* invent. A malformed one reads as ABSENT, which renders no badge β the safe direction, because
|
| 152 |
+
* the alternative is a confident number derived from junk.
|
| 153 |
+
*/
|
| 154 |
+
export function parseNavImportant(raw: unknown): NavImportant | undefined {
|
| 155 |
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
| 156 |
+
const r = raw as { marked?: unknown; counted?: unknown; partial?: unknown };
|
| 157 |
+
const int = (v: unknown): number | null =>
|
| 158 |
+
typeof v === "number" && Number.isFinite(v) && v >= 0 ? Math.floor(v) : null;
|
| 159 |
+
const marked = int(r.marked);
|
| 160 |
+
const counted = int(r.counted);
|
| 161 |
+
if (marked === null || counted === null) return undefined;
|
| 162 |
+
// `=== true`, the same strictness `manage`/`canDelete`/`locked` use: a drifted server sending a
|
| 163 |
+
// truthy string must not turn a real count into a `+`.
|
| 164 |
+
return { marked, counted, partial: r.partial === true };
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
/**
|
| 168 |
+
* ββ THE BADGE TEXT, AND IT IS ONE FUNCTION BECAUSE THERE ARE TWO CALLERS.
|
| 169 |
+
*
|
| 170 |
+
* A database row and a COLLAPSED FOLDER both draw this number, from different inputs (one entry's
|
| 171 |
+
* block, a sum over a folder's members). Two renderers deciding "is there a number here" by their
|
| 172 |
+
* own rules is [[one-question-two-normalizers]] with the answer on screen, so the rule lives here
|
| 173 |
+
* and both call it.
|
| 174 |
+
*
|
| 175 |
+
* The four cases, and the third is the one the ticket calls out by name:
|
| 176 |
+
* Β· nothing marked -> `null`. R1's own clause: NOT a zero. Most databases are this.
|
| 177 |
+
* Β· a real count -> the number, plus `+` when at least one view could not be counted.
|
| 178 |
+
* Β· nothing countable -> `"+"` ALONE. β NEVER `"0+"`: that string is `D-205` itself, the
|
| 179 |
+
* thing the owner photographed, and rendering it here would be the
|
| 180 |
+
* same defect moved into the nav. A bare `+` says "there is something
|
| 181 |
+
* here we could not count" without claiming the something is zero.
|
| 182 |
+
* Β· a genuine zero -> `"0"`. A marked view that matches no records is a fact worth
|
| 183 |
+
* seeing, and it is distinguishable from the case above because
|
| 184 |
+
* nothing was skipped.
|
| 185 |
+
*/
|
| 186 |
+
export function importantBadge(imp?: NavImportant): string | null {
|
| 187 |
+
if (!imp || imp.marked <= 0) return null;
|
| 188 |
+
if (imp.counted > 0) return `${imp.counted}${imp.partial ? "+" : ""}`;
|
| 189 |
+
return imp.partial ? "+" : "0";
|
| 190 |
}
|
| 191 |
|
| 192 |
/**
|
|
|
|
| 264 |
// β WAVE 27 item 3 / C9 β same `=== true` strictness. A locked database is a padlock
|
| 265 |
// beside a name, so a drifted server sending a truthy string must not paint one.
|
| 266 |
...(p.locked === true ? { locked: true } : {}),
|
| 267 |
+
// ββ WAVE 34 R1 / C1 β the important block, validated on the way in like the icon.
|
| 268 |
+
...(parseNavImportant(p.important) ? { important: parseNavImportant(p.important)! } : {}),
|
| 269 |
});
|
| 270 |
}
|
| 271 |
return out;
|
|
|
|
| 659 |
}
|
| 660 |
}
|
| 661 |
|
| 662 |
+
/** One rendered row of the folded nav: a folder head, or an entry (optionally inside one).
|
| 663 |
+
*
|
| 664 |
+
* β `count` and `important` are DIFFERENT NUMBERS and neither is a version of the other.
|
| 665 |
+
* `count` is how many BLOCKS the folder holds (a top-level entry plus its depth-1 children counts
|
| 666 |
+
* as one), which is the number that has been drawn beside a folder name since wave 14.
|
| 667 |
+
* `important` is R1's aggregate over its members' record counts. Putting them in one slot would
|
| 668 |
+
* make a folder head say "3" and mean two different things depending on whether anything inside
|
| 669 |
+
* it was ever marked. */
|
| 670 |
export type NavRow =
|
| 671 |
+
| {
|
| 672 |
+
kind: "folder";
|
| 673 |
+
folder: NavFolder;
|
| 674 |
+
count: number;
|
| 675 |
+
open: boolean;
|
| 676 |
+
important?: NavImportant;
|
| 677 |
+
}
|
| 678 |
| { kind: "entry"; entry: NavEntry; folderId?: string };
|
| 679 |
|
| 680 |
/**
|
|
|
|
| 717 |
for (const c of b.children)
|
| 718 |
out.push({ kind: "entry", entry: c, ...(folderId ? { folderId } : {}) });
|
| 719 |
};
|
| 720 |
+
// ββ WAVE 34 R1 / C1 β a COLLAPSED folder carries the SUM of its members' numbers.
|
| 721 |
+
//
|
| 722 |
+
// β SUMMED FROM THE SAME `members` ARRAY THAT PRODUCES `count`, in the same pass, and that is
|
| 723 |
+
// the whole correctness of it. The alternative β a second lookup in `Shell.tsx` keyed off
|
| 724 |
+
// `dbEntries` β would derive the number from one list while the rows came from another, which is
|
| 725 |
+
// the defect `shell.test.ts` already pins for `count` ("a folder whose only member was filtered
|
| 726 |
+
// out counts 0, not 1"). It bites hardest during SEARCH, when the caller passes
|
| 727 |
+
// `EMPTY_NAV_PREFS` and no folder renders at all.
|
| 728 |
+
//
|
| 729 |
+
// β CHILDREN CONTRIBUTE. A block is a head plus its depth-1 children, and a child is its own
|
| 730 |
+
// database with its own marked views, so the head's number would be short without them.
|
| 731 |
+
const aggregate = (blocks: Block[]): NavImportant | undefined => {
|
| 732 |
+
let marked = 0;
|
| 733 |
+
let counted = 0;
|
| 734 |
+
let partial = false;
|
| 735 |
+
for (const b of blocks) {
|
| 736 |
+
for (const e of [b.head, ...b.children]) {
|
| 737 |
+
if (!e.important) continue;
|
| 738 |
+
marked += e.important.marked;
|
| 739 |
+
counted += e.important.counted;
|
| 740 |
+
partial = partial || e.important.partial;
|
| 741 |
+
}
|
| 742 |
+
}
|
| 743 |
+
return marked > 0 ? { marked, counted, partial } : undefined;
|
| 744 |
+
};
|
| 745 |
for (const f of prefs.folders) {
|
| 746 |
const members = byFolder.get(f.id) ?? [];
|
| 747 |
const open = !closed.has(f.id);
|
| 748 |
+
// β ONLY WHEN CLOSED. An open folder shows its members' own numbers directly underneath, so a
|
| 749 |
+
// head number there would be the same records counted twice on one screen β the ticket's
|
| 750 |
+
// "an expanded one does not double-count", enforced at the source rather than in the renderer.
|
| 751 |
+
const imp = open ? undefined : aggregate(members);
|
| 752 |
+
out.push({ kind: "folder", folder: f, count: members.length, open,
|
| 753 |
+
...(imp ? { important: imp } : {}) });
|
| 754 |
if (open) for (const b of members) emit(b, f.id);
|
| 755 |
}
|
| 756 |
for (const b of loose) emit(b);
|
|
|
|
| 857 |
/** β WAVE 27 item 3 / C9 β a LOCKED database: no new records, fields still fine (see the
|
| 858 |
* long note on `NavPage.locked`). Absent β unlocked. */
|
| 859 |
locked?: boolean;
|
| 860 |
+
/** ββ WAVE 34 R1 / C1 β the mark-important counts (see the long note on `NavPage.important`).
|
| 861 |
+
* THE SECOND HALF of that flag, and it is here for the reason `canDelete` and `locked` are:
|
| 862 |
+
* a value parsed onto the payload and dropped in `shapeNav` reaches no row anybody renders,
|
| 863 |
+
* which is precisely how wave 20 shipped four wirings that went nowhere. */
|
| 864 |
+
important?: NavImportant;
|
| 865 |
/**
|
| 866 |
* β WAVE 25 (D-54) β THIS ROW IS A SURFACE, NOT A DATABASE.
|
| 867 |
*
|
|
|
|
| 1021 |
// β WAVE 27 item 3 / C9 β THE SECOND HALF, for the reason stated directly above: a flag
|
| 1022 |
// parsed onto the payload and dropped here reaches no row anybody renders.
|
| 1023 |
...(p.locked ? { locked: true } : {}),
|
| 1024 |
+
// ββ WAVE 34 R1 / C1 β THE SECOND HALF AGAIN, AND IT WAS MISSED ON THE FIRST TRY.
|
| 1025 |
+
// `parsePages` validated `important` onto the payload and this function dropped it, so the
|
| 1026 |
+
// number reached the wire, survived validation, and never reached a row. The `shapeNav`
|
| 1027 |
+
// leg in `shell.test.ts` is what found it, minutes after being written; the two comments
|
| 1028 |
+
// above say this exact thing about two earlier flags and it happened anyway, which is the
|
| 1029 |
+
// argument for the leg rather than for the comment.
|
| 1030 |
+
...(p.important ? { important: p.important } : {}),
|
| 1031 |
// β D-54 β the databases/surfaces split, stamped at the ONE place rows are built.
|
| 1032 |
// Not `kind`: a surface is `native` (this tree renders it) and so are the grids, so the
|
| 1033 |
// existing discriminator cannot answer this question and widening it would have made
|
|
|
|
| 1188 |
* Home β a worse answer to the same question.
|
| 1189 |
*/
|
| 1190 |
"ai-agent": ASSISTANT_ROUTE,
|
| 1191 |
+
/**
|
| 1192 |
+
* ββ WAVE 34 (ruling R14, contract C2) β QUERY IS MERGED INTO THE ASSISTANT.
|
| 1193 |
+
*
|
| 1194 |
+
* Owner: *"Combine the AI assistant module AND the Query module. In the secondary navigation at
|
| 1195 |
+
* the top, just above '+ New chat', an easily accessible toggle between Chat / Query."* So there
|
| 1196 |
+
* is ONE route and one rail row, and `#/query` is a hash people hold: bookmarked, pasted, and
|
| 1197 |
+
* left in an open tab since wave 32.
|
| 1198 |
+
*
|
| 1199 |
+
* β THIS ENTRY IS THE INSTANT-RESOLVE HALF ONLY. It stops the frame rendering Home for the one
|
| 1200 |
+
* paint before the URL is rewritten; `REWRITTEN_ROUTES` below is what actually puts
|
| 1201 |
+
* `?mode=query` on the address bar, and the Query LIST only opens because of that.
|
| 1202 |
+
*/
|
| 1203 |
+
[QUERY_ROUTE]: ASSISTANT_ROUTE,
|
| 1204 |
+
};
|
| 1205 |
+
|
| 1206 |
+
/**
|
| 1207 |
+
* ββ WAVE 34 R14 / C2 β retired hashes the shell REWRITES rather than merely resolves.
|
| 1208 |
+
*
|
| 1209 |
+
* β WHY THIS IS NOT JUST ANOTHER `LEGACY_ROUTES` ROW. A legacy row is an internal translation:
|
| 1210 |
+
* the address bar keeps saying `#/ai-agent` and nothing downstream cares. Query cannot work that
|
| 1211 |
+
* way, because the merged surface decides WHICH LIST to show from `?mode=` on the hash β so a
|
| 1212 |
+
* resolve that never touched the URL would land the reader on the Chat list, which is the opposite
|
| 1213 |
+
* of what their bookmark asked for.
|
| 1214 |
+
*/
|
| 1215 |
+
export const REWRITTEN_ROUTES: Readonly<Record<string, string>> = {
|
| 1216 |
+
[QUERY_ROUTE]: `${ASSISTANT_ROUTE}?mode=query`,
|
| 1217 |
};
|
| 1218 |
|
| 1219 |
+
/**
|
| 1220 |
+
* The ROUTE KEY in a hash, with any query string or nested fragment stripped.
|
| 1221 |
+
*
|
| 1222 |
+
* β THE BUG THIS EXISTS TO PREVENT WAS REPORTED BY LANE D BEFORE IT COULD HAPPEN (post D-1), and
|
| 1223 |
+
* it is invisible until you try it: the shell read the WHOLE remainder of the hash as the key, so
|
| 1224 |
+
* `#/assistant?mode=query` resolved to the key `assistant?mode=query`, matched no route, and fell
|
| 1225 |
+
* through to Home. The redirect below would have "worked" β right URL, wrong screen β and every
|
| 1226 |
+
* gate keyed on the redirect's target string would have been green.
|
| 1227 |
+
*/
|
| 1228 |
+
export function routeKeyOf(hash: string): string {
|
| 1229 |
+
return hash.replace(/^#\/?/, "").split("?")[0].split("#")[0];
|
| 1230 |
+
}
|
| 1231 |
+
|
| 1232 |
+
/**
|
| 1233 |
+
* The hash this one must BECOME, or null when it is already right.
|
| 1234 |
+
*
|
| 1235 |
+
* β RETURNS NULL FOR AN ALREADY-REWRITTEN HASH, which is the whole loop-safety argument: writing
|
| 1236 |
+
* `location.hash` fires `hashchange`, the caller asks again, the key is now `assistant`, and this
|
| 1237 |
+
* answers null. One rewrite, never a cycle.
|
| 1238 |
+
*/
|
| 1239 |
+
export function rewrittenHash(hash: string): string | null {
|
| 1240 |
+
const target = REWRITTEN_ROUTES[routeKeyOf(hash)];
|
| 1241 |
+
return target ? `#/${target}` : null;
|
| 1242 |
+
}
|
| 1243 |
+
|
| 1244 |
/** The current key for a hash that may name a retired one. Identity for everything else. */
|
| 1245 |
export function canonicalRoute(route: string): string {
|
| 1246 |
return LEGACY_ROUTES[route] ?? route;
|
web/src/shell/navExtras.css
CHANGED
|
@@ -61,6 +61,19 @@
|
|
| 61 |
display: none;
|
| 62 |
}
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
.shell-rail-skeleton {
|
| 65 |
display: flex;
|
| 66 |
flex-direction: column;
|
|
@@ -193,6 +206,43 @@
|
|
| 193 |
font-variant-numeric: tabular-nums;
|
| 194 |
}
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
/* ββ wave 14 C-NAVFOLD: drag placement (items 4/6) βββββββββββββββββββββββββ */
|
| 197 |
|
| 198 |
.shell-nav-row.is-dragging {
|
|
|
|
| 61 |
display: none;
|
| 62 |
}
|
| 63 |
|
| 64 |
+
/* β WAVE 34 R15 β "then a subtle line separation". SUBTLE is the whole specification, so this
|
| 65 |
+
is a hairline at the rail's own border token and nothing else: no margin collapse games, no
|
| 66 |
+
inset, no second colour. It groups Home+Inbox / Assistant+Agents / Database+Connectors.
|
| 67 |
+
β It is a direct child of `.shell-nav`, which is what makes the `is-rail-loading` rule above
|
| 68 |
+
hide it with the rows. Do not wrap the groups in `<div>`s to get the same look: that would put
|
| 69 |
+
the rows one level deeper and the `> *` in that selector would stop reaching them, silently
|
| 70 |
+
bringing back the two-row-states defect the whole rule exists to prevent. */
|
| 71 |
+
.shell-nav-sep {
|
| 72 |
+
height: 1px;
|
| 73 |
+
margin: 6px 8px;
|
| 74 |
+
background: var(--lp-line);
|
| 75 |
+
}
|
| 76 |
+
|
| 77 |
.shell-rail-skeleton {
|
| 78 |
display: flex;
|
| 79 |
flex-direction: column;
|
|
|
|
| 206 |
font-variant-numeric: tabular-nums;
|
| 207 |
}
|
| 208 |
|
| 209 |
+
/* ββ ββ WAVE 34 R1 / contract C1: the mark-important number, in the database list ββββββββββ
|
| 210 |
+
Owner, 2026-08-16: *"the mark important number should be moved into the database navigation,
|
| 211 |
+
so when we click database, it shows the list of the databases, each showing their number"* and
|
| 212 |
+
*"we don't need the important word, just show the number, make it smaller"*.
|
| 213 |
+
|
| 214 |
+
Drawn by TWO callers: a database row (`Shell.tsx`'s flyout) and a COLLAPSED folder head
|
| 215 |
+
(`FolderHead` above), both through `nav.ts::importantBadge` so the "is there a number here"
|
| 216 |
+
rule is written once.
|
| 217 |
+
|
| 218 |
+
β `--lp-fs-4xs` (0.625rem = 10px against the 17px root), NOT `--lp-fs-3xs` (11.69px) which the
|
| 219 |
+
sibling `.shell-nav-foldercount` uses. Two reasons and the first is the ruling: R1 says SMALLER,
|
| 220 |
+
and a badge at the same size as the count already beside it is not smaller, it is a second copy.
|
| 221 |
+
The second is arithmetic: C1 asks for <=11px and `--lp-fs-3xs` computes to 11.69px, which is
|
| 222 |
+
over. The font gate enforces token-or-nothing, never a computed ceiling, so nothing in code
|
| 223 |
+
catches picking the wrong token; it is checked here instead.
|
| 224 |
+
|
| 225 |
+
β `margin-left: auto` ON THE ROW, RESET INSIDE A FOLDER HEAD. A database row needs the number
|
| 226 |
+
at the right edge so a list of them reads as a column; a folder head already gives `auto` to
|
| 227 |
+
`.shell-nav-foldercount`, and a SECOND `auto` there splits the free space between the two
|
| 228 |
+
numbers and pushes them apart with a gap in the middle. Hence the adjacency rule below. */
|
| 229 |
+
.shell-nav-count {
|
| 230 |
+
flex: none;
|
| 231 |
+
margin-left: auto;
|
| 232 |
+
padding-left: 6px;
|
| 233 |
+
font-size: var(--lp-fs-4xs);
|
| 234 |
+
font-weight: 400;
|
| 235 |
+
color: var(--lp-muted);
|
| 236 |
+
font-variant-numeric: tabular-nums;
|
| 237 |
+
/* A row is a flex line with an ellipsised label; the number must never be the thing that
|
| 238 |
+
shrinks, or a "13" becomes a "1" with no way to tell. */
|
| 239 |
+
white-space: nowrap;
|
| 240 |
+
}
|
| 241 |
+
/* Inside a folder head the number follows the member count, which already holds the right edge. */
|
| 242 |
+
.shell-nav-foldercount + .shell-nav-count {
|
| 243 |
+
margin-left: 0;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
/* ββ wave 14 C-NAVFOLD: drag placement (items 4/6) βββββββββββββββββββββββββ */
|
| 247 |
|
| 248 |
.shell-nav-row.is-dragging {
|