Skip to content

refactor(ui-web): rebuild the frontend on a typed transport, stores and a react tree - #475

Closed
0xKT wants to merge 83 commits into
mainfrom
refactor/ui_web_architecture
Closed

0xKT wants to merge 83 commits into
mainfrom
refactor/ui_web_architecture

Conversation

@0xKT

@0xKT 0xKT commented Sep 17, 2026 •

Copy link
Copy Markdown
Member

Summary

Rebuilds the ui-web/ frontend's architecture without changing the page: the served page, its stylesheet and its behaviour are the same, the code underneath is not. The wire protocol and the Python backend are untouched.

What changed, in three stages plus a layout pass (37 commits, one per step, each landed with every gate green, plus the layout pass squashed in from #478):

  • A. Module graph and types. The three concatenated script layers (seam / demo / live, ~7,000 lines reached through window.DS, window.RavenShell, window.RavenIslands) became ES modules with an explicit install order; a typed WsTransport implements the RpcTransport interface and carries the page's reconnect behaviour; the data-source seam and the island registry are imports instead of window objects; a Vite dev server with hot reload proxies to the local gateway.
  • B. Session runtime and sources. Every RPC domain has a typed features/<domain>/source.ts; turn state lives in one SessionRuntime per conversation with an ordered pipeline of stages; the offline ?stub=1 page runs on wire-shaped fixtures through a fixture transport instead of a second source layer; the live layer's boot, connection and update-notice code moved into state/.
  • C. Chrome components. page.html shrank from 524 lines to a 59-line shell (<head>, splash, no-JS notice, two script blocks); every region is rendered by App.tsx from a detached React root that portals into document.body; page, language, detail drawer, confirm and settings dialogs, menu, toasts, rail, workspace pane, dock, capabilities page, sheet rack, drafts, tooltip, overlays and the Escape order are stores plus components under src/state/ and src/chrome/; document-level listeners are registered from one place in a pinned order; language is a subscription (no more redrawAll, no more querySelectorAll passes). src/legacy/ and the RavenShell bridge are gone.

Gates added to prove the page did not move: a sha256 pin on page.css, two booted-DOM goldens (stub and live-without-server), 19 per-region DOM goldens, 31 island DOM snapshots, the body-level portal order, the 14-item Escape priority table (91 pairwise cases), the boot listener order, fixture shape checks against the OpenRPC contract, and per-step before/after dumps of the whole body, the boot walk and the language flip (zh / en / unset), all byte-identical.

Outside ui-web/, exactly five approved touches: .github/workflows/ci.yml drops the retired count-shared-globals gate, tests/test_ui_language_repaint.py is deleted (it pinned the concatenated layers by source text and has no subject any more), CONTEXT-MAP.md registers the new ui-web/CONTEXT.md, and tests/test_ui_agent_marks.py / tests/test_ui_provider_marks.py follow two moved component files by path.

Behaviour that is not one-to-one, all deliberate and recorded:

  • ?stub=1 (the developer demo) boots to the new-task screen like the live page instead of auto-opening a canned conversation; its web-search banner and context-chip tooltip are gone with the second source layer.
  • ?onboard=demo is now truly canned (it used to write the real config).
  • Three races closed by the stores: lastAsk is per conversation; the detail drawer's data-fill no longer lingers 260 ms after an owner change; the plugins drawer no longer flickers data-open on open.
  • session.resume no longer sends a session_key the server never read; the knowledge page's missing-source message comes from one place.
  • The two inline <script> elements now precede the React-rendered regions in <body> (not rendered; nothing reads body child positions).

A closing layout pass sits on top of the three stages (#478, squashed into this branch): src/shell/ is dissolved into src/components/ (shared React pieces, PascalCase), src/lib/ (pure helpers, camelCase) and src/state/; the app lifecycle (boot, install, connection, updates, splash) moves to src/app/ and the transport choice and gateway seam join src/rpc/; the language modules become src/state/lang/ and the settings dialog state one store; the island registry lives at src/features/registry.ts; the last four store-driven writers (permission chip, tier chip and panel headings, rail foot, the More flyout) now render through React from their stores; the 24 gate scripts move to scripts/gates/; the module harness refuses a fake key that names no module (that check caught one inert stub left by an earlier commit); and a ratchet gate pins every remaining DOM touch under src/state/ and src/app/ per file. All of it is DOM-invisible: the goldens, snapshots, boot walk and language-flip dumps are byte-identical before and after.

Follow-ups, not in this PR: data-i18n* attributes stay on the JSX as inert markers (removing them changes the DOM goldens); ui-web/CONTEXT.md and README.md are not yet in tests/test_living_docs.py's list; the pre-existing bugs the refactor preserved on purpose (manual MCP add returning -32601, the clarify hasQA target, unread mkPluginRow fields) will be filed as issues.

Type

  • Fix
  • Feature
  • Docs
  • CI / tooling
  • Refactor
  • Other

Verification

From the repo root, on the branch head:

  • make lint-ui (tsc --noEmit) -> clean

  • cd ui-web && HOME=$(mktemp -d) npx vitest run -> 152 files, 2494 tests passed, 0 skipped

  • make build-ui -> boot-snapshot: OK (284 nodes match golden) and boot-snapshot: OK (285 nodes match golden)

  • cd ui-web && node scripts/check-page.mjs && node scripts/check-css.mjs && node scripts/check-class-namespace.mjs && npm run gen:check -> all OK, generated.ts matches the contract (178 methods)

  • uv run --frozen pytest tests/test_ui_agent_marks.py tests/test_ui_provider_marks.py tests/test_ui_css_token_gate.py tests/test_living_docs.py -q -> 55 passed

  • uv run --frozen pytest tests/test_ui_agent_marks.py -q -> 7 passed

  • make check-source-language over origin/main..HEAD -> exit 0

  • uv run --frozen pre-commit run --from-ref origin/main --to-ref HEAD -> all passed

  • git diff --stat $(git merge-base origin/main HEAD)..HEAD -- . ':!ui-web' -> exactly the five files named above

  • Full Python suite (make test-python): see the CI run; the branch has main merged in and changes no Python source beyond the three test-path edits.

  • Not done: a real-browser smoke pass. The IME path in the composer, the desktop-shell drag and zoom, and the live reconnect path are covered by happy-dom tests only.

  • Relevant tests pass locally

  • Relevant lint / type checks pass locally

  • User-facing docs or screenshots are updated when needed

Risk

  • Security: no protocol or auth change; the WebSocket cookie and token paths are unchanged and covered by the transport tests.

  • Backward compatibility: raven serve serves the same dist/index.html; the build entry points (npm run build, python3 ui-web/build.py) keep their names; ?stub=1, ?onboard=demo, ?desk-demo=1 and file:// still work, with the stub first-frame change noted above.

  • Rollback: revert the squash commit. page.css is byte-identical to main, so a revert has no styling delta to reconcile.

  • Security impact considered

  • Backward compatibility considered

  • Rollback path is clear for risky changes

Related Issues

N/A

0xKT and others added 30 commits September 16, 2026 23:26
… dom before the refactor

Three gates for the architecture refactor's promise that nothing the reader
sees moves:

- scripts/page-css-hash.test.mjs pins page.css by sha256; a deliberate style
  change updates the digest in the same PR.
- scripts/boot-snapshot.mjs boots dist/index.html in happy-dom at ?stub=1 and
  compares the settled body shape (tag, id, classes, data-*; no text, no
  script or style elements) against scripts/__golden__/boot-stub.txt. build.py
  runs it after writing dist/, so every job that assembles the page checks it;
  a missing golden is written on a developer machine and refused under CI.
- one DOM-shape snapshot case per island page test (31 cases across 18 files)
  through src/test/domSnapshot.ts, generated from today's render and verified
  deterministic by a second run.

scripts/i18n-keys.test.mjs also lands here: every literal t('gui.x') must be
in i18n/messages.json; the two keys missing today are allowlisted and tracked
as a copy fix.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…ehaviour

WsTransport implements RpcTransport over the /rpc socket, porting every
behaviour of the untyped rpc object in live/020-rpc.js: calls made while the
socket is still opening wait for it, a socket closed before it opened reports
false rather than deciding what it means, pending calls are rejected with
code -1 on a drop, rejections carry the gateway's data.detail as their
message with code, data and the raw frame kept, notifications fan out to a
handler set, binary frames go to their own sink, and rejoin backs off from
1.5s by 1.6x to an 8s cap under a 20 minute ceiling, probing /health after
each failed attempt to tell an absent gateway from a refused session.

The interface grows the members the port needs: a 'reconnected' state, a
per-state info object carrying the failed attempt count so the caller can
show the upgrade shade exactly when the old tick did, binary(handler), and
callUnchecked() -- the escape hatch for the two method names the page calls
that the contract does not declare, so they keep failing the way they do.
FixtureTransport implements the same. state/gateway.ts holds the installed
transport for the callers the next steps rewire.

UI actions stay outside the transport and are driven by its states in the
integration step: the reconnecting status line, the reload on a moved build,
the upgrade shade, the auth failure banner and the desktop-shell reauth.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
… reassigning across files

The concatenated page script reassigned 28 bindings declared in other parts,
the one thing an ES module graph cannot express: an imported binding is
read-only. Two shapes, two answers.

Four names are function declarations another part replaced wholesale (extSet,
showPage, closeDetail in demo/120-capabilities.js; drawCaps in
demo/152-skills.js). Each becomes a function-declaration entry point that
stays callable by its old name, a `var <name>Decorators` registry, a
`decorate<Name>(wrap)` registrar and the old body as `<name>Base`, with a
shared applyDecorators() in demo/010-kernel.js reducing in registration
order so the last registrar wraps every earlier one -- the order the
capture-and-replace chain produced. The registry is a `var` with no
initialiser because demo/120 -> demo/152 is an edge of a 14-file cycle, so
152 can register before 120's statement has run and an initialiser would
discard it.

Nine `let` names whose writers are all in their own layer move onto an object
the declaring file owns: runState.use, capFilter.kind/.query, park.turnOwner/
.lastAsk, staged.model/.tier/.perm, setupState.providerConfigured.

APP_VERSION and HOST_PLATFORM are written from the OTHER layer, and a field
the live layer writes is a strand count-shared-globals.mjs counts. They keep
their `let` and gain a setter verb in the declaring file, which is the shape
demo/010-kernel.js already uses for LANG.

Five sandbox tests inject some of these names into a Function harness and
follow the rename. The analysis scripts the change was driven from are kept
under scripts/codemod/ with their baseline boot snapshot and dist digest.

Verification: `npm run --prefix ui-web build` + `python3 ui-web/build.py` +
`npm test --prefix ui-web` (108 files, 1836 cases, 0 failures, same as
before); boot snapshot of dist/index.html at ?stub=1 byte-identical;
`node ui-web/scripts/count-shared-globals.mjs` still 0/0/18;
`node ui-web/scripts/codemod/writes.mjs` now reports 0 cross-file
assignments; the island call order of all four decorated verbs recorded
before and after is identical.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
src/seam, src/demo and src/live become src/legacy/{seam,demo,live}. The 49
files are unchanged, so dist/index.html is byte-identical.

The layer names "seam", "demo" and "live" stay the argument every caller
passes: build.py's _concat maps them to legacy/<name> internally, which is
what keeps the manifests and the two Python tests outside this directory
working untouched. count-shared-globals.mjs gains a `legacy` base for the
layer reads while shell/bridge.ts stays under src/.

Twenty test files read the layer sources by path: sixteen scripts/*.test.mjs
and four src/features/**/*.test.ts (the plan counted only the .mjs ones).
Eleven more files name a moved file in prose and follow it.

Verification: `python3 ui-web/build.py` then
`shasum -a 256 ui-web/dist/index.html` gives
7bee82640cef7ac90f367309a606e6f462f46b62b2abb740f312bc22b166fd76, identical
to the pre-move build; `npm test --prefix ui-web` 108 files / 1836 cases / 0
failures; `node ui-web/scripts/count-shared-globals.mjs` 0/0/18; boot
snapshot identical; `uv run pytest tests/test_ui_language_repaint.py -q` 11
passed.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…ith an explicit install order

The 49 parts under src/legacy/ were one script whose concatenation order was
load-bearing. ES modules do not honour that order -- each evaluates when the
graph first reaches it, depth first -- and both layers contain a cycle (14
files in demo/, 13 in live/), so the order had to stop mattering rather than
be preserved.

It does. Every module body now only declares; everything a part used to do
while the script ran is in its install(), and src/legacy/index.js calls those
in the manifests' order, the live half only under liveMode() -- the `return`
the live IIFE used to open with, which is what still keeps ?stub=1 and
file:// on fixture data. main.tsx imports installLegacy and calls it as its
last statement, exactly where the third inline <script> used to sit, so every
window.X it publishes exists by then.

A declaration whose initialiser is impure or order-dependent becomes a bare
`let name;` plus an assignment at its original position inside install(): 45
of them. Exports are one trailing `export { }` per file rather than `export`
prefixes, because the Python test outside this directory and
count-shared-globals.mjs both find declarations by line-start form. 267 import
statements bind 536 names; the names main.tsx hangs on window stay globals in
this step.

install() bodies keep their statements' original column. Six sandbox harnesses
slice a part out by text and evaluate it, and re-indenting would also rewrite
every multi-line template literal a moved statement carries; the indent comes
when those harnesses stop reading text.

demo/010-kernel.js imports i18n/messages.json instead of carrying build.py's
marker, taking its slash and ui keys. build.py stops concatenating and stops
inlining the catalogue, keeps the three manifests and _concat for the tests
that read them, and page.html loses the third <script>; check-page.mjs expects
two blocks.

One implicit global write had to go: demo/150-chrome.js's `sTab = 'model'`
landed on window in sloppy mode and throws in a module, so it is spelled
window.sTab, the way the same slot is written in demo/130-settings.js and in
features/settings/store.ts.

Two new gates, both vitest so `npm test` runs them, both shown to fail on a
mutation first: scripts/legacy-shape.test.mjs asserts every top-level
statement is an import, a declaration, install() or the export block, and that
no top-level initialiser reads a binding from its own strongly connected
component (Tarjan over the real import graph, not a hand-kept leaf list);
scripts/legacy-undef.test.mjs is no-undef over the layers using the
TypeScript API -- eslint is not a dependency and this does not add one -- with
a second pass that rejects any write to a name the part does not declare.

Verification: `npm run --prefix ui-web build` + `python3 ui-web/build.py` +
`node ui-web/scripts/check-page.mjs` (2 scripts, both parse);
`npm test --prefix ui-web` 110 files / 2036 cases / 0 failures (108 / 1836
before, plus the two gates' 200); `npm run type-check`, `gen:check`,
`check-css`, `check-class-namespace` and `count-shared-globals` (0/0/18) all
pass; `uv run pytest tests/test_ui_language_repaint.py -q` 11 passed. The
happy-dom boot snapshot of dist/index.html is byte-identical to the pre-A1
baseline at ?stub=1 (428 nodes) and to the pre-conversion build at
?stub=1&onboard=demo (435), ?stub=1&desk-demo=1 (428) and in live mode with no
gateway (286 nodes, one ECONNREFUSED and no other console output, the same as
before).

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…revision they read

Seven of the scripts under scripts/codemod/ measure the legacy layers as one
concatenated text with the live half inside one IIFE, which is the shape
src/legacy/ no longer has. Run against the modules they died on a null
dereference instead of explaining themselves. They are kept as the evidence
the conversion was planned from, so they now check for the module era and
name the revision they apply to, plus the two gates that read the module
graph.

Verification: each of the seven exits 2 with that message against the current
tree, and deps5.mjs run against a checkout of the commit before the
conversion still reports its 536 bindings, 267 edges and two cycles.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…ad of source text

Eighteen harnesses read the layers under src/legacy/ as text: six evaluated a
whole part inside `new Function` with its collaborators as parameters, and
twelve sliced a function body out of the concatenated layer by brace matching.
The parts are ES modules now, so each harness imports the part it is about and
calls install() where the behaviour it asserts on lives, with the collaborators
replaced per part and per export (scripts/legacy-part.mjs) and the page's window
names published as globals.

scripts/rpc-connect.test.mjs is deleted rather than ported: its five cases
already stand in src/rpc/wsTransport.test.ts, against the typed transport.
scripts/legacy-source.mjs goes with the harnesses that used it, and
scripts/codemod/boot-snapshot.mjs was a stale copy of scripts/boot-snapshot.mjs.

With no harness anchored on a column any more, install()'s body is indented
(scripts/codemod/a3b-indent-install.mjs, 2168 lines across 49 parts), leaving
every line whose start lies inside a multi-line template literal or a block
comment where it was; the run refuses to write if any literal's own text moved.
The two shape gates take their list of parts from src/legacy/index.js, so
build.py's manifests are read only by the Python test outside this directory.

Verification: `npm run build && python3 build.py` (boot snapshot OK, 427 nodes),
`npx vitest run` 2092 passing in 113 files (2097 in 114 before, less
rpc-connect's five), `npm run type-check`, `node scripts/count-shared-globals.mjs`
0/0/18, `node scripts/check-page.mjs`, and
`uv run --frozen pytest tests/test_ui_language_repaint.py -q` 11 passed.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…he typed transport

main.tsx installs a WsTransport as the page's gateway in live mode, before the
legacy layer is installed, and the layer speaks through it: 141 `gateway().call`
sites over 106 method names, twelve `gateway().on` registrations replacing the
`rpc.notify[name] = fn` table (one handler per name, as the assignments already
were), one `gateway().binary`, one `gateway().connect`. Stub mode installs no
transport and needs none -- the live half is the only caller of gateway() and
legacy/index.js does not install it there.

live/020-rpc.js keeps only what paints: the upload refusals, SHELL/SURFACE, the
shell reauth handshake, authFail, bootFail, and the reconnect the reader sees,
ported from the deleted rejoin loop onto the transport's connection state --
the status line on the drop, the upgrade shade from the first failed retry
under the same guard as before, the dist-moved reload or the handler registry
on the way back, the banner when the transport gives up. The `rpc` object is
gone.

Two names the contract does not declare (raven.mcp.list, raven.mcp.set) go
through callUnchecked, so they answer -32601 exactly as before. The composed
`'model.' + op` is now the four literals the settings pane can ask for, which
is what lets scripts/legacy-rpc-names.test.mjs hold every literal in the layer
to RPC_METHODS; the two unchecked names are listed there and asserted absent
from the contract. The gate was verified by misspelling a call, which it
reported by file and line.

SURFACE goes back to being derived where SHELL is read. The es-module
conversion had left its initialiser at the top of the file while SHELL moved
into install(), so the desktop shell had been announcing itself as `page` in
system.hello since that commit.

`// @ts-check` stays off on all six candidate files: they produce 147
(080-overrides), 100 (120-settings), 91 (050-turn), 81 (230-tabs), 35
(240-external-agents) and 24 (165-knowledge) errors, almost all implicit-any
parameters and the window names and untyped DS entries that A6 and A5 remove.
None of them are about a method name, which is what the new gate covers.

Verification: `npm run build && python3 build.py` (boot snapshot OK, 427
nodes), `npx vitest run` 2095 passing in 114 files, `npm run type-check`,
`node scripts/count-shared-globals.mjs` 0/0/18, `node scripts/check-page.mjs`,
`uv run --frozen pytest tests/test_ui_language_repaint.py -q` 11 passed, and a
happy-dom boot of dist/index.html at http://127.0.0.1:18792/ with no server:
285 nodes, byte-identical to the same probe before this change, no page error,
and the refused socket as the only console error.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…window object

The seam a page's renderer reads its data through was an untyped bag on
window: the demo layer published it, both layers installed into it by string
key, and every island reached it back through `window.DS`. It is now
src/state/sources.ts -- one member per domain, each typed by the island's own
XxxSource interface, with setSources/resetSources for the callers that install
one.

The 22 domains are the union of what the two layers install: 18 both register
a fixture for and overwrite live, plus artifacts and composer (the offline
shell's alone) and knowledge and model (the live layer's). capabilities has no
island behind it, so its two verbs are declared beside the interface.

src/legacy/seam/000-datasource.js is gone with the layer it was the only part
of; build.py loses its one-entry manifest and scripts/codemod/a3-index.mjs
regenerates src/legacy/index.js from the remaining two. Five modules that had
hand-rolled a window.DS read of their own (shell/prose, features/browser,
features/model, features/subagents, features/transcript) and knowledge/store,
which hand-rolled a globalThis.DS one, go through the module or through ds().

Behaviour change, one: the knowledge page's missing-source line is now ds()'s
"DS.knowledge is not installed" rather than its own wording, which is what
reaching the seam through the shared accessor costs. It is only reachable with
no knowledge source installed, which is the offline shell.

Verification: `npm run build && python3 build.py` (boot snapshot OK, 427
nodes), `npx vitest run` 2091 passing in 114 files (2095 before, less the four
per-file gate cases the deleted seam part had), `npm run type-check`,
`node scripts/count-shared-globals.mjs` 0/0/18, `node scripts/check-page.mjs`,
`node scripts/check-css.mjs`, `node scripts/check-class-namespace.mjs`,
`npm run gen:check`, `uv run --frozen pytest tests/test_ui_language_repaint.py -q`
11 passed, and a happy-dom probe of dist/ in live mode with no gateway
listening: the settled DOM is identical to the pre-change build's 285 nodes and
the console carries only the refused connection.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…tead of window

The last three seams between the page's two halves were window properties: the
island bag (three literals in main.tsx), the shell bridge the legacy chrome
published, and 28 scalars main.tsx hung there for the layers to call by name.
All three are imports now.

src/islands.ts owns the bag as one exported object, with the two host nodes the
skills and plugins roots render into; the 143 RavenIslands call sites across 31
legacy parts import it. shell/bridge.ts gains setShell and resetShell and holds
the shell in a module variable; demo/155-bridge.js hands its half in from
install(). Each of the 25 scalars with a caller becomes an import of the real
export under the same local name, so the call sites read as they did; md,
toggleTheme and workGlyphSvg had no caller and are gone.

Four smaller bridges went with them. window.sTab is src/state/settingsTab.ts,
which the chrome and the settings island both import. window.persistPermMode
inverts: shell/perm.ts exports setPermPersister and live/120-settings registers
the write-back. window.__liveBoot is a flag demo/160-boot.js owns with a
claimBoot() the live guard calls, rather than a container one layer declares and
the other fills. The four __x devtools hooks stay on window, which is the only
place a console can reach them: __approve (demo/040), __clarify and __upnote
(live/190) and __dag (live/240), each in its own commented block.

Two island stores read the bag from inside the bundle. They are handed what they
need at the foot of islands.ts instead (setAgentPane, setDeskOpener): the desk
imports both of them back and subscribes to one as it evaluates, so importing
the bag from them ran that subscription against a half-built module.

The harnesses follow: loadPart takes an `islands` option and `fakes` keys
outside src/legacy/, and its `globals` option is gone with its last caller.
scripts/legacy-undef.test.mjs no longer reads a published-name list -- its
allowlist is browser globals plus webkit and __ASSETV, so a former seam name
that stayed free now fails it.

Verification: `npm run build && python3 build.py` (boot snapshot OK, 427 nodes),
`npx vitest run` 2091 passing in 114 files, `npm run type-check`,
`node scripts/count-shared-globals.mjs` 0/0/18, `node scripts/check-page.mjs`,
`node scripts/check-css.mjs`, `node scripts/check-class-namespace.mjs`,
`npm run gen:check`, `uv run --frozen pytest tests/test_ui_language_repaint.py -q`
11 passed, and a happy-dom probe of dist/ in live mode with no gateway
listening: the settled DOM is identical to the pre-A5 build's 285 nodes and the
console carries only the refused connection. `git grep -n "window\." -- ui-web/src
ui-web/scripts` outside tests leaves browser APIs, the webkit desktop bridge,
__ASSETV and the four devtools hooks.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
The production artifact is unchanged: vite build still emits one classic IIFE
bundle and build.py still inlines it into page.html as a single script. The
dist bytes are identical to the previous commit's.

What this adds is a developer path over the same source. vite.config.ts is now
a function of {command, mode}: build keeps today's lib config, mode 'test'
keeps exactly what vitest sees today, and serve gets a server block. The dev
server binds 127.0.0.1, serves src/page.html with hot reload, and proxies
/rpc (ws, rewriteWsOrigin), /files, /file, /knowledge/file, /health, /auth and
/oauth/callback to a local `raven serve`, whose port it reads from
~/.raven/serve.json and falls back to 18792. Same origin, so the session cookie
and the ws handshake both work.

A serve-only plugin turns page.html into a dev entry in memory: the style
marker becomes a link to src/styles/page.css, the asset digest becomes "dev",
and the script marker becomes a module entry on src/main.tsx. page.html itself
is untouched, so build.py keeps splicing the same two markers. One middleware
points /src/assets/raven.svg at ui-web/icon/raven.svg, which is where the build
copies it from.

The dist ETag watcher in live/210-update-notice.js is now built-page-only:
under the dev server '/' is the dev server, and every hot update would read as
a new build. The click handler stays wired, because the version notice sharing
that row comes from the gateway.

The no-undef gate counted the `meta` in `import.meta` as a free identifier;
it is syntax, and is now skipped like the other non-reference positions.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…t shape

The boot snapshot took one mode. A live-only boot break -- the SURFACE/SHELL
regression A3 introduced was one -- passes an untouched stub snapshot, so
boot-snapshot.mjs now takes --url and --golden and build.py runs it twice: the
demo shell on its fixtures against boot-stub.txt (427 nodes), and live mode
with no gateway answering against the new boot-live-noserver.txt (285 nodes).
Offline the refused connection is the expected condition, so only a broken
program fails that run: a ReferenceError, TypeError or SyntaxError that is not
itself a network failure.

build.py writes dist/index.html with newline="" so a Windows checkout emits the
same bytes as CI and the wheel, matching the same fix on the other line.

The codemods and analysis scripts that did the conversion are spent and gone.
The one generator that is not -- the index that pins the install order -- moves
to scripts/legacy-index.mjs and keeps regenerating src/legacy/index.js
byte-identically; build.py and the shape gate point at it instead of the
deleted directory.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…words

The two composer shape snapshots were taken with the test's zh fixture
catalogue, so the new .snap file carried CJK tooltip text and the repo's
source-language gate refused the PR: ui-web is not an exemption zone, and a
new file has no carrier pass. The cases now switch the fixture language to
en before mounting, which the file's afterEach already resets.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
ruff's S607 refuses a subprocess started from a partial executable path, so
build.py resolves node through shutil.which and says plainly when it is not
on PATH instead of letting the boot snapshot gate fail on a missing binary.
The trailing-whitespace hook also trimmed one line a snapshot case added to
the transcript page test.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…t pushes

Version tolerance had eleven homes and the push names had none. Both get one.

rpc/capabilities.ts takes the -32601 registry that was private to
live/220-browser.js -- three other parts imported the browser source to reach
it -- and adds the eleven "this gateway is older than this page" conditions as
named predicates, each one moved from its site unchanged. system.hello's
server_capabilities is absorbed there too, and the transport records every
-32601 against the name that drew it.

rpc/notifications.ts is the hand-written table of the eleven pushes: the ten in
raven/acp/updates.py's SIDE_CHANNEL_METHODS plus browser.frame, the base64
screencast an older gateway sends instead of a binary frame. The transport's
on() takes those names plus the subscription envelope, so a misspelt one is a
compile error wherever the caller is TypeScript.

Behaviour is unchanged at every site. Three of the eleven describe a tolerance
that has no runtime condition today -- the subagent.delivered no-op arm, the
12s naming backstop, and the base64 frame registration -- so they are named and
documented but nothing consults them: narrowing any of the three would change
what the page does, which this stage does not do.

Gates: scripts/notifications-contract.test.mjs reads the Python frozenset
directly and also holds every gateway().on() name in the legacy layer to the
table; src/rpc/capabilities.test.ts covers the eleven predicates, the registry
and the handshake list. Both were shown to fail first.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…dule

Ten domains -- cron, connections, memory, knowledge, playbooks, skills,
plugins, capabilities, onboard and external agents -- had their calls and
their wire-to-page mappings inside the legacy live layer, where nothing type
checks them and nothing could test a mapping without slicing the layer's text.
Each now has features/<domain>/source.ts, written against the island's own
XxxSource interface with its row shapes derived from ResultOf<>, and the live
part is one line that installs it.

The mappings that carried a rule are exported pure functions with a test
each: cronToRow and jobToSave (including the DST arithmetic a one-shot job
crosses), the four ext.list row builders, pmNormEntry, xaRowOf with the
probe-status carry-over that keeps a probe-less refetch from blanking a health
line, and the channels.status merge.

The twelve-entrance catalogue moves to features/connections/catalogue.ts: it
is production data that lived in the demo fixture table, and the live source
read it from there. It still merges onto those same row objects rather than
returning new ones -- both sources answer with them and write onto them, which
is what makes a demo edit survive a redraw and a background reload keep the
rows the reader is looking at.

Settings no longer reaches into two other islands' stores: connections grows a
nav.ts for the one verb another feature may call, and the session count and
delete-all read the seam directly.

Three shapes the seam disagreed about, each a type change with no behaviour
behind it: KbDoc.origin and ToolRow.needs now say what the contract says, and
memory.list keeps sending q as null with a note on why the generated type
cannot spell it.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
Seven more domains leave the legacy layer: settings, model, tier, banner,
prose, workspace and browser. Unlike the ten before them these hold state --
the raw config, the provider list, the default model pair, the tier menu, the
session working directory -- so the state moves with the calls into
features/settings/source.ts, features/model/source.ts,
features/workspace/source.ts and features/browser/source.ts.

Two page-level facts the sources share get a module of their own. The view
generation ticket, which drops a refresh whose page the reader has left, is
state/session/generation.ts: the three readers that check it are in the
sources now and only the two view switches spend one. The staged model, tier
and permission mode a draft picked before it had a conversation stay in the
override part, where both paths that abandon a draft reset them, and the
sources reach them through the interface in state/session/staging.ts. B5
replaces the implementation behind both without touching a reader.

What is left in live/120-settings.js is page chrome no island owns yet: the
composer model chip, the language flip's whole-page redraw, the boot language
restore, and the update check. It publishes the three of those the settings
source needs through one SettingsChrome seam, and it has left the live layer's
import cycle, which is now six files rather than seven.

The 27 cases of scripts/model-refresh-live.test.mjs move to
features/model/source.test.ts with their assertions unchanged, plus one for the
session-scope write that had no case. New tests cover the four path resolvers
and the host check the prose chips lean on.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
Stage B step B4, the precondition for the session-runtime rewrite: no
production code changes apart from three new forwarding modules.

state/session/{runtime,registry,pipeline}.ts export the names the rewrite
will keep -- switchTo/park/resume/bySubscription, duration/finishTurn/send/
stop/drain, dispatch/stream/notify and the four side-channel entries -- and
each body is a one-line forward to today's page layer. So the eleven
harnesses that move onto them keep their assertions verbatim, and the
rewrite can replace the implementation without touching a test.

The 23 behaviours the inventory found untested are pinned as
characterisation, each one read off the current source first: one case per
onEvent arm, the cancel race and the four drain sites, the notification
owner resolution and the 4000-frame buffer cap, parking by owner and the
restore order, the rail row shaping and the list re-read, the session
actions, the seven external-agent writes, the workspace turn count and the
two-second sub-agent heartbeat. Two are labelled as what they are rather
than as intent: the dead cron.started/cron.finished arms the rewrite
deletes, and the clarify answer that marks the open conversation's step
instead of the asker's, which stays a bug for this stage.

Cases 1991 -> 2079, the 88 added being the characterisation above:
grep -rhoE "^\s*it(\.\w+)?\(" src scripts | wc -l

Verified in ui-web: HOME=$(mktemp -d) npx vitest run (123 files, 2275
passed), npm run type-check, npm run gen:check, npm run build && python3
build.py (boot snapshots 427 and 285 byte-identical to the golden), node
scripts/count-shared-globals.mjs (0/0/18), node scripts/check-page.mjs.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
A turn belonged to the page: one `live` object held the open step, the say
buffer, the calls in flight and the clocks, and four more page-level names
answered "which conversation is this frame about" -- `live.subId`, `turnOwner`,
`viewGen`, and two dispatchers that took an owner as an argument. Leaving a
conversation mid-turn filed seven things in a parked-turn map, the first of
which was every child node of `#stage`.

Now each conversation is a `SessionRuntime`: its phase, its steps, its queue,
its naming wait, its staged model, tier and permission mode, its working
directory and the DOM host its transcript lane lives in. A frame off the socket
names its subscription, the registry answers with the runtime, and that runtime
either is the one on screen -- in which case the stage table applies the frame
-- or is holding a turn off screen, in which case the frame is buffered and
replayed on the way back in. The residency rule is the one the page always had:
a conversation with no turn running keeps nothing and re-reads itself from disk.

The 23-arm dispatcher is an ordered stage table with one exhaustive switch, so
a member added to `TurnEvent` is a compile error rather than a frame the page
drops in silence. The three the contract declares and the page does not draw
have a stage that says so. The two it handled and nothing ever sent are gone,
with their characterisation cases:

    git grep -n "cron\.started\|cron\.finished" -- raven rpc-schema

finds no emitter and no contract entry.

Nine live parts are absorbed. What is left in each is the wiring it installs:
the rail's data and its two self-writes are features/rail/source.ts, the three
writes that also move the reader are features/rail/leave.ts, the delegation
verbs and the tool-result pair are features/transcript/source.ts, the sub-agent
roster is features/subagents/source.ts, and the turn is src/state/session/.

Behaviour is unchanged, including two things that are wrong: a clarify answer
still marks the step of the conversation on screen rather than the one that
asked, and the untitled sentinel is still a hardcoded literal beside its
translation. Both are on the design's issue list. One thing did change by
deletion: the retry text a failed send offers is the conversation's own now, so
a retry pressed after a switch can no longer send one conversation's message to
another.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…d of a second source layer

The demo layer's twenty fixture sources, each in its island's own shape,
become one library of contract-shaped responders behind the transport:
src/rpc/fixtures/ answers 96 methods across fifteen domain files, typed by
ResultOf<> and fed from an injected now(). ?stub=1 and a page opened from
disk choose FixtureTransport over WsTransport (src/state/transport.ts) and
then install the same parts and read the same sources the live page does, so
there is one data path and one set of renderers in both modes.

The scripted conversations move with it. 030-fixtures' two scripts and
080-replay's scheduler become a turn.send responder that pushes event frames
on the transport's own timer, so an offline turn plays out over time through
the same stage table a live turn does, and session.resume answers the same
script read back as stored messages.

?onboard=demo and ?desk-demo=1 become per-domain override groups over
whichever transport was chosen (OverrideTransport), so both keep working on
a live page as well as an offline one.

Deleted: demo/020-prose, 030-fixtures, 080-replay and 112-browser whole;
the fixture halves of ten more demo parts; live/230-tabs' desk-demo block;
150-chrome's model-chip menu, which was built from the fixture provider table
and replaced on every page by live/120-settings.

New gates: fixture-shape (every responder's answer walked against
openrpc.json's own required lists and types, canvases included) and
fixture-now (two libraries born on one instant answer byte-identically).
Both found drift the demo fixtures had carried: null where the contract
declares an array, and eleven results missing a required field.

Cases 2,282 -> 2,269: sixteen per-part gate cases went with the four deleted
parts, three fixture-only cases were deleted with the code they pinned
(the demo playbook shape gate's two, and the capabilities test's fixture
manual-add), and six new gate cases landed.

The ?stub=1 boot snapshot is the one gate this step is allowed to move, and
it drops from 427 to 284 nodes in four differences, each a consequence of the
offline page taking the live boot path: .chat gains data-fresh=1 (the page
opens on the new-task screen, as live boot's switchToDraft does, instead of
the demo shell opening a canned session); #stage's replayed transcript and
#bannerHost's websearch banner are gone (the banner source is the live one
now, which refuses that notice by design; the conversation is one rail click
away through session.resume); and #ctxChip loses its data-tip, which was read
off the replayed run's usage. The live-no-server golden is unchanged at 285.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…stage b

The live half of the legacy page script was 26 files whose whole body was an
install(): a source onto the seam, a handler onto the transport, or a step of
the boot. It is four modules now.

- state/boot.ts: the claim on the first frame and the gateway sequence, from
  live/010-boot-guard.js and live/200-boot.js, plus the session source the rail
  is held on -- one object, with pin and deleteAll as members rather than two
  later installs. main.tsx calls boot() after installLegacy().
- state/install.ts: the wiring, as four lists in the manifest's order -- every
  domain's source onto the seam, the seven pushes that are not a turn's, the
  composer and rail actions, the three dev hooks.
- state/connection.ts: what a connection looks like to the reader, from
  live/020-rpc.js (git records it as the rename), and the desktop shell's ready
  and reauth handshakes.
- state/updates.ts: the rail-foot notice and the upgrade, from
  live/210-update-notice.js.

Only live/120-settings.js stays: it holds redrawAll and the language chrome
that tests/test_ui_language_repaint.py reads by source text, so the file name
and the _LIVE_PARTS entry stay until stage C removes the test and the chrome
together.

Beside that:

- features/workspace/record.ts takes the panel record from demo/100-workspace.js
  and demo/110-subagents.js -- wsOnTool, wsOnToolDone, wsOnHistory, wsArgs,
  wsRecordChange. The panel chrome (setWs, wsPick, wsView, bumpWs, drawWs) stays
  for stage C. This breaks the last three-member cycle in demo/, so
  legacy-shape's known cycles go from [3, 2] to [2].
- demo/040-state.js binds the island verbs by import rather than by one
  destructure of the bag in its install(), which retires the hand-written
  040-state.d.ts: the compiler infers every one of them now. The bag still
  carries the twenty-one that no longer have a reader; stage C deletes it.
- The unused RavenIslands alias goes, registry.ts's two imports of ./runtime
  merge, and eleven comments that pointed at a deleted file now point at its
  successor.

Behaviour, DOM and styles unchanged: both boot snapshots still match (284 and
285 nodes), the 31 island snapshots are untouched, count-shared-globals is
still 0/0/18, and tests/test_ui_language_repaint.py plus
tests/test_ui_agent_marks.py stay green. Cases 2,269 to 2,171: the two per-file
shape gates lost 25 files (4 cases each, -100) and boot-order gained 2.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…ore the chrome rewrite

Stage C moves every top-level region of src/page.html into App.tsx, one PR
at a time, and the promise is that the DOM does not move. Three gates are
added first, all of them reading today's tree, so that each later step
changes what is asserted rather than what is expected.

- src/test/domSnapshot.ts gains bodySiblings(), which signs the body's
  direct children, and regionSnapshot()/elementSnapshot(), which include an
  element's own line. domSnapshot() itself is untouched, so the 31 island
  snapshots do not move.
- src/test/regions.test.ts writes one golden per region, from page.html's
  body parsed without running a script. The goldens live as plain text
  under src/test/__golden__/ rather than as vitest snapshots so that -u
  cannot reflow them, and the writer refuses to write under CI and refuses
  to overwrite unless UPDATE_REGION_GOLDENS=1.
- src/test/portals.test.ts records the thirteen body-level hosts with their
  kind, z token and place, and checks that place against both boot goldens.
  Two steps of the z ladder are ties broken by DOM order alone, so the
  order is a contract with nothing else guarding it.
- src/state/overlays.test.ts records the fourteen-layer Escape order and
  asserts it against the chain's source text, plus the three capture-phase
  keydown handlers that run before it.

Each gate was shown to fail on a one-byte change to what it pins -- a
golden, a swapped pair of chain branches, a changed body position -- and to
pass again once restored. No product code is touched.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…f the legacy kernel

Stage C step C1. i18n/messages.json and the T() lookup are src/i18n/t.ts now,
and the language itself is src/state/lang.ts: the store holds `applied`, writes
<html lang>, repaints the static markup and notifies its subscribers. The
legacy kernel keeps LANG, T, fillVars and slashText/Name/Help as re-exports and
langSet as a one-line shell, so every part importing them is untouched.

The nullable `applied` is what keeps the first frame identical. A page nobody
has picked a language for never runs the repaint -- the fixture config carries
no `language` key and a page with no gateway never gets that far -- so it sits
on page.html's Chinese literals under lang="zh-CN" while T() answers English.
text(key, literal) hands back the literal in that state, which is what the
components later steps convert will render.

redrawAll stays where it is and becomes a lang.subscribe subscriber, so a pick
repaints once and the rollback of a failed config.set repaints once. langRestore
gets its own setter, setQuiet: it applies the remembered language ahead of the
first data-driven paint and has never repainted anything.

The three readers of document.documentElement.lang -- shell/platform.language,
MemoryPage's memWhen and the settings dialog's language radio -- read lang.tag(),
which answers the document's own declaration while nothing has been applied, so
each keeps today's answer in both load modes and in a bare test document. The
two components subscribe with useSyncExternalStore.

Three pieces of C1 are deliberately not done; the plan block records why.
applyI18n is deleted rather than shelled (no importers, and a shell would need
a second writer of <html lang> that leaves `applied` behind). bridge.ts's t()
still forwards to Shell.T (about forty test files install a fake T and assert on
keys). rawVerb, callParts, verb, verbIng, COPY_ICO, tipFlash and dur stay in the
kernel, each already having a live twin in features/transcript or shell.

Verification: npm run build plus build.py (284 / 285 nodes), 2,212 cases in 130
files (was 2,203 in 129), npm run type-check, npm run gen:check,
scripts/check-page.mjs, scripts/count-shared-globals.mjs (0/0/18),
scripts/check-css.mjs, and pytest tests/test_ui_language_repaint.py
tests/test_ui_agent_marks.py (18 passed). The booted page dumps the same
<html lang> and the same text for all 66 data-i18n elements as HEAD does, in
both load modes and with the remembered language unset, 'en' or 'zh'.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
… the open page a store

The three dialog regions -- the confirm sheet, the shared detail drawer and the
settings frame -- render from a new src/App.tsx instead of from page.html's
markup. The root is a DETACHED element and each interior portals into the
container page.html still carries, because createRoot(container).render()
clears that container's own children on its first commit: a root at
document.body deletes the splash and every static region (measured in
happy-dom against this repo's react-dom). The commit is flushSync and happens
before everything that reads it -- the chrome binds #cfNo, #cfYes, #setClose
and #dClose by id while it installs, and the settings island looks #snavList up
while it renders.

#splash and #noJs stay in page.html for good: both are pre-JavaScript shells,
so neither can be something React puts on screen. hideSplash moves out of the
legacy boot part into src/state/splash.ts instead, with its 600ms default and
the load handler's 250ms unchanged.

showPageBase becomes src/state/page.ts: the same seven data-open writes, the
same scroll reset, app mark, rail mark and overlay closes, plus the field the
DOM used to be asked for. The two layers that decorated showPage subscribe
there now, registered in the order the decorator reduce made their side effects
visible in. NAV_OF stays in the legacy part and is imported, so the table has
one home.

Zero pixel and zero behaviour change: both boot goldens (284 / 285 nodes), the
nineteen region goldens, the thirty-one island snapshots and the body's
standing order are all unchanged, and a happy-dom dump of the three regions'
innerHTML in both load modes differs only in whitespace text nodes that a flex
or grid parent never rendered. The one measured difference is registration
order: react-dom's document-level selectionchange listener moves from eleventh
to second, because the first createRoot call is now this root.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…ponent

Four islands drew into the same #detail dialog by id. Each cleared #dBody with
innerHTML, blanked #dTitle and wrote data-open for itself; two of them watched
the element with a MutationObserver to learn that one of the others had closed
it; and the close verb in the legacy layer was wrapped by two decorators whose
reduce decided the order their effects landed in. The drawer's state was a
reading of the DOM, with four writers and no declared order.

New src/state/detail.ts owns it: the owner, the open and fill flags, the title,
an opens counter, one host element per owner, and the fade that lets a card
outlive the flag it closed on. shell/detailfade.ts folds into it and is deleted.
App.tsx's DetailPanel subscribes for the title and holds the close button and
the scrim, which the chrome used to bind three and one times over. The two flags
and #dBody's one host stay imperative writes, because aside#detail is static
markup until the end of stage C and React must not own that child list.

Close order is now declared rather than derived from who registered first:
plugins, skills, memory, xa, then the flag write, which is the order the
decorator reduce and the two observers produced. The four islands stop touching
the shared ids and the two observers go.

Zero pixel and zero functional change. The served interior of #detail is
byte-identical in both load modes (happy-dom boot dump against the previous
commit's build), both boot goldens still match at 284 and 285 nodes, and the
region golden, the 31 island snapshots and every island test assertion are
unchanged. One transient is deliberately gone: a memory or agent card opened
inside the fade of a skill or plugin card no longer inherits its fixed panel
height, because the flags now have a single authoritative writer.

Gates: 2,251 vitest cases in 134 files (+14, all new -- state/detail.test.ts
pins the host, the title, the fill asymmetry, the close order and the fade;
App.test.tsx pins the title blanking and the two closers), type-check,
gen:check, check-page, count-shared-globals 0/0/18, check-css,
check-class-namespace, and tests/test_ui_language_repaint.py.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
… the toasts onto stores

The confirm sheet, the settings frame, the context menu and the notices were
four DOM writers reaching into the page by id: confirmAsk wrote three elements
and parked its callback in a module let, openSet and closeSet read and wrote one
attribute on the veil, and the menu and toast writers built their nodes by hand.

Each is a store plus a component now. state/confirm.ts holds the question and
the answer, state/settingsDialog.ts whether the dialog is up, shell/menu.ts the
rows and the host they were raised in, and shell/toast.ts the notices still up;
src/App.tsx renders all four into the containers src/page.html still carries.
The flags, the menu's position and the focus stay imperative, because those
containers are not React's until C14, and each store commits with flushSync, so
a caller that measures the result or clicks into it still finds it there and the
order of the writes is the order the old verbs made them in.

state/portals.ts is new: the ordered table of everything standing at the body
moves out of the test that read it, and host() hands out the four boot-time
layers in that order rather than in the order they are asked for -- two steps of
the --z ladder are ties broken by that order alone. scrollbars.ts and main.tsx
take their layers from it; the tooltip layer joins them in C13.

Zero DOM change: both boot goldens (284 and 285 nodes), the nineteen region
goldens, the thirty-one island snapshots and the document-level listener order
are unchanged to the byte, and the four regions' innerHTML dumps are identical
in both load modes.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…arch row onto a store

The rail's six children -- the two icon buttons, the nav strip, the search row,
the session list's ground, the foot row and the grip -- render from a new
src/chrome/Rail.tsx instead of from page.html's markup, portalled into the
aside.rail the page still carries. One file per region under src/chrome/, beside
features/ (the islands) and shell/ (behaviour modules).

setRail becomes src/state/rail.ts: the same three writes in the same order, plus
the flag the collapse shortcut used to read off the grid. button#railShow stays
static markup with its imperative click, because it is a top-level region of its
own and a body portal from the detached root would append it after every other
one.

shell/find.ts becomes the row's store. The box's hidden, the clear button's
hidden and the search button's aria-expanded are one state read three ways, and
the commit is synchronous so that a shown row is what takes the focus. The field
stays uncontrolled with its three native listeners: input and blur do not
bubble, the Escape key is stopped there so the document chain does not also take
a panel down, and a component owning it would re-render a text field the reader
is typing into.

Seven clicks move into the JSX -- the collapse toggle, the search toggle, the
clear button, the four module rows and the door to settings -- and the demo
layer's dead #newBtn handler is deleted, with the last three CJK literals that
file carried. state/install.ts had rebound that button after the demo install,
so the demo handler never ran; the new-task row's click stays there, where the
action it runs belongs. So do the writers of #moreFly's rows, the nav marks, the
foot's two slots and the update notice: React renders each of those as the
constant the page was served with, and it diffs against its own last props, so
a value it never changes is a value it never writes again.

Zero pixel and zero behaviour change. Both boot goldens (284 / 285 nodes), the
nineteen region goldens, the thirty-one island snapshots and the body's standing
order are unchanged; a happy-dom dump of aside.rail and #railShow in both load
modes differs only in whitespace text nodes that a flex or grid parent never
rendered -- ninety dropped, four reproduced in the one parent that is neither a
flex nor a grid -- with all fourteen text nodes that carry words byte-identical;
and the document-level listener order is identical in both modes.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…earch field

The capabilities page is one section serving two modules, and everything around
its body -- the heading, the search field, the status pills, the "installed"
entry point each tab rides in the filter bar, and the fold that registers a
server by hand -- renders from a new src/chrome/CapsPage.tsx instead of from
page.html's markup. Three portals rather than one: #capsBody sits between the
bar and the fold and is shared ground, so the seven containers stay static and
their interiors move.

src/state/caps.ts is what the tab and the filter bar are now: extTab, the two
write-only filter fields, the chrome each draw decides, and the draw itself. The
decorator chain the two tab layers wrapped drawCaps and extSet with is gone;
each part registers its own steps and this module declares the order they were
visible in, the way state/detail.ts declares the drawer's close order. What
page.html still owns is written by hand from there, with one writer each: the
section's aria-label, the fold's hidden flag, the bar's display, and #pageHero,
which cannot be a rendered child while .wrap's other children are the page's.

The three handlers the chrome hung on the bar become one dispatcher and two
clicks. #cq stays uncontrolled with native listeners, so the composition guard
reads the real KeyboardEvent, and the three layers stacked on its input event
dispatch on the open tab instead. The manual add is a store action the button
calls, and it goes on failing exactly as it does today: neither raven.mcp.list
nor raven.mcp.set is a declared method, so both take the transport's unchecked
path and the second is answered -32601.

The two installed buttons were two appends into the bar, so their order was the
order the parts installed in; they are React's children now, and a tab flip
cannot reorder them. The store commits synchronously, which is also what puts
them in the boot snapshot: React's own scheduler never flushes under happy-dom.
nlSay goes with the step, having had no caller since the B stage.

Zero pixel and zero behaviour change. Both boot goldens (284 / 285 nodes), the
nineteen region goldens, the thirty-one island snapshots and the document-level
listener order are unchanged; a happy-dom dump of #capsPage in both load modes
differs only in whitespace text nodes that no flex or grid parent rendered --
twenty dropped, four reproduced in the one parent that is neither -- and in the
hint's inline style, which React writes through the CSSOM: the three
declarations read back equal. A scripted walk over both modes -- open the page,
flip the tab five times, type, press Enter with a composition open, click a
pill, try the manual add -- is identical at all fourteen steps.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…act root

The chat column's chrome above the dock -- the session header, the scroller's
three grounds, the back-to-bottom pill's glyph and the wordmark -- renders from
a new src/chrome/ChatTop.tsx instead of from page.html's markup. Four
containers, four portals: div.dock is the column's last child and still
page.html's, and a portal appends, so one portal of five children into .chat
would have put the header under the dock. div#wsGrip is the fifth region and
keeps nothing but itself, because it has no interior to render.

shell/banner.ts becomes the notice's store and src/chrome/Banner.tsx its two
shapes. The decision stays in draw(): which notice stands is a priority rather
than a composition, and it reads the capability list, which throws when the
seam is not installed -- something the caller has to hear about and a render
must not ask. Asking for the host first is the same statement it opened with,
so a bench with no chat column still decides nothing and consults nothing.

The header's rename button is the one click that moves, into the JSX, and the
demo layer's binding of it is deleted. Everything else these elements carry
stays where it is written: seven modules write h1#title's text and one of them
swaps the whole heading for an input, the workspace panel writes the toggle's
state and the badge's count, the composer writes the pill's flag and label on
the container, and two islands append their own hosts into #stage. React diffs
against the props it rendered last rather than against the document, so a value
it never changes is a value it never writes again -- which is what the new
cases refuse to let slip: a flip of the language re-renders all four interiors
and none of those values moves.

Zero pixel and zero behaviour change. Both boot goldens (284 / 285 nodes), the
nineteen region goldens, the thirty-one island snapshots and the body's
standing order are unchanged; a happy-dom dump of the five containers in both
load modes has all forty-four blocks identical -- innerHTML byte for byte once
comments and inter-tag whitespace are collapsed, attribute order included, and
every text node that carries a word unchanged -- with the only difference being
forty-six whitespace-only text nodes that a flex, a grid or a run of block
children never rendered; and the document-level listener order is identical in
both modes.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…and keep the textarea native

The dock's four children -- the raven band behind the card, the sheet rack, the
card itself and the band in front -- render from a new src/chrome/Dock.tsx
instead of from page.html's markup, portalled into the div.dock the page still
carries. That band has to stay one node for a second reason beyond the portal
mechanism: the composer hangs a ResizeObserver and a MutationObserver on it, and
a node rebuilt per render would lose both.

textarea#ta stays uncontrolled with its four native listeners installed by id
from the composer island. React's onKeyDown would see the same composition
flags, but a component owning the value would write over a candidate the input
method has not committed yet, so the field's Enter is now pinned twice over: one
case for isComposing and one for the older keyCode 229 spelling. The palette's
Escape is pinned as well, against a sentinel listener on the document, because
that key is stopped at the field so that dismissing the menu does not fall
through to "interrupt the running turn".

shell/ctxchip.ts becomes the context ring's store and <CtxChip/> renders it. The
showing, the two warmth classes, the dash left to go and the one sentence the
hover pill and the accessible name share were five writes by id; they are one
state read five ways, and the commit is synchronous so that a caller which asks
for a draw and then reads the chip sees it. Both attributes stay absent rather
than empty until a window is known, which is how the page is served.

Not one click is the component's. The send button and the paperclip belong to
the composer store, the two mode chips to the popover writers the chrome binds,
the model chip to the live layer, and each of #sheetRack, #queued, #slashList,
#permList and #tierList is handed over with no children because another writer
fills it -- the rack most of all, since page.css gives the stack its frost
through `.dock .sheets:has(> *)`. The attachment tray is still inserted before
.field at runtime, and a test pins it there: none of the card's children is
conditional, so React never reconciles that child list and a node put between
two of them stays between them. That is also what makes the two popovers safe to
move to the body on first open, which has its own case.

Zero pixel and zero behaviour change. Both boot goldens (284 / 285 nodes), the
nineteen region goldens, the thirty-one island snapshots and the body's standing
order are unchanged, and the document-level listener order is identical in both
load modes. A happy-dom dump of the dock differs in whitespace text nodes that a
flex, grid or display:none parent never rendered -- sixty-two dropped, two
reproduced in the one parent that is none of those -- with every element's text
identical once whitespace is removed and all seven text nodes that carry words
byte-identical. One thing does not come across byte for byte: react-dom sets an
img's src after every other attribute, on purpose, so the five raven images
serialise with src last where the markup had it second. Nothing reads attribute
order.

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
0xKT and others added 4 commits September 21, 2026 19:06
## Summary

Second catch-up of `refactor/ui_web_architecture` with `main`, this time
to
`6ebc2baa` (#592): the 31 commits `main` gained since #529 took it to
`dbe259f6`. Synced main to `6ebc2baa`; the next round starts from that
SHA.

#529 landed as a squash, so git still saw the two branches diverge at
`5a29ea22` and re-raised every conflict #529 had already settled (68
paths
instead of 33). The first commit here merges `dbe259f6` with `-s ours`
--
tree unchanged, ancestry recorded -- so the second commit merges only
what
is new; a third takes #528 and a fifth takes #592 (READMEs only, no
conflict), both of which landed on `main` while the earlier commits were
being verified. The same steps work for every later round.

Thirty-three paths conflicted in the second commit, one in the third;
the
fourth commit is what an independent read of the merge turned up (the
sheet's Connect, `capabilities_wanted`'s fourth case, the dead styles);
the
sixth merges this branch's own tip (#584, #591, #586), where only the
fixture-shape gate's UNSENT list met (both sides kept). How each
conflict
was settled:

- 17 legacy ui-web files (`live/`, `demo/`, `features/xa`,
  `features/knowledge`, `scripts/boot-order.test.mjs`,
  `composer/open-conversation.test.ts`), edited on `main` for #542, #549
  and #554: deleted on this branch, and the deletions stand. `main`'s
  `shell/workdir.ts` and its test, which git relocated into `lib/`, are
  dropped for the same reason -- see the follow-up below.
- `main.tsx`, `page.html`, `state/session/registry.test.ts`: this
branch's
version. `main`'s edits there were the legacy chip wiring; the registry
conflict was a rename git guessed from
`scripts/page-switch-live.test.mjs`.
- `raven/agent/subagent/probe.py`: both imports; Test records a snapshot
  for a preset too (`main`, #554) through `record_capabilities` (this
  branch, #559); a kept record carries `needs_auth`; the boot backfill
  re-measures an outdated model menu (this branch) and a recorded
  credential refusal (`main`).
- `raven/rpc/methods/subagents.py`: `needs_auth` reads the snapshot this
  branch already loads per row.
- `raven/agent/subagent/manager.py` (#528, third commit): both sides
kept
at both hunks -- `main`'s two shutdown refusals beside
`SPAWN_REFUSED_PREFIX`
  with this branch's `_row_pin` after them, and the spawn announce that
types its mark and carries `node_id` (this branch) then returns early
when
  `_inject` was refused (`main`).
- `tests/test_subagent_probe.py`, `test_subagent_acp.py`,
`test_rpc_subagents.py`, `test_rpc_console.py`, `test_config_update.py`:
  both sides' new tests, in order. This branch's two backfill tests pin
  `_unconfigured_acp_preset_rows` the way `main`'s do, and the
`_agent_home` helper both sides defined is one function passing a
`Path`,
  as the config property does.
- `README.md`, `README.zh-CN.md`: `main`'s figure and showcase section.
- `ui-web/src/rpc/generated.ts`, `ui-tui/src/rpc/generated.ts`,
  `ui-tui/src/i18n/messages.generated.ts`: regenerated from the merged
  schema and catalogue.

Carried onto the rebuilt tree, since the backend halves merged cleanly:

- #554: the agent hub's rows, its sheet and the wizard step name a
refused
  agent `Unauthorized` (disabled) instead of offering Connect; the sheet
offers Test beside it, the press that re-measures the handshake and lets
the row return to Connect after a sign-in. `capabilities_wanted` treats
a
recorded refusal as wanting a re-measure, the same fourth case the boot
  backfill gained from `main`. Four tests.
- #542: rail rows carry the session's `workdir`, so the folder group
`RailPage.tsx` gained in the merge can fill. The tag's class moves under
  the rail namespace (`rail-wdt`) with its rule in the new
  `features/rail/styles.css`, as check-class-namespace requires.
- #549: the page-side upload ceiling follows `raven/rpc/files.py` to 100
MB.
- The offline fixtures send `needs_auth`;
`session.list.sessions[].workdir`
is pinned unsent in fixture-shape until the chip lands. The folder
chip's
thirty lines of popover rules that `main` added to `styles/page.css` are
  dropped with the chip; the follow-up brings its own.

Follow-up, not in this PR: the folder chip itself (#542's
`shell/workdir.ts`: the composer chip, the `fs.dirs` browser, `workdir`
on
`session.create`) has to be rebuilt on this tree's chip pattern
(`state/perm.ts` + `chrome/PermChip.tsx`). The backend, the i18n keys
and
the rail half are already here.

## Type

- [ ] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [x] Other (catch-up merge)

## Verification

Run in the worktree on the merged tree:

- `uv run pytest tests/test_subagent_probe.py tests/test_subagent_acp.py
tests/test_rpc_subagents.py tests/test_rpc_console.py
tests/test_config_update.py tests/test_rpc_session.py
tests/test_rpc_contract_shapes.py tests/test_rpc_schema_match.py
tests/test_rpc_registration.py tests/test_i18n_boundary.py
tests/test_subagent_manager.py tests/test_subagent_dag_runner.py
tests/test_rpc_bootstrap.py tests/test_cli_tui_commands.py
tests/test_cli_gateway_commands.py tests/test_rpc_transport.py
tests/test_cli_serve_commands.py tests/test_importer_orchestrator.py
tests/test_cli_gateway_page.py -q` on the final head -- 1893 passed (the
same files, run per commit as the branch grew: 1141, 590, 501).
- `make lint-python` -- ruff check clean; ruff format clean after
formatting the three spliced test files.
- `npm test` in `ui-web` on the final head -- 189 files, 2468 tests
passed (before the sync merge: 193 files, 2609; the first run had failed
two gates, check-class-namespace and fixture-shape, on the merged `.wdt`
class and the three new optional wire fields, both fixed as described
above).
- `npm run type-check`, `npm run lint` (0 errors, 5 pre-existing
warnings), `npm run gen:check` (193 methods) in `ui-web`.
- `npm run lint:i18n`, `npm run lint:rpc`, `npm run type-check`, `npm
test` in `ui-tui` -- all clean.
- `make build-ui` on the final head -- page built, `boot-snapshot: OK`
on both snapshots (235 nodes, the golden #591 set).
- `scripts/check_source_language.py
origin/refactor/ui_web_architecture..HEAD` and
`scripts/check_large_files.py origin/refactor/ui_web_architecture..HEAD`
-- both exit 0; `git merge-tree --write-tree origin/main HEAD` -- clean.

The full Python suite is left to CI.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

No source change of its own beyond the resolutions and the three carries
above. On the page: a refused external agent now shows a disabled
`Unauthorized` button where it showed Connect; conversations pinned to a
folder group under their own rail heading with the folder's name on the
row; uploads up to 100 MB are sent instead of refused at 25 MB. All
three
match what `main` ships today.

This branch is squash-only, so the merge base stays at `5a29ea22` after
this lands; the next round repeats the `-s ours 6ebc2ba` step first.
Rollback is reverting the squash commit; the branch is back at
`99c37e74`.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A. Unblocks #475.

---------

Co-authored-by: xfng-sd <xufang@shanda.com>
Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Co-authored-by: Handsome-wzw <68996445+Handsome-wzw@users.noreply.github.com>
Co-authored-by: Dizhan Xue <dizhan.xue@evermind.ai>
Co-authored-by: Kevin Hu <kevinhu.sh@gmail.com>
Co-authored-by: admin <admin@SH-HuKai.local>
Co-authored-by: 江国庆/Forrest <mr.jianggq@163.com>
Co-authored-by: 江国庆 <guoqingjiang@deepglint.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: ypflll <ypflll@163.com>
Co-authored-by: yao pengfei <yaopengfei@shanda.com>
Co-authored-by: zhanghui <huizhang1995@gmail.com>
Co-authored-by: Zuyi Zhou <144661423+ZuyiZhou@users.noreply.github.com>
Co-authored-by: Tong Li <litong02@shanda.com>
Co-authored-by: litong <238663200+TongLi31@users.noreply.github.com>
Co-authored-by: Tchen-data <176354753+Tchen-data@users.noreply.github.com>
Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com>
Co-authored-by: Dizhan Xue <xuedizhan17@mails.ucas.ac.cn>
Co-authored-by: silverLXT <25195409+silverLXT@users.noreply.github.com>
Co-authored-by: xiaotian.luo <xiaotian.luo@thetahealth.ai>
Co-authored-by: userName20260323 <zhao.wang@evermind.ai>
Co-authored-by: zhao.wang <270284818+userName20260323@users.noreply.github.com>
Co-authored-by: arelchan <1239372199@qq.com>
Co-authored-by: arelchan <204152633+arelchan@users.noreply.github.com>
…596)

## Summary

The composer's model chip showed a model the conversation was not
running.

`loadSettings` painted the chip from `agents.defaults.model`, which is
what NEW
conversations start on, not what the open one runs. Every settings load
ran that
paint: opening the dialog, and every settings write, each of which
reloads. So a
conversation that had switched model had the default put back over it,
the switch
read as lost, and a page reload was what appeared to "apply" it. The
turns had
been running on the picked model the whole time -- only the chip was
wrong. The
same line moved the chip onto a new default even for a conversation
holding a
model of its own.

The line is older than the split between the two values. That split
added
`setDefaultPair` right above it, for the settings page's own
default-model row,
and left this one behind. Deleting it is the whole change: the chip
belongs to
`loadProviders`, which asks `model.options` for the visible conversation
and runs
on every path that changes which conversation that is -- including a
default-scoped write, when the server answers that this conversation
follows the
default.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Reproduced and re-verified on a real host (`raven web` on an isolated
`RAVEN_HOME` built from a real config, driven through the browser),
reading the
model each turn actually ran from
`<raven_home>/telemetry/usage-<date>.jsonl`
rather than from the page:

| Step | Chip before the fix | Chip after | Model the turn ran |
| --- | --- | --- | --- |
| First turn of a new conversation | v4-pro | v4-pro | v4-pro |
| Pick v4-flash in the chip's picker | v4-flash | v4-flash | -- |
| Next turn | v4-flash | v4-flash | v4-flash |
| Open the settings dialog | **v4-pro** | v4-flash | -- |
| Next turn | v4-pro | v4-flash | v4-flash |
| Reload the page | v4-flash | v4-flash | -- |
| Change the default while this conversation holds its own model |
**follows the default** | stays on v4-flash | v4-flash |
| Change the default for a conversation that holds none | follows |
follows | the new default |

The last row is the regression the deleted line could have taken with
it: it goes
through `applies_to_session` and `loadProviders`, not through this line.

Commands:

- `npm test --prefix ui-web` -- 189 files, 2469 tests, all pass
- `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing warnings in
  CronPage/SubagentsPage, untouched here)
- `npm run type-check --prefix ui-web` -- clean
- `npm run gen:check --prefix ui-web` -- generated.ts matches the
contract
- `npm run --prefix ui-web build && python3 ui-web/build.py` --
boot-snapshot OK
  (235 nodes match golden), then `check-page.mjs`, `check-css.mjs`,
  `check-class-namespace.mjs` all OK
- `npx commitlint --from github/refactor/ui_web_architecture --to HEAD`
-- clean

The new case, `a settings load moves the default pair and leaves the
conversation chip alone`, was run against the unfixed module first and
fails
there on the assertion it is named for.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

User-visible and in the right direction: the chip stops contradicting
the
conversation. No behaviour changes on the server, no stored state
changes, and
nothing else reads what the deleted line wrote. Rollback is reverting
one commit.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…er the panes (#593)

## Summary

Eight items on the conversation view, on top of
`refactor/ui_web_architecture`.

- **The answer footer was invisible for good, not just at rest.**
`.turn.ai > .ansfoot .acts` is four classes and the reveal it was paired
with, `.turn:hover .acts`, is three, so the transparent rule outweighed
the hover and the copy and branch buttons never came back. Measured on
the running page: `opacity` stayed `0` with `.turn:hover` matching. The
footer now fades as one element at the turn level, which is also the
shape the reference design gives it. Its button hover colours already
matched the design and now actually show.
- **The permission popover** takes the reference design's geometry and
type: 268px wide, 7px/9px rows at an 8px radius, the name at 13.5px
going from 500 to 600 when it is the one in force, the hint at 11.5px in
`--faint`, and a 14px amber tick with a 2px stroke. The per-row shield
and the filled band behind the chosen row are gone. `gui.perm.smart_h`
and `gui.perm.full_h` are reworded to the design's sentences, in both
languages, and the tui catalogue is synced.
- **The boot splash and the favicon** are the flat raven mark
(`ui-web/src/assets/raven.png`), square so a browser tab cannot stretch
it, with the bird 68px of the 86px box and the rest transparent. The
splash mark was sized for the old 960px illustration; at 111px square it
renders the bird at the size it did before without upscaling much past
the file. The mark is a silhouette, so it inverts on the dark ground -
down both theme paths, the way the page's own mono marks do - and
`raven-dark.png` is the same drawing inverted, behind a `media` query on
a second icon link, because the favicon has the same problem against a
dark tab strip.
- **The back-to-bottom pill** centres on the transcript column rather
than on the chat. With the anchored desk taking a strip off the chat's
right edge, the pill sat half that strip, 162px at the default panel
width, right of the text it marks.
- **The wash moves from `.chat` to `.main`,** so a desk pane shares the
conversation's ground instead of standing on flat `--page` two pixels
beside it. That seam ran the full height of the window whenever a pane
was open. `--page` is the wash's own last layer, so nothing changes
where no gradient stop reaches.
- **The inline rename field sizes to the name in it.** It opened at
`min(420px, 52%)` however long the name was, so a two-word conversation
got a field with most of it empty paper. `field-sizing: content` makes
the input measure its own value; the cap stays the heading's own, so a
rename cannot widen the header past where the title already sat, and a
7ch floor keeps a field you have just emptied from collapsing to a
sliver.
- **The desk reserve leaves the scroll container alone.** Its three
columns now take the same inset as `max-width: min(cap, 100% - R)` plus
`translate: -R/2`, which is the same geometry case for case and does not
relay the transcript out on every frame of the animation.

That last one is a partial answer to a reported render glitch in the
message list when panes open and close: two cards of the same width
225px apart with a clean horizontal break between them, which is a stale
raster band rather than two layouts. It did not reproduce here -
hundreds of frames of pane and palette cycling at the reported window
size and with a wider dragged panel, checking every frame that the turns
agree on their offset and that the column tracks the composer, found no
disagreement. A padding animation on a scroll container is the standing
suspect, so this removes it; it is offered as a plausible fix, not a
confirmed one.

Three judgement calls worth a reviewer's eye:

1. `gui.perm.full_h` first took the reference design's "no restrictions"
wording, which review caught as a false security model - and it was: the
waterfall in `raven/permissions/gate.py` returns builtin denials and
user deny rules BEFORE it reads the mode, so `PermissionMode.FULL`
bypasses the ask tier and nothing else, which is what
`PermissionsConfig` documents. The hint now keeps the design's
dangerous-operation warning and drops the claim about the standing
denials.
2. The popover keeps its note ("takes effect from the next tool call").
The design's menu is generic - the effort chip opens the same one - and
has no slot for it, but the sentence is real information.
3. The popover heading keeps the page's own `.lab` face rather than the
design's plain sans, because `#tierPop` opens from the chip next door
wearing the same heading and one of the two in a different face is worse
than either.

`ui-web/src/assets/ravens/main-agent.webp` is now unreferenced, as the
other four in that directory already were. Left in place rather than
deleted on my own.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Run in `ui-web/` on the rebased branch:

- `npm test` - 189 files, 2462 cases, all passing.
`scripts/gates/desk-reserve-css.test.mjs` is rewritten: it pinned the
old `padding-right` on `div.scroll` by literal, and now pins the
cap-and-slide pair, the composer's `36px + R`, the pill's centring, and
that the scroll container itself carries no inset.
- `npm run type-check` - clean.
- `npm run lint` - 0 errors, 4 pre-existing
`react-hooks/exhaustive-deps` warnings, none in the touched files.
- `npm run build` then `python ui-web/build.py` - page assembled, both
boot goldens match (235 nodes each).

Measured in Chrome against the dev server, stub fixtures, rather than
asserted:

- the answer footer reads `opacity: 0` at rest and `1` with the turn
hovered, the button at `--text` on `--surface`;
- the transcript column, the composer card and the pill share one centre
with the desk closed (986) and open (824, reserve 324);
- the clamped narrow-window case lands the column's left edge on the
scroller's, which is what the old padding did;
- the popover measures 268px with the row metrics above and no shield;
- the rename field measures 135px for an eight-character name where it
used to take 420, caps at 420 for a 200-character one, and holds its
56px floor when emptied, at a 1177px header;
- the splash mark renders 111x111 from an 86x86 file, and the favicon is
served as `image/png`.
- the splash mark resolves to `invert(1) drop-shadow(...)` over a
#1d1d1d ground down both the explicit-dark and system-dark paths and to
the shadow alone down both light ones, and the dark icon link matches
only under `prefers-color-scheme: dark`;

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

All of it is the conversation view's paint. The two that could bite
beyond it:

- A `translate` makes its element a containing block for positioned
descendants. Nothing in the scroller's three columns depends on an outer
one today - measured on the running page, one absolutely positioned
element and it sits inside its own positioned parent - but a new
`position: fixed` inside the transcript would be re-based by it. The
comment beside the rule says so.
- The wash on `.main` is inherited by anything that later renders there
without a ground of its own. The module pages are opaque fixed layers
and are unaffected; dark theme resolves to its own flat gradient.

`gui.perm.full_h` is the one wording change a reader could act on, and
item 1 above is the caveat.

Rollback is per item: each is one or two rules in
`ui-web/src/styles/page.css`, except the popover markup
(`ui-web/src/chrome/PermPopover.tsx`, `ui-web/src/state/perm.ts`), the
two catalogue strings, and the new asset with the two references to it
in `ui-web/src/page.html`.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
Co-authored-by: KT <74288668+0xKT@users.noreply.github.com>
…#587)

## Summary

Draws the onboarding wizard's agents step with the Agent Hub's rows.
#559
took the agents page to the hub prototype but left the wizard's third
step on
the old settings page's two-bucket rows, with classifiers of its own and
a
toast for a refused connect -- two looks and two classifications for the
same roster.

The hub's row, its one control, the dot and the section block move out
of
`ExtAgentsPage.tsx` into `Rows.tsx`, and the wizard draws them in two of
the
hub's three sections -- available first, then connected -- from the
hub's own
`sectionOf`. So a refusal stays on the row in red with Retry, a connect
in
flight says so on the row, and a stale preset asks the hub's question
before
it migrates. The one new seam is `onOpen`: the page passes the sheet
opener,
the wizard passes nothing and gets a plain row.

What stays the wizard's, as decided for #523: no sheet (a step is a
decision, not a roster to manage), no "not installed" section and no
openai
row, and the step counts itself done on an external agent alone, so the
shipped ravens are drawn as connected without completing it. One
classifier
now: `wizardSection` narrows `sectionOf` and `isFound` reads it; the
wizard-only predicates and toast verbs are gone with their tests. The
wizard's duplicate section labels leave the catalogue for the hub's
identical
ones, and the TUI copy is regenerated. In the class-namespace gate the
extAgents pin drops to the one `.pmhero` the page still writes: `.kd`
and
`.sulist` were the old step's alone, and nothing names them now.

Connectivity itself is untouched: the adapter pins, the probe and the
credentials an agent needs are the sub-agents module's, and the step
inherits
whatever lands there through the same `subagents.*` calls.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
cd ui-web && npm test            # 190 files, 2549 tests passed (40 gates included)
cd ui-web && npm run type-check  # clean
cd ui-web && npm run lint        # 0 errors
cd ui-web && node scripts/check-class-namespace.mjs   # OK
cd ui-web && npm run build && python3 build.py        # boot goldens 251/252 match
cd ui-web && npm run gen:check                        # generated.ts matches the contract
npm run lint:i18n --prefix ui-tui                     # generated catalogue up to date
```

Real host: a fresh RAVEN_HOME served from this branch, walked with
playwright
to the agents step -- the two sections drawn with the hub's rows (Claude
Code
and Codex available with their catalogue lines, the four shipped ravens
connected), rows without a button role, Connect on Claude Code showing
"connecting" on the row and then landing (the adapter pin on the base is
current now), the step's primary lighting up; the hub page opened beside
it
draws the same rows in its three sections.

Base merge (refactor/ui_web_architecture at ccd32f9, one merge commit
on
this branch): npm test 189 files / 2471 tests, type-check, lint, the
class
gate, build + build.py (boot goldens 235 nodes), gen:check and lint:i18n
all green; the real-host walk repeated on the merged head with the same
result, and Codex shows the disabled Unauthorized control after the
re-scan
(the base's #595 stage, carried by the shared rows).

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

Behaviour changes, wizard only: a refused connect is red text on the row
with
Retry rather than a toast, and -- the hub store keeping failures per row
--
that row stays red on the agents page too until the next write on it; a
preset the probe has not measured yet is offered like the hub offers it;
a
connected row counts towards the step whatever its probe says; a shim
preset
whose binary is absent is out of the step (the hub's "not installed").
The
agents page is unchanged in markup and behaviour except that only a row
that
opens the sheet shows the hand cursor and the hover. Rollback is
reverting
the squash commit; no config or wire change.

## Related Issues

N/A

---------

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: resolve the current-main merge conflicts and the source-language gate failure.

I reviewed the revision through bdce28d6745d, including the catch-up, the settings/model-chip and conversation-view fixes, and the new wizard/Agent Hub row reuse. I found no additional code defect after checking the repository rules, the full diff and affected callers/history, backward compatibility, test changes for weakening, and the Web UI source/store/seam architecture in CONTEXT-MAP.md and ui-web/CONTEXT.md.

The two existing blockers remain: git merge-tree --write-tree HEAD github/main fails against fetched main 3c7bae73070d with 49 conflicts (28 content, 19 modify/delete, two file-location), and uv run python scripts/check_source_language.py github/main...HEAD still rejects the CJK fixture additions in ui-web/src/rpc/fixtures/sessions.ts. GitHub reports CONFLICTING / DIRTY.

Verification: frontend 2,471/2,471 passed; 841 targeted Python tests passed; type-check, generated-client check, i18n catalogue check, class-namespace gate, production build/build.py boot goldens, large-file gate, and git diff --check passed. ESLint completed with four pre-existing warnings and no errors.

0xKT and others added 2 commits September 21, 2026 21:22
…it (#594)

## Summary

The provider split (#565) moved model picking to one body-level picker
surface and left the compact provider card to the onboarding wizard
alone, but the wizard's first step was not re-aligned with it. This PR
does that, keeping the step in the prototype's shape.

**Regression fix.** The picker now lives at the picker layer (46)
outside the wizard, so opening it from a role pill inside the wizard
(110) drew it behind the wizard. It is lifted the same way the confirm
veil already is while the wizard is shown, and the layer gate pins the
ladder (picker token below the wizard, base rule on the token, lifted
rule present).

**Wizard add block, aligned with the prototype and the catalogue page.**
- The vendor select shows the catalogue page's four groups (direct,
gateway, browser sign-in, local). One `groupOf` answers both the
wizard's select and the catalogue's filter, in the catalogue's order (an
aggregator first, whatever credential it takes), so the two cannot
drift; labels are the catalogue's and no i18n key is added. A new test
pins the buckets, that every vendor lands in exactly one, the
connected-first order and the search needle.
- An aggregator gets the address row the catalogue's connection card
already gives it, prefilled from its default base. The base is sent
whenever the field is non-empty, as before.
- When a device flow lands, the add form that started it closes:
pointing at a now-connected slug, it used to redraw itself silently for
the next unconnected vendor.

**The settings catalogue's columns.** Found while trying the catalogue
in the wizard, fixed where it lives: the provider catalogue sized its
grid with a max-height, which a grid's fr row does not resolve against,
so both columns grew to the vendor list's height and were clipped -- the
list could not scroll past the first seventeen vendors and the "pick
one" placeholder sat below the fold. A definite height bounds the row
track, so the list and the detail pane scroll on their own; a gate reads
the sheet and pins it.

Decisions for the reviewer:
- Local vendors keep their optional key row (the prototype has none). It
matches the catalogue's connection card and costs nothing; drop it on
request.
- Known debt, not addressed here: the wizard's add block duplicates
about sixty lines of form rules from the catalogue's connection card.
Folding them into one component is a follow-up.
- Two small inconsistencies surfaced by review, left for that same
follow-up: the vendor tag on the catalogue's detail card still says
"Local / self-hosted" where the filter and the wizard say "Local
deployment" (identical in Chinese), and three address-row conditions
carry an `endpoint` term that the kind helper already implies.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

```
cd ui-web && npm test          # see the PR checks for the counts; includes the layer and catalogue gates
cd ui-web && npm run type-check  # clean
cd ui-web && npm run lint        # 0 errors, 4 pre-existing warnings (same on the base)
cd ui-web && node scripts/check-class-namespace.mjs   # OK
```

Real host: a gateway from this branch under an isolated RAVEN_HOME,
driven by Playwright on the first-run wizard. Before: the picker
computed z-index 46 and the element under its centre belonged to the
wizard. After: z-index 111 and the element under its centre is the
picker's search field; the vendor select shows the four groups with the
same counts the registry gives the catalogue page (direct 25, gateway
21, browser sign-in 4, local 5). In the settings dialog the catalogue's
columns measure one screenful and the list scrolls on its own (they
measured 2008px and were clipped before).

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

- [ ] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

UI only. The lift rule applies only while the wizard is open. The
settings dialog changes in two places: its catalogue columns are bounded
and scroll (they were clipped before), and its filter reads the shared
grouping, which files every one of the registry's 55 vendors exactly
where the old predicate did (recomputed over the registry). The store
change touches only the OAuth poll's success branch. Rollback is a plain
revert.

## Related Issues

N/A

---------

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
## Summary

Restoring a conversation from the archive page put it back everywhere
except the
screen. `loadSessions` replaced the rail's rows and never drew them, and
the
restore reaches the rail only through that function, so the row came
back in the
server's listing and on disk while the rail kept showing the list from
before.
Reloading the page was what appeared to restore it.

Every other writer in this feature already draws after a replace --
`leave.ts`
does it on both of its transitions, the session registry does it after
its
reconcile -- so the fix is the missing call in the one path that did
not, rather
than a draw at the restore call site. At boot the rail is still held,
where a
draw sets the skeleton and `releaseRail` paints the rows, so the other
caller is
unaffected.

Found while driving the merged archive flow end to end on a real host,
after
#585, #589 and #596. It is the same shape as #596: server state correct,
screen
stale until a reload.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

On a real host (`raven web` on an isolated `RAVEN_HOME` built from a
real
config, driven through the browser), reading the archived flag from the
last
metadata record of the session transcript rather than from the page:

| Step | Before | After |
| --- | --- | --- |
| Archive a conversation from the rail | leaves the rail, `archived:
true` on disk | same |
| Settings, Archive page | it is listed | same |
| Restart the gateway | still archived, does not come back | same |
| Restore it from the Archive page | `archived: false` on disk, **rail
still does not show it** | rail shows it at once |
| Reload the page | rail shows it | rail shows it |

Commands:

- `npm test --prefix ui-web` -- 189 files, 2470 tests, all pass
- `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing warnings in
  CronPage/SubagentsPage, untouched here)
- `npm run type-check --prefix ui-web` -- clean
- `npm run --prefix ui-web build && python3 ui-web/build.py` --
boot-snapshot OK
  (235 nodes match golden)
- `npx commitlint --from github/refactor/ui_web_architecture --to HEAD`
-- clean

The new case, `a plain re-read draws the rows it just replaced`, was run
against
the unfixed module first and fails there on the assertion it is named
for.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

One added call in a function with two callers; the other one runs while
the rail
is held, where the draw is a no-op beyond the skeleton it already sets.
No server
change, no stored state change. Rollback is reverting one commit.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: resolve the current-main merge conflicts and the source-language gate failure.

I reviewed the provider/onboarding delta at 924112f7455e and found no additional code defect. I covered the repository rules and Web UI vocabulary, the diff and its provider/store/picker callers, relevant history, backward compatibility, test changes for weakening, and the source/store/layer architecture. The two previously reported blockers remain unchanged at this revision: the current-main merge still has 49 conflicts, and the source-language gate still rejects the non-English session fixtures.

Verification: frontend 2,481/2,481 passed; type-check, generated-client check, class-namespace gate, production build/build.py boot goldens, and git diff --check passed. ESLint completed with the same four pre-existing warnings and no errors.

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: resolve the current-main merge conflicts and the source-language gate failure.

I reviewed the delta from 924112f7455e through 061e3ab60065. The rail change is sound: loadSessions preserves client-only current/running state through reconcileRows, replaces before drawing, and the startup hold still defers the visible paint until releaseRail. The new tests exercise both the restore redraw order and reconciliation; I found no new defect in this revision's code.

The two existing blockers remain. Against fetched main at 3c7bae73070d, git merge-tree --write-tree HEAD github/main exits 1 with 49 conflicts (28 content, 19 modify/delete, and two file-location conflicts), and GitHub reports mergeable: false / mergeable_state: dirty. uv run python scripts/check_source_language.py github/main...HEAD still exits 1 on the raw CJK in ui-web/src/rpc/fixtures/sessions.ts lines 99 and 101-103.

Verification: npm test passed 191 files and 2,483/2,483 tests on an isolated rerun; npm run type-check passed; npm run lint reported zero errors and four warnings in untouched files; git diff --check passed. An earlier accidental pair of concurrent full runs produced three 5-second timeouts; the uncontended rerun passed all three and the full suite.

I covered the repository rules, this revision's diff, the affected callers and history, backward compatibility, test-strength changes, and the rail/store architecture constraints.

gloryfromca and others added 2 commits September 21, 2026 21:42
## Summary

The memory section offered a delete that no store behind it can honour,
so this
takes the control off and drops the `memory.delete` method with it. Base
is
`refactor/ui_web_architecture`, not `main`.

What the reader hit: deleting a know-how entry answered "this backend
cannot
delete a agent_skill memory". That row could never have been deleted.
The
adapter rebuilds the skill directory path from the row's name, and a
directory
written before EverOS started sanitizing names keeps its spaces, so the
derived
path matches nothing and delete reports False.

Why the path rule was not fixed instead:

- EverOS has no user-facing memory deletion. Its one destructive
operation on a
skill exists to reap the directory an update left behind when it renamed
a
skill. Retirement is a design decision EverOS states and defers: its
extractor
lowers the confidence of a skill it wants retired, and the store writes
that
  back as an ordinary skill.
- Episodes were the one kind the adapter could act on, through the
`deprecated_entries` frontmatter map EverOS itself uses, and that
answered
True. It still was not what the reader asked for: the section lists
through
`POST /api/v1/memory/get`, which does not filter `deprecated_by` the way
`/search` does. A retired episode left recall and stayed on the list, so
the
  delete was reported as done and the row came back on the next load.
- Profiles and cases answered False throughout, so two of the four kinds
could
  only ever report the same failure.

Removed: the armed delete button and its two call sites, the memory
source's
`remove` and the store's guard around it, the `memory.delete` method and
its
contract entry (193 wire methods to 192), the five catalogue strings,
the
offline fixture's parameters for the call, and the tests that drove all
of it.
`memory.*` is now the read-only surface its registration comment already
claimed, so `register_memory_methods` no longer takes a loop factory.

`MemoryBackend.delete` and the EverOS adapter that implements it are
left as
they are. Nothing calls them now; the backend contract test still holds
the
adapter to answering a bool, so a backend that grows a real deletion has
a
contract to meet when a UI for it is designed.

No doc, spec or screenshot in the tree names the removed control, so
none
needed updating.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Commands and results:

- `uv run --frozen --all-extras pytest tests/test_rpc_memory.py
tests/test_rpc_registration.py tests/test_rpc_schema_match.py
tests/test_rpc_contract_shapes.py tests/test_i18n_boundary.py
tests/test_memory_backend_protocol.py
tests/test_memory_backend_contract.py tests/test_cli_tui_bootstrap.py
-q` -> 506 passed, 0 skipped
- `npm test` (ui-web) -> 189 files, 2469 passed
- `npm run type-check` (ui-web) -> clean; `npm run lint` (ui-web) -> 0
errors (4 warnings, all pre-existing in connections / cron / subagents)
- `npm run gen:check` (ui-web) -> generated.ts matches the contract (192
methods); `npm run lint:rpc` and `npm run lint:i18n` (ui-tui) -> in sync
- `node scripts/check-css.mjs`, `node scripts/check-class-namespace.mjs`
-> OK
- `make check-source-language`, `make check-large-files`,
`scripts/check_commit_messages.py`, `commitlint` -> exit 0 each (checker
exit codes, not a pipeline's)
- Isolated acceptance run: a gateway on its own port with its own
`RAVEN_HOME`, reading the running EverOS. Settings -> Memory -> Know-how
-> the OCR entry opens with no button on it; the profile view has none
either; the gateway answers `memory.delete` with `-32601
method_not_found` while `memory.stats` still reports its 396 episodes.
- The assertion the removed tests carried was put back on the pick test
(a kind switch drops the picked memory) and checked by no-op-ing
`closeDetail`: red with the mutation, green without it.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

User-visible change: the memory section loses its delete control.
Nothing else
on the section changes; listing, semantic search, the kind switch and
the picked
memory all behave as before.

Wire change: `memory.delete` is gone from the contract, so a client that
still
calls it gets `-32601`. The TUI never called it, and the page's only
caller is
removed in this change.

Rollback: revert the commit. The backend implementation is untouched, so
the
control returns in the state it was in.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com>
Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
## Summary

Two settings-dialog writes whose effect the page did not show. Both were
found
by an adversarial review of the archive and model fixes and then
reproduced on a
real host.

**The permission chip could name a weaker tier than the conversation ran
at**,
which is the one direction this control must never fail in. A mode
picked before
there is a conversation is staged, and the first message applies it to
the
session it mints. The chip's re-read did not know that: with no session
the read
is default-scoped, so the gateway answers the configured default, and
the chip
was painted from it unconditionally. Opening the dialog re-reads the
chip, and
so does every settings write, so the pick was overwritten seconds after
it was
made while the write that matters still went through. The draft keeps
its own
pick now, which is what the model chip already does for the same case. A
draft
that picked nothing still follows the default, and a conversation still
follows
its own mode: both come from the session-scoped read the guard does not
touch.

**Turning auto-archive on showed nothing.** The sweep runs inside
session.list,
so writing the setting moves nothing by itself: the card went on saying
nothing
was archived and the rail went on listing the stale conversations, until
a
reload ran the sweep and a pile of them disappeared at once, with no
moment
connecting that to the switch. The toggle now re-reads both lists, which
is what
performs the sweep and then shows what it did. A refused write re-reads
nothing,
so a failure cannot read as though it took.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Both driven on a real host (`raven web` on an isolated `RAVEN_HOME`
built from a
real config), reading the result from the layer that owns it rather than
from
the page: the mode a session was created on from `permissions_mode` in
the last
metadata record of its transcript, and the archived state from
`archived` in the
same place.

The permission chip, with `permissions` empty so the shipped default
applies:

| Step | Chip before | Chip after |
| --- | --- | --- |
| New-task screen | smart | smart |
| Pick full access | full | full |
| Open the settings dialog | **smart** | full |
| Chip at the moment of sending the first message | **smart** | full |
| `permissions_mode` on the session that message minted | **full** |
full |

The last two rows are the defect: the chip and the session disagreed,
and the
chip was the weaker of the two.

Auto-archive, against two conversations backdated past the threshold
with the
setting off to start with:

| Step | Before | After |
| --- | --- | --- |
| Flip the switch on | switch on, card still empty, rail still 5 rows |
card lists both at once, rail drops to 3 |
| Reload the page | now they are gone from the rail | unchanged |

The third backdated conversation stays on the rail in both columns: it
carries
an `archived` key from an earlier restore, which the sweep skips by
design.

Commands:

- `npm test --prefix ui-web` -- 189 files, 2475 tests, all pass
- `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing warnings in
  CronPage/SubagentsPage, untouched here)
- `npm run type-check --prefix ui-web` -- clean
- `npm run --prefix ui-web build && python3 ui-web/build.py && node
ui-web/scripts/check-page.mjs` -- boot-snapshot OK (235 nodes match
golden),
  page OK
- `npx commitlint --from github/refactor/ui_web_architecture --to HEAD`
-- clean

Four cases were added, each with a control that must stay green through
the same
removal:

- `a draft that picked a mode keeps it when the chip is re-read`,
against
`a draft with no pick of its own, and a conversation, both follow the
read`
- `turning the switch on re-reads both lists, which is what runs the
sweep`,
  against `a refused write re-reads nothing`

Each of the two first cases was run against its unfixed module and fails
there
on the assertion it is named for.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

The permission change is one early return, reached only when there is no
session
AND the draft staged a pick; what the gate enforces was already the
staged
value, so it changes what the reader is told, not what runs. The
auto-archive
change adds two reads after a write that succeeded. No server change, no
stored
state change in either. Rollback is reverting the two commits, which are
independent of each other.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: resolve the 52 current-main merge conflicts and the source-language gate failure.

I reviewed the two-commit delta from 061e3ab through 05fa01b. I found no new defect in it: removing the branch-added memory-delete surface is internally complete across registration, schema, generated transport, stores, UI, and tests; the permission-draft guard remains generation-safe; and the auto-archive write now triggers the backend sweep before refreshing both archived and active lists.

The standing gates remain reproducible against fetched main c777ed7. git merge-tree --write-tree HEAD github/main exits 1 with 31 content conflicts, 19 modify/delete conflicts, and two file-location conflicts. uv run python scripts/check_source_language.py github/main...HEAD also exits 1 on the raw CJK fixture additions in ui-web/src/rpc/fixtures/sessions.ts lines 99 and 101-103. These are ordinary merge/CI paths with no passing route in the current revision.

Verification: ui-web npm test passed 191 files / 2,485 tests; the relevant RPC, memory, i18n-boundary, and bootstrap pytest selection passed 506 tests; generated-client verification and TypeScript type-check passed; ESLint reported zero errors (four pre-existing warnings outside this delta); and git diff --check passed. I covered the repository rules, this revision's diff, callers and history, backward compatibility, test weakening, and the typed-transport/store/React architecture constraints.

gloryfromca and others added 2 commits September 21, 2026 22:01
## Summary

The rail's import row counted settled sources over the total, so its
share stood still for the whole of a large source. On the live run a
287-message memory directory is 29 batches of 10 and about ten minutes
at 42 percent, which the maintainer read as a hang.

- The orchestrator reports, per source, how many of its messages have
landed after each batch (`on_batch(platform, source_key, sent, total)`),
the way it already reports per-source outcomes.
- `import.status` carries it as a nullable `current {platform,
source_key, sent, total}` beside `phase`, set while the message pass is
on and null otherwise; additive and optional in the contract, both
generated clients regenerated.
- The row adds that source's share to its count: `(settled + sent/total)
/ total`. A gateway restart clears the field and the row falls back to
the per-source share, which is what it showed before.
- A source is counted or named, never both. The gateway stops naming a
source the moment the run's progress event settles it, and leaves the
one a retry is sending out of the settled count instead of out of sight.
Without the first, a poller on a three-source pass saw 33, 67, 33, 47,
60, 67, 100, 67 percent, reaching 100 with a whole source still to send;
without the second, a failed source being sent again was held by the
count and named at the same time. The row also ignores a `current` while
no run is on.
- The row stops at 99 percent until every source is settled: rounding
carried the last source over 99.5 well before it was done, which is the
100-percent-then-wait the profile mirror's progress line already warns
about.
- The row carries the current source's own counts beside the percentage
(`41% - 120/287`), the way it already carries the phase's `1/3`. One
source's whole share is `1/N` of the bar, so in a run of many the
percentage alone moves a few times an hour; the counts move with every
batch.

## Type

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `uv run pytest tests/test_rpc_schema_match.py
tests/test_rpc_import_sync.py tests/test_importer_orchestrator.py
tests/test_importer_phases.py -q`: 472 passed.
`tests/integration/test_import_e2e.py` green on the first round of this
branch.
- The reports are read out of a real pass rather than a set global: a
two-source run over a fake backend that polls `import.status` from
inside `store` sees `[(k1,0,12), (k1,10,12), (k2,0,12), (k2,10,12)]`,
and a poll taken in the window between one source settling and the next
one's first batch sees `current` null both times.
- ui-web: 194 tests across the import feature and the gates pass; `npm
run type-check` clean, `npm run gen:check` in sync, eslint clean on the
touched feature.
- Mutation checks, each confirmed red: report the batch before
`backend.store` rather than after it lands; report once per store
attempt instead of once per landed batch; name every report after the
first source of the run; drop `on_progress` from the gateway's
`run_import` call; drop `on_batch` from it; drop the `running` guard,
the clamp, or the zero guard from the row's share.
- Real host: a gateway built from this branch, its own RAVEN_HOME and a
synthetic Claude Code home of three memory sources (5, 68 and 9
messages), driven through the wizard's data-sync step in a browser,
storing into a real EverOS. The row, sampled from the DOM:

```
run  0% - 0/5        (first source)
run 33% - 0/68       (second source begins)
run 38% - 10/68
run 43% - 20/68
run 48% - 30/68
run 53% - 40/68
run 58% - 50/68
run 63% - 60/68
run 67% - 0/9        (third source begins)
done                 100%
```

The number moves with every batch inside the 68-message source, the bar
width follows it, no step goes backwards, and 100 percent arrives only
with the finished row. One apparent inversion appeared while two
samplers were reading the page at once and did not reproduce with a
single sampler; the row's poll is a fixed interval and does not
serialise its reads, so an out-of-order answer could still show one.
That is older than this branch and corrects itself on the next read.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

User-visible: the row's percentage now moves during a large source
instead of stepping once per source. `import.status` gains one optional
nullable field; older clients ignore it.

Rollback: revert the squash commit; no data migration is involved.

- [x] Security impact considered (no new inputs, credentials or
endpoints)
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com>
Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
…604)

## Summary

The model picker showed a conversation's model under a vendor whose key
does not
serve it.

`is_current` is the whole difference between the two `model.options`
reads: the
backend marks the conversation's provider when the request names a
session, and
`agents.defaults`' when it does not. Both answers landed in one
module-level
array, so the default-scoped read -- the settings dialog's, repeated by
every
settings write, and the agents page's -- overwrote the conversation's.

What the reader saw: a conversation running a Gemini model while the
default is
a DeepSeek one, one visit to the settings dialog, then a click on the
model
chip. The picker opened on DeepSeek's column with `gemini-3.5-flash`
prepended
and ticked inside it, DeepSeek's count raised by that phantom row, and
the
current dot on both vendors.

Split, the way the model pair one layer down already is (#596 separated
`defaultModelLive` from the chip's own value and left this array
shared).
`providersLive` is the visible conversation's answer, read by the picker
through
`modelSource`; `defaultProvidersLive` is the configured default's, read
by the
settings snapshot, the onboarding model step, and the agents page, which
loads
that list for itself.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

Driven on a real host (`raven web` on an isolated `RAVEN_HOME` built
from a real
config, default `deepseek/deepseek-v4-pro`, the conversation switched to
`gemini/gemini-3.5-flash`):

| Picker opened after one visit to the settings dialog | Before | After
|
| --- | --- | --- |
| Column it opens on | DeepSeek | Gemini |
| Where the tick sits | on a prepended `gemini-3.5-flash` row inside
DeepSeek | on Gemini 3.5 Flash, in Gemini |
| DeepSeek's model count | 4 (three plus the phantom) | 3 |
| Vendors carrying the current dot | DeepSeek and Gemini | Gemini |

Without the settings visit the picker was correct in both columns, which
is what
makes the shared array the cause.

Commands:

- `npm test --prefix ui-web` -- 191 files, 2486 tests, all pass
- `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing warnings in
  CronPage/SubagentsPage, untouched here)
- `npm run type-check --prefix ui-web` -- clean, and it is what found
the third
reader: the agents page imports this export under an alias, so grep
missed it
- `npm run --prefix ui-web build && python3 ui-web/build.py && node
ui-web/scripts/check-page.mjs` -- boot-snapshot OK (235 nodes match
golden),
  page OK
- `npx commitlint --from github/refactor/ui_web_architecture --to HEAD`
-- clean

The new case, `a default-scoped read leaves the conversation rows
alone`, was run
against a module made to share one array again and fails there on the
assertion
it is named for.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

One module-level array becomes two, and every reader was enumerated and
pointed
at the scope it means. No server change, no stored state change, no
change to
what any read asks for. Rollback is reverting one commit.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

Co-authored-by: gloryfromca <23442919+gloryfromca@users.noreply.github.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: resolve the 52 current-main merge conflicts and the source-language gate failure.

I reviewed the two-commit delta from 05fa01b through cbaa057 and found no new code defect. The import-progress callback is kept consistent with persisted source counts across accepted batches, retries, failures, skips, cancellation, phase handoff, and task teardown; the generated contracts and React store consume the optional state coherently. The provider-list split also preserves the distinct default and visible-session scopes across its callers.

The standing gates remain: against fetched main c777ed7, git merge-tree --write-tree HEAD github/main exits 1 with 31 content conflicts, 19 modify/delete conflicts, and two file-location conflicts, while uv run python scripts/check_source_language.py github/main...HEAD still exits 1 on the raw CJK additions in ui-web/src/rpc/fixtures/sessions.ts lines 99 and 101-103. GitHub reports CONFLICTING / DIRTY.

Verification: the focused importer/RPC/schema pytest suite passed 509 tests; ui-web npm test passed 191 files / 2,492 tests; generated-client verification and TypeScript type-check passed; ESLint reported zero errors and four pre-existing warnings outside this delta; and the delta passed git diff --check. I covered the repository rules, diff, callers and history, backward compatibility, test weakening, and the typed-transport/store/React architecture constraints.

arelchan and others added 2 commits September 21, 2026 23:18
## Summary

Style only, against `refactor/ui_web_architecture`. Every value here is
the
design prototype's, restored where the port had drifted. No behaviour,
no copy,
no data: the one `.ts` file is the anchored panel's arithmetic and the
one
`.tsx` is how that panel is dismissed.

**The control standard, which had not been ported at all.** Anything you
type
into is paper with a `--line` edge and turns amber with a glow on focus;
one row
of controls shares a height and a radius; grey is left to what is not a
control.
The blanket `input[type=text], input[type=password], select` rule sat on
`--surface` at radius 7 with no height, and outranked the settings
sheet's own
rules, so one form carried five grounds, five heights and three radii.
Measured
after: every control in the settings dialog is 32px.

**Switch, segmented control, small button**, to the prototype's
geometry:

| | before | after |
|---|---|---|
| switch | 32x18, grey knob | 38x22, 16px paper knob with a shadow |
| segmented | joined buttons, monospaced 11.5px | grey track, 2px of
air, pressed segment is a paper card, 13px |
| `.mini` | one size, 12.5px / radius 8 | 28/7/12.5 on module pages,
32/8/13 in the settings dialog -- the two tiers the prototype has |

**A module page is read on `--page`**, which is what that token is for.
On
`--ink` the cards, which are `--ink` themselves, lost their ground and
the page
read flat.

**The model panel hangs down from its field again, sized off the card.**
It ran
132px out of a 520x500 sheet, because the placer knew only `.smodal` and
fell
through to the window. Now the box decides the width (the field's left
edge to
20px inside the card, floored at 320 and capped at 560) while the window
decides
the height: down first, giving up height to the shelf it has, up only
from a
shelf too shallow to read. Its own type came down from 15/14.5px to the
prototype's 14/13.5/13. It closes on a press outside it, which is what a
menu
does, and the Cancel button beside the search box is gone.

**`raven.svg` is the mark the chrome draws**
(`components/RavenMark.tsx`), not a
second bird. The file shipped here was different artwork carrying an
opaque
white plate, while `AgentMark.tsx` and
`scripts/gates/agent-mark-css.test.mjs`
both already described a file with a `prefers-color-scheme` rule. The
gate that
pinned the plate now pins either mechanism, which is what its own prose
says it
guards.

**Two dead values found on the way.** `--green` is used once, by the
new-file
marker in a change list, and is defined nowhere -- that marker had no
colour.
And the caret on a rail group heading, which the prototype shows on
hover or
when the group is folded, had lost all three of its opacity rules and
sat there
permanently.

Not in this PR, and not style: the roster prints registry keys
(`claude_code`, `hermes`) rather than brand names, `Raven-PPT` has no
catalogue
entry so its line falls back to an English probe verdict, and the agent
sheet's
"what it is good at" shows the dispatch prompt. Those are copy and data.

## Type

- [ ] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [x] Other -- style parity with the design prototype

## Verification

```
cd ui-web && npx vitest run              # 191 files, 2492 tests pass
cd ui-web && npx tsc --noEmit            # clean
cd ui-web && npx vite build              # ok
node ui-web/scripts/check-css.mjs        # OK
node ui-web/scripts/check-class-namespace.mjs  # OK
python3 ui-web/build.py                  # boot-snapshot OK (235 nodes match golden)
node ui-web/scripts/check-page.mjs       # OK, 2 scripts parse
uv run pytest tests/test_ui_agent_marks.py -q  # 7 passed
```

Read back in a real browser on the built page in both themes, with
computed
styles measured rather than eyeballed, on: the agents page, the settings
dialog
(usage, providers, tools), channels, scheduled work, the transcript and
the task
graph. The model panel was measured open: left edge on the field's,
width 478 of
an available 478, height shrunk to the shelf, opens downward, closes on
an
outside press.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

Visual only, and wide: the blanket input rule and `.mini` reach every
page, so
the risk is a control somewhere off this PR's read path looking
different. The
sizes are the prototype's, so a difference is a page that had drifted
rather
than a regression. `.mini.update` opts out of the new width floor
explicitly.
Rollback is reverting the commit; nothing here is persisted or migrated.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…605)

## Summary

On Safari, the task board drew every node card of a graph on the first
node's spot: a two-step run showed one box with both titles overprinted
and an arrow pointing at empty canvas where the second step should have
been. Chrome drew the same graph correctly.

The cause is how `DagGraph` placed a node: a `transform="translate(x
y)"` on the node's `<g>`, with the card inside a `<foreignObject x=0
y=0>`. WebKit hit-tests a `foreignObject` where the layout put it but
paints its HTML without the ancestor group's transform, so every card
was painted at the SVG's origin. Removing the CSS transform on the
board's viewport did not change anything; writing the offset onto the
`foreignObject` itself did, and that is what this change does: the
node's place goes on each shape (`rect`, mark, label box, or the
caller's card) and the group carries no transform. Edges, layout,
selection and keyboard handling are unchanged.

One file. The label branch used by the transcript card gets the same
treatment, since it has the same shape and would fail the same way on a
graph wide enough to notice.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `npx vitest run` in ui-web: 189 files, 2469 tests passed. New test in
`DagGraph.test.tsx`: two dependent nodes carry no group transform, their
rects sit at different places, each label box shares its rect's row and
sits 31px inside it, and a caller's card takes the node's own x/y.
- `npm run type-check`, `npm run lint` (0 errors; the 4 warnings are the
base branch's), `npm run build`, `python ui-web/build.py`,
`check-page.mjs`, `check-css.mjs`: all pass.
- Real page, served by a gateway on this branch head, opened in
Playwright's WebKit 26.5 (the Safari engine the report came from) and in
Chromium: before the change WebKit painted both cards of a two-step DAG
at the first node's position; after it both engines paint the two cards
at their own rows with the arrow between them, and every `.nd` group has
no `transform` attribute.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

Rendering only; no data, contract or behaviour change. Geometry is
identical to before on engines that honoured the group transform, since
the same numbers now sit on the shapes. Reverting the commit restores
the previous markup.

## Related Issues

N/A

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: resolve the 54 current-main merge conflicts and the source-language gate failure.

I reviewed the visual-restoration commit at 8b9b558 and, after the PR moved during verification, extended the review through current head ef3b3cb. I found no new code defect. The shared CSS and picker changes preserve the existing component boundaries and responsive constraints, and the final DAG change correctly moves every node shape to absolute layout coordinates while leaving the already-absolute edges aligned.

The standing gates remain against fetched main c777ed7: git merge-tree --write-tree github/pr-475 github/main exits 1 with 33 content conflicts, 19 modify/delete conflicts, and two file-location conflicts. The source-language check still exits 1 on the raw CJK additions in ui-web/src/rpc/fixtures/sessions.ts lines 99 and 101-103. GitHub reports CONFLICTING / DIRTY.

Verification: at 8b9b558, ui-web npm test passed 191 files / 2,492 tests, the agent-mark pytest selection passed 7 tests, generated-client verification and TypeScript type-check passed, ESLint reported zero errors and four pre-existing warnings, and the delta passed git diff --check. At ef3b3cb, the added DAG suite passed 9 tests, TypeScript type-check passed, and its delta passed git diff --check. I covered the repository rules, diff, callers and history, backward compatibility, test weakening, and the typed-transport/store/React architecture constraints.

#607)

## Summary

The strip above the composer drew one chip per running task (name only,
up to three, plus an overflow chip), each opening that task's own desk
pane. The prototype -- the spec for this page -- draws one pill, "{n}
running", with the task names as its hover title, and a click opens the
desk's tasks tab, whose rows already name each run and say how far it
has got. The strip is a reminder that background work is under way, not
a second copy of the tasks list, so this redraws it as the prototype has
it.

Three commits:

- `fix(ui-web)`: the strip becomes one `.tkrunhint` pill inside
`.tkruns`, on the dock's column axis, with a `.tkrundot` running dot
matching the task row's. It subscribes to the language store (the whole
pill is a catalogue word; the old overflow chip never did and stayed in
the served language after a pick). `gui.tasks.running_n` replaces
`gui.tasks.overflow_more`; the TUI's generated table is regenerated. The
old `.runs` / `.trun` rules leave `page.css` for the domain sheet, the
class-namespace pins for tasks come down by two, and the CONTEXT.md
"Task strip" term, the Dock comment and two dot comments are rewritten
to match.
- `refactor(ui-web)`: the tasks store's `hover` key and the row's `.hl`
class go. They existed so the strip's chip and the list row could light
each other up; with one pill there is no other side, and a row lighting
itself through a store round trip is what `.sarow:hover` already does.
The row's class is a plain attribute again, so the class-namespace tool
counts `.task` on the local list instead of the expression list: one pin
up, one down, nothing added (the tool's own header documents this kind
of move).
- `fix(ui-web)`, found by the browser regression for the pill: a spawn's
pending frame files a row under the manager's `task_id`, and the running
frame renames it onto the record id. A `tasks.list` read landing between
the two brings the same spawn in under its record id, and the store's
merge keeps the pending row beside it; the running frame then updates
the record row and leaves the pending one standing until the next full
read. On the served page the pill counted three tasks where two ran. The
frame that names both ids now folds the pending row into the record row.

Targets `refactor/ui_web_architecture`, not `main`.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

All from the repo root of the branch checkout, against the merge base
`refactor/ui_web_architecture` @ cbaa057:

- `npm test --prefix ui-web` -- 191 files, 2494 tests passed (base:
2492; the two new cases are the strip's live repaint and the reducer's
fold). Includes the gates: `check-class-namespace` OK at 260 unprefixed
/ 57 in expressions (base 261 / 59), `css-one-owner`, `i18n-keys`,
`island-lang`.
- `npm run type-check --prefix ui-web` and `npm run lint --prefix
ui-web` -- clean (the 4 `exhaustive-deps` warnings are the base's, in
files this branch does not touch).
- `npm run build --prefix ui-web && python3 ui-web/build.py && node
ui-web/scripts/check-page.mjs` -- both boot snapshots match their
goldens (235 nodes), page check OK.
- `npm run gen:check --prefix ui-web` and `npm run --prefix ui-tui
lint:i18n` -- the RPC client and the TUI's generated catalogue are
current.
- `make lint-python`, `make test-python`, `pre-commit run --from-ref
origin/refactor/ui_web_architecture --to-ref HEAD`,
`scripts/check_commit_messages.py`, `scripts/check_source_language.py`,
`scripts/check_large_files.py` -- all green (24487 python tests passed,
109 skipped); the TUI lane (`npm run lint && npm run lint:rpc && npm run
type-check && npm test && npm run build` in `ui-tui/`) passes with the
regenerated catalogue.
- Served page, real browser (playwright against a gateway started from
this checkout on an isolated `RAVEN_HOME`): two slow `spawn`s in one
conversation show one pill "2 running" (its Chinese counterpart in the
zh build) whose title lists both names; with the desk shut, a click
opens it on the tasks tab with the two rows and opens no pane; with the
desk on the deliverables tab, a click brings the tasks tab forward;
after `config.set language=en` and a reload the pill reads "2 running";
once both spawns settle the pill is gone. 6/6 steps pass on the
three-commit build. The reducer fix in commit 3 came out of this run: on
one attempt the pill briefly read 3 where 2 ran.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

## Risk

User-visible: while tasks run, the composer shows one pill "{n} running"
instead of up to three named chips; clicking it opens the desk's tasks
tab rather than one task's pane (the pane is one more click away, on the
row). The tasks tab, its running-count badge and the rows are unchanged.
Rollback is reverting the two commits; nothing outside `ui-web/`, the
catalogue and its generated TUI table is touched.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: rewrite or relocate the non-English fixtures and resolve the 54 current-main merge conflicts.

Reviewed the new task-strip aggregation and spawn-row deduplication delta. I traced the React subscriptions, desk-tab handoff, task-store refresh merge, reducer replay/idempotence path, affected callers, and the surrounding task-domain architecture; I also checked the AGENTS.md/CONTEXT.md rules, compatibility implications, history, and the changed tests for weakening. I found no additional defect in this revision.

Verification: npm test passes 191 files / 2,495 tests; npm run type-check, npm run gen:check, and npm run lint pass (lint reports the same four pre-existing hook warnings). git diff --check is clean.

The standing gates still fail against fetched GitHub main (c777ed7dfe6c): git merge-tree --write-tree HEAD github/main exits 1 with 33 content conflicts, 19 modify/delete conflicts, and two file-location conflicts; GitHub reports mergeable: false / mergeable_state: dirty. uv run python scripts/check_source_language.py github/main...HEAD also still exits 1 on raw CJK in ui-web/src/rpc/fixtures/sessions.ts lines 99 and 101-103.

## Summary

The desk's tasks list row drew `TaskRow.playbook` -- the playbook
library name the backend recovered from the first node id's prefix -- as
a grey monospaced chip between the task name and the error tag. Next to
a task it reads as a node id (`ai-competitor-compare`), and the
prototype's row never had it: a dot, the summary, the duration line and
the error tag.

Three commits:

1. `fix(ui-web)`: remove the span, its `.tksrc` rule and the
never-rendered `.tsrc` rule in `page.css` the chip's styling was
borrowed from; the test that asserted on the chip becomes a guard that a
playbook run's row shows the summary alone.
2. `refactor(*)`: with the chip gone the `playbook` field on
`tasks.list` had no reader in either UI, while every call still listed
the playbook library to derive it. The schema property, the pydantic
field, the derivation (`_derive_playbook`, `_playbook_names`, the tag
regex) and its four tests go; both generated clients are regenerated
from the contract; the offline fixtures stop sending the field. A
playbook run still tells itself apart on the wire by its node ids, which
the executor prefixes with the playbook's name.
3. `test(ui-web)`: the guard asserts the name line holds a single
element, so any second element beside the name fails it, whatever feeds
it.

Which playbook a run came from stays readable on the `load_playbook`
receipt in the conversation, and on the instance handle the task pane's
work-order tab shows.

## Type

- [x] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

## Verification

- `uv run pytest tests/test_rpc_tasks.py tests/test_rpc_schema_match.py
-q` -> 434 passed
- `uv run ruff check` and `uv run ruff format --check` on the three
edited Python files -> clean
- `npm test --prefix ui-web` -> 191 files, 2492 passed (domain suites
and the gate suite)
- `make lint-ui` -> generated.ts matches the contract (192 methods);
eslint 0 errors (4 pre-existing warnings in connections / cron /
subagents); tsc clean
- `npm run --prefix ui-web build`, `python3 ui-web/build.py`, `node
ui-web/scripts/check-page.mjs`, `node ui-web/scripts/check-css.mjs`,
`node ui-web/scripts/check-class-namespace.mjs` -> boot-snapshot
235/235, every check OK
- `npm run lint:rpc --prefix ui-tui`, `npm run type-check --prefix
ui-tui`, `npm run lint --prefix ui-tui`, `npm test --prefix ui-tui`,
`npm run build --prefix ui-tui` -> generated.ts in sync; tsc clean; 0
errors; 144 files, 2074 passed; built
- `uv run pre-commit run --files <the nine files of commit 2>` -> all
hooks passed
- Mutation check on commit 1: with the chip line put back, the rewritten
test fails (`expected 'Cross-check quotesnightly-checks' not to contain
'nightly-checks'`)
- Real gateway (`raven web` on this branch, the user's home): after
commit 1 the row of the playbook-dispatched E2E run reads `Turn the
incident note into an archived record... / 1m32s / 6 files` with no
`.tksrc` element; after commit 2 `tasks.list` for that session answers
rows without a `playbook` key and the row still draws without a chip

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed (the
design note's field list drops `playbook`; no doc names the chip)

## Risk

User-visible: the playbook name no longer shows on the task list row.
Wire: `tasks.list`'s `TaskRow` loses the optional `playbook` property;
the only clients of that contract are the two UIs in this repo, both
regenerated here, and the ACP surface does not expose `tasks.list`.
Rollback: revert the two commits.

- [x] Security impact considered (display and a dropped read-only field)
- [x] Backward compatibility considered (see Wire above)
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: rewrite or relocate the non-English fixtures and resolve the 54 current-main merge conflicts.

Reviewed the revision that removes the unused playbook chip and TaskRow.playbook derivation. I checked the AGENTS.md/CLAUDE.md and context rules, the wire-schema/model/backend/UI/fixture delta, generated clients, callers and history, backward compatibility in both client/server directions, and the changed tests for weakening. The optional response field has no remaining in-repo consumer, old clients tolerate its absence, new clients ignore it from an older server, and I found no additional defect in this revision.

Verification: uv run pytest tests/test_rpc_tasks.py tests/test_rpc_schema_match.py -q passes 434 tests; npm test passes 191 files / 2,495 tests; frontend type-check, generator parity, and lint pass (with the same four pre-existing hook warnings); Ruff and git diff --check pass.

The standing gates remain unchanged against fetched GitHub main (c777ed7dfe6c): git merge-tree --write-tree HEAD github/main reports 33 content conflicts, 19 modify/delete conflicts, and two file-location conflicts, while GitHub reports mergeable: false / mergeable_state: dirty. uv run python scripts/check_source_language.py github/main...HEAD still exits 1 on raw CJK in ui-web/src/rpc/fixtures/sessions.ts lines 99 and 101-103.

## Summary

The orchestration card in the transcript (a `run_subagent_dag` or
`load_playbook`
row, expanded) drew a graph of its own and, under it, a node panel whose
"open the
run" link raised the old agents-panel record window. The desk's task
pane already
draws the same graph and reads the same `dag.node` record, node by node,
so the
card was a second renderer for work the desk owns. The prototype's card
is the grid
alone, with the task cell as the door to the run. This PR makes the card
that.

Two commits, the second deleting only what the first killed:

1. **The card hands its graph to the desk.** The card keeps its grid
(task, scale,
playbook, state, cost, run id, replanned-into) and nothing else. The
task cell is
a door -- `role=button`, a title, a standing arrow -- whenever the run
has an
id, and opens the run's task pane on the desk. `openDagRun` goes through
the
tasks seam: `openRun` when the tasks store holds the row, one
`tasks.list` read
when it does not, the tasks tab when neither answers (a branched
conversation
replaying its parent's delivered row lands there). The delivered row
already
went through `openDagRun` and follows. The node panel, the `openDagNode`
seam,
`features/dag/open.ts`, the 21 catalogue keys only the panel read and
the
   `dag-renderer` gate go.
2. **The renderer's default box goes.** The desk's board is `DagGraph`'s
only
caller and hands it its own node card, so the default SVG box, the card
surface, the label trimming, the mark table and the per-node clock had
no
   reader left.

User-visible changes besides the door: the first failed node is no
longer opened
unasked inside the card (the desk pane's failure banner names it, one
click away);
a node's internal dependencies are read off the board's edges rather
than a list;
the node id, which the deleted panel spelled out, rides on the desk node
panel's
title.

Left for a follow-up, deliberately: `features/dag/mount.ts` and
`store.ts` (the run
state `state/session/resume.ts` still writes and nothing reads) --
deleting them
means either a new row on `domain-shape`'s shrink-only exceptions or
dissolving
`features/dag/` into its two remaining consumers, which is a decision of
its own.

## Type

- [ ] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [x] Refactor
- [ ] Other

## Verification

- `npm test --prefix ui-web` -- 188 files, 2464 tests, all pass (three
suites
fewer than the base: the two deleted gates and the deleted label suite)
- `npm run type-check --prefix ui-web` -- clean
- `npm run lint --prefix ui-web` -- 0 errors (4 pre-existing warnings in
  CronPage/SubagentsPage, untouched here)
- `node ui-web/scripts/check-class-namespace.mjs` -- OK; the transcript
and dag
  pins lowered to what the tool prints
- `npm run --prefix ui-tui lint:i18n` -- generated catalogue up to date
- `npm run --prefix ui-web build && python3 ui-web/build.py` --
boot-snapshot OK
  (235 nodes match golden)

Driven on a real host (`raven serve` from this branch on an isolated
`RAVEN_HOME`
built from a real config, deepseek as the default model, every channel
off), with
a playwright driver against the served page:

| Step | Result |
| --- | --- |
| Expand the card of a running three-node graph | grid only, no graph,
no node panel; task cell is `role=button` with a title and the arrow; a
long title is cut and the arrow stays in view |
| Click the task cell | exactly one desk pane, `task:dag:<run_id>`,
headed by the run's summary, showing the board |
| Click a node on the board | the desk's node panel with the node's
record |
| Click the task cell again | no second pane |
| Click the delivered row after the run settles | the same pane |
| Reload, expand the restored card, click | the same pane |
| A graph with one node on a lane that cannot reach its model | the
card's state row counts `1 failed`; the desk pane shows the failure
banner before any node is picked; the banner opens the failed node |
| The board before and after commit 2 | screenshots identical (node
cards, edges, lane frames) |

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

## Risk

Web UI only; no server or wire change. The transcript card loses its
graph and
node panel on purpose; every fact they showed is on the desk's task
pane, reached
from the card's task cell and from the delivered row.
`features/dag/mount.ts` keeps
running unchanged. Rollback is reverting the squash commit.

- [x] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

## Related Issues

N/A

---------

Co-authored-by: Claude (claude-sonnet-5) <noreply@anthropic.com>

@gloryfromca gloryfromca left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Blocking: rewrite or relocate the non-English fixture strings and resolve the current-main merge conflicts.

At 4b005ad46eb4 against current main (c777ed7dfe6c), both standing blockers remain: uv run python scripts/check_source_language.py github/main...HEAD still rejects the raw CJK in ui-web/src/rpc/fixtures/sessions.ts lines 99 and 101-103, and git merge-tree --write-tree HEAD github/main still exits 1 with 54 conflicts (33 content, 19 modify/delete, and two file-location conflicts). GitHub likewise reports CONFLICTING / DIRTY.

The new DAG-card-to-task-pane refactor adds no separate finding. I covered the repository instructions, the revision delta, its task-source and desk callers, history/backward fallbacks, test changes, and the documented architecture constraints. Verification passed: frontend 188 files / 2,464 tests; type-check; generated RPC check; lint with four existing warnings and no errors; TUI i18n check; production build; and the 235-node boot snapshot.

gloryfromca pushed a commit that referenced this pull request Sep 21, 2026
…ters from stores (#478)

Stacked on #475 (`refactor/ui_web_architecture`): the closing layout
pass over `ui-web/`, nine commits on top of that branch (plus the merge
of `main` into it that was already on the local branch). The base branch
is protected against direct pushes, so this lands through a pull request
instead. Everything here is DOM-invisible: the two boot goldens, the 19
region goldens, the 31 island snapshots, the boot walk and the
language-flip dumps are byte-identical before and after every commit.

- `src/shell/` is dissolved: shared React pieces go to `src/components/`
(PascalCase), pure helpers to `src/lib/`, stores and listener rules to
`src/state/`, the two behaviour modules (`panes`, `scrollbars`) to
`src/chrome/behaviour/`.
- `src/state/` is layered: the app lifecycle (`boot`, `install`,
`connection`, `updates`, `splash`) moves to `src/app/`, the gateway seam
and the transport choice join `src/rpc/`, the three language modules
become `src/state/lang/` (the barrel re-exports the store only), the
settings dialog state is one store, and the island registry moves to
`src/features/registry.ts`.
- The last four store-driven writers render through React from their
stores: the permission chip, the tier chip and its panel headings, the
rail foot, and the More flyout (a new `MoreFly` component; `mark()`
still writes `aria-current` imperatively because two test suites
hand-write those rows).
- Gates: the 24 gate scripts move to `scripts/gates/` (tools and goldens
stay put, so `build.py`, `package.json` and CI are untouched); the
module harness now refuses a fake key that names no module, which
surfaced one inert stub in an earlier commit; a ratchet gate pins every
remaining DOM touch under `src/state/` and `src/app/` per file; two new
`Dock` cases pin the chip class order and the tier icon.
- Docs: `ui-web/CONTEXT.md` gains the menu and right-click entries and
loses its stale paths; `README.md`'s layout table describes the tree as
it is.

Outside `ui-web/`: two path lines in `tests/test_ui_agent_marks.py` and
`tests/test_ui_provider_marks.py` follow the two moved component files.

Not one-to-one, both behind `hidden` and unobservable: when the tier
catalogue goes from available back to unavailable, the chip falls back
to the served literal instead of keeping the last drawn tier.

- [ ] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [x] Refactor
- [ ] Other

From the repo root, on the branch head:

- `make lint-ui` -> clean
- `cd ui-web && HOME=$(mktemp -d) npx vitest run` -> 152 files, 2494
tests passed, 0 skipped
- `make build-ui` -> `boot-snapshot: OK (284 nodes match golden)` and
`boot-snapshot: OK (285 nodes match golden)`
- `cd ui-web && node scripts/check-page.mjs && node
scripts/check-css.mjs && node scripts/check-class-namespace.mjs && npm
run gen:check` -> all OK
- `uv run --frozen pytest tests/test_ui_agent_marks.py
tests/test_ui_provider_marks.py tests/test_ui_css_token_gate.py
tests/test_living_docs.py -q` -> 55 passed
- `make test-python` -> 23137 passed, 109 skipped
- `make check-source-language` over `origin/main..HEAD` -> exit 0
- `uv run --frozen pre-commit run --from-ref origin/main --to-ref HEAD`
-> all passed
- `git diff --stat $(git merge-base origin/main HEAD)..HEAD -- .
':!ui-web'` -> the two test files above plus the three files #475
already changes
- `git grep -n "src/shell" ui-web` -> one hit, a comment inside
`page.css` left alone because the stylesheet is byte-pinned

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

- Security: none; no protocol or auth path changes.
- Backward compatibility: build entry points, script names called by
`build.py` and CI, and every URL flag are unchanged.
- Rollback: revert the merge into `refactor/ui_web_architecture`; every
commit is a move or a store-rendered equivalent of an imperative write,
with no golden delta to reconcile.

---------

Co-authored-by: xfng-sd <xufang@shanda.com>
Co-authored-by: Claude (claude-opus-5) <noreply@anthropic.com>
gloryfromca pushed a commit that referenced this pull request Sep 21, 2026
…at hold them (#481)

Stacked on #475 (`refactor/ui_web_architecture`): the conventions that
keep the rebuilt `ui-web/` from drifting, written down in
`ui-web/CONTRIBUTING.md` and enforced by gates. Twenty-two commits, each
landed with every gate green. Three of them change what a DOM diff sees
and are named below; the rest are DOM-invisible.

- **One store shape.** `state/store.ts` exports `makeStore<T>()` with
`get / set / subscribe / _resetForTests`; 33 hand-rolled stores
migrated, `getState` / `put` / `commit` / `wipe` retired; the
`store-shape` gate pins the eight modules that keep a different shape,
each with its reason.
- **One source seam.** `ds<K extends keyof Sources>(domain: K)` is
key-typed, so a renamed or missing domain is a compile error; every
`gateway()` call lives in a domain's `source.ts` (`rpc-names` enforces
placement); `app/install.ts` is the only module that assigns
`sources.*`; skills has its own `source.ts` and the shared `ext.list`
read is `features/installed/source.ts`.
- **No island bag.** `features/registry.ts`'s `islands` object is gone;
page-wide code imports a domain's store directly, and the three
callbacks `state/page.ts` needed are registered from `app/install.ts`.
That one edge was 18% of the import graph's closure weight and held the
tree's only module-evaluation-order hazard. The `import-direction` gate
now pins the layering (`lib <- components <- chrome <- app`, `lib <- rpc
<- state <- chrome, features, app`, `features -/-> features` except
`source.ts`/`types.ts`) with today's upward and cross-domain edges as a
down-or-gone list. Two of the four pinned import cycles are dissolved
the way the gate prescribes (a state module declares a slot, the domain
fills it): `state/page.ts` no longer reads `state/caps.ts`,
`state/navfly.ts` no longer imports the rail store; 17 files sit in a
cycle now, in two components, down from 21 in four.
- **Language is resolved at boot** (localStorage, then config, then
`<html lang>`), so `t(key)` is the only way to take a string; every
island root subscribes to the language store and the hand-kept repaint
list shrinks to the two imperative steps. `T()` and `lang.text()` are
retired, and so are the 61 inert `data-i18n*` attributes the old
document walk used to read.
- **Each domain declares itself once.** `features/<domain>/manifest.ts`
(aggregated in `features/manifests.ts`) and `state/pages.ts` drive the
page sections, the Escape order, the body order, the rail buttons, the
mounts and the region goldens; `domain-shape` and `domain-registration`
gates hold the two tables together.
- **Homes and names.** The desk is its own domain (`features/desk/`);
`resume` lives with the session runtime; the composer's three sheets
live in the composer; the session naming protocol is
`state/session/naming.ts`; `features/xa/` is `features/extAgents/` with
matching ids and exports; wiring modules are all `wire.ts`; page roots
are all `<Domain>App`; `popover`, `pane`, `escapeOrder`, `installEntry`
/ `installSkill`, `Frame` replace the competing words; `useInTask` (not
a hook) is `startTaskWith`; the `file-names` gate pins the case rules.
- **Hooks and types.** `react-hooks/rules-of-hooks` is an error (the
seven warnings were a misnamed verb and one hook after an early return,
now `PlainCallRow`); `tsconfig` adds `noUnusedParameters` and the eight
sites it named are gone; `npm run lint` is down to seven warnings, all
`exhaustive-deps` effects that deliberately run once plus one guarded
expression.
- **Gates and tooling.** The stylesheet hash pin is retired (the eleven
declaration-level CSS gates remain), the six CSS gates share one parser,
`check-css` and `check-class-namespace` run under `npm test`; the
class-namespace gate covers every domain with a per-domain prefix, reads
class literals inside `className={...}` expressions as well as
attributes, and pins today's debt as three down-or-gone lists (86
shared, 342 unprefixed, 91 unprefixed in expressions); fixture
responders return `Wire<ResultOf<M>>` instead of `as`-asserted literals
(which exposed five fixture answers that did not match the contract),
and the turn script fixture is a typed union with no cast left; the
offline library's coverage of every called method is gated with a named
exemption list; `boot-order` is three gates by concern; eslint with
import sorting and unused checks (the tui's config) runs as `npm run
lint` after a one-time 206-file sweep, and `make lint-ui` and the CI ui
job run it.
- **Docs and comments.** `ui-web/CONTRIBUTING.md` (layout, domain
skeleton, store shape, rendering, source and fixtures, naming, CSS,
tests and gates, tooling, the adding-a-domain checklist, and a table of
every pinned exception), new `ui-web/CONTEXT.md` entries, a README whose
layout table matches the tree, and `tests/test_living_docs.py` scanning
all three. Every code comment that explained a decision by what the
deleted script layers used to do now names the live owner or is gone.

What a reader can see change:

- The offline `?stub=1` page and the first live frame before the config
answers now render in one resolved language instead of Chinese markup
with English dynamic text; two tooltip strings in the boot golden moved
with it, and the golden now records tooltip attributes by name rather
than by sentence.
- Domain ids follow their directory: `#extAgentsPage`,
`#connectionsPage`, `#memoryPage`, `#playbooksPage` and their bodies and
rail buttons. `page.css` referenced none of them; the knowledge page
keeps `#kbPage` because the stylesheet pins it.
- The 61 `data-i18n`, `-ph`, `-title`, `-aria` and `-tip` attributes are
gone from the chrome markup. No selector, dataset read, stylesheet rule
or gate named one; the 14 goldens that carried them change by exactly
those attributes and nothing else (each new golden diffs clean against
the old one with the attribute text stripped).

Also restored: the cron page's run-history refetch on a language change,
which the earlier `redrawAll` deletion had dropped.

Outside `ui-web/`, three files: the `Makefile`'s `lint-ui` target and
the CI ui job gain `npm run lint`, and `tests/test_living_docs.py` scans
the three `ui-web/` documents.

Not in this PR, each recorded in `CONTRIBUTING.md`'s exceptions table:
i18n namespaces still use their old abbreviations because the catalogue
(`i18n/messages.json`) is shared with the TUI and lives outside
`ui-web/`; the two remaining import cycles (the six-file
composer/transcript/workspace knot and the eleven-file session knot);
the class names still defined in `page.css` rather than per domain; the
three places `rpc-schema/openrpc.json` disagrees with its own
descriptions, which is the backend's to settle.

- [ ] Fix
- [ ] Feature
- [ ] Docs
- [ ] CI / tooling
- [x] Refactor
- [ ] Other

From the repo root, on the branch head:

- `cd ui-web && npm run type-check && npm run lint` -> clean, 7 warnings
(from 14 on the base's first lint run), 0 errors
- `cd ui-web && HOME=$(mktemp -d) npx vitest run` -> 162 files, 2534
tests passed, 0 skipped (from 152 / 2494 on the base; one test whose
subject was deleted went with it)
- `make build-ui` -> `boot-snapshot: OK (284 nodes match golden)` and
`boot-snapshot: OK (285 nodes match golden)`
- `make lint-ui` -> `gen:check`, `npm run lint`, `type-check` all clean
- `cd ui-web && node scripts/check-page.mjs && node
scripts/check-css.mjs && node scripts/check-class-namespace.mjs && npm
run gen:check` -> all OK
- `uv run --frozen pytest tests/test_ui_agent_marks.py
tests/test_ui_provider_marks.py tests/test_ui_css_token_gate.py
tests/test_living_docs.py -q` -> 55 passed
- `make check-source-language` over `origin/main..HEAD` -> exit 0
- `uv run --frozen pre-commit run --from-ref
refactor/ui_web_architecture --to-ref HEAD` -> all passed
- `git diff --stat refactor/ui_web_architecture..HEAD -- . ':!ui-web'`
-> exactly `Makefile`, `.github/workflows/ci.yml`,
`tests/test_living_docs.py`
- Every new gate was mutation-checked (one-line breakage red, restored
green); per-step happy-dom dumps of the body order, listener sequence,
boot walk and language flips are byte-identical across the DOM-invisible
steps and differ only as described above in the other three.
- Not done: a real-browser pass.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [x] User-facing docs or screenshots are updated when needed

- Security: none; no protocol or auth path changes.
- Backward compatibility: build entry points, script paths CI calls, URL
flags and DOM ids the stylesheet references are unchanged; renamed ids
and the dropped `data-i18n*` attributes appear only in goldens.
- Rollback: revert the squash merge into `refactor/ui_web_architecture`.

---------

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
gloryfromca pushed a commit that referenced this pull request Sep 21, 2026
Takes the web UI's agents page (rail entry "Agent Hub", page "Agents")
to the prototype's agent hub, and gives the wire what the prototype
needed and did not have: a default model on an agent row.

Base: `refactor/ui_web_architecture`, the long-lived architecture branch
(#475), not `main`. The diff shown here is the diff that merges into
that branch.

What the page does now:

- Three sections, never folded: connected, available to connect, not
installed on this machine. One control per row (connect / disconnect /
retry / install), a status dot only where there is something to say, and
a one-line summary from a small catalogue of what each agent is and who
makes it.
- A 520x500 sheet in the shared detail host: what the agent is good at
(editable, saved on blur), the API key where one is needed, the install
command and site for an absent agent with a re-check, a Test button, and
the model pill.
- A write in flight is held on the row as "connecting" for its length; a
refusal -- a model pick included -- stays on the row as red text with
Retry rather than as a toast. A running test keeps a Stop control.

The model pill, by the row's own rule (`model_source`, a new wire
field):

- The built-in row picks from raven's connected providers. The pick is
stored naming its provider (`stored_model_id`, the way `config.set
model` stores the host's), validated against a provider raven knows that
holds a usable credential, and paired with that credential on every
dispatch through `ProviderPool.bind_pin` -- the backend resolves the pin
per run, so a cached backend never runs one conversation's key against
another's model. The write and the dispatch read the provider off the
stored id through one function, `stored_provider_name` (the inverse of
`stored_model_id`), so a section raven has no spec for resolves to that
section on both sides.
- An acp row, raven's own shipped products included, picks from the
choices its handshake advertised (`model_choices`) and the value is
pushed over `session/set_config_option`, through one resolver
(`session_model_for`) for a spawn, a direct chat and a DAG node
(`model_for` threaded through the runner).
- An openai or cli row has no menu; the pill is disabled. Unset reads by
ownership: one of raven's own "follows the main Raven", a third party
runs on "its own default". Ownership is the wire's `own` (a new field:
built-in, vendored, or an acp row whose handshake named Raven), with the
catalogue as the fallback for a server that predates it. The picker
offers listed ids only; there is no typed entry, since an acp row's
write takes exact menu values and a built-in row's adds to no provider.

Deviations from the prototype, deliberately:

- No cancel while connecting; the row shows "connecting" until the
server answers.
- Third parties do not pick from raven's providers: there is no path
that would deliver raven's credential to them, so they pick from their
own advertised menu.
- Raven-PPT is a hidden row and is not listed; "build an agent" is not
in this PR.
- The old page's rename control, transport chips and test-cost notes are
gone; the prototype has none of them.
- The built-in row's description stays read-only in the sheet (the
server now takes the write; the field follows later).

Install data (commands and sites) comes from each vendor's own
documentation: Claude Code, Codex, OpenCode, Hermes, OpenClaw,
MiroThinker, GitHub Copilot, Qwen Code, CodeBuddy, Qoder, Grok, Kimi
Code, Pi.

Wire changes: `SubagentRow.own / model / model_choices / model_source`;
`subagents.update` takes `model`, `provider`, `clear_model` (clear wins)
and `description: null` (back to the factory text; a built-in row goes
back to following its seed); `subagents.test` takes `source:
"vendored"`; `ThirdPartyAcpSubagentConfig.model` (and
`schemas/subagent.schema.json` re-exported for it);
`SubagentManager(provider_pool=...)` is injected by the loop and the
playbook CLI.

Reviewed in two passes (three read-only reviewers plus a refuter per
finding): ten findings confirmed, all fixed in the backend commit --
among them a dispatch that withheld the model from a cached built-in
backend, a `model_source` that promised a write the server refused, and
a built-in override edit that would have shadowed a legacy-spelled row.

Review round (gloryfromca, LivXue): all five of the first review's
blockers and the second review's two blockers plus its two "fix or file"
items where a fix was in reach are addressed in four commits -- the
one-resolver read of a stored built-in model (`stored_provider_name`)
with the spec-less section case tested on both sides and an unreadable
config raised rather than blamed on the pick, the unusable-pin warning
keyed per pin, a refused acp push said once per value instead of once
per turn, the wire's `own` flag read by the page, the stored-prefix
spelling matched to the host slug, no typed picks, a Stop for a running
test, the model op held on the row, and the stale narrative in the
manifest, the CONTRIBUTING ledger rows and two comments. The first
reviewer's two later nonblocking follow-ups are in as well: the Claude
Code preset no longer requires a `claude` on PATH (the adapter's SDK pin
carries the CLI), and a stored id whose prefix names no provider section
is read as part of the id, so a hand-written gateway-served id keeps its
pin. The second review's finding 4 is fixed on the record side: a failed
manual Test writes its verdict over the previous record's capabilities
rather than erasing them, and a row recorded before the menu existed
reads "attention, model menu not measured" until a re-verify lands
instead of a stored "ready" (`CapabilitySnapshot.model_menu_measured`).
Deferred by the owner as a feature the prototype does not draw: a row
field saying whether the stored model is in effect (pin resolved / push
accepted), which would be the durable answer to the second review's
"silently" in findings 1 and 2. Not in this PR and not tracked as an
issue for now.

Four pre-existing defects the live regression surfaced are fixed here as
well, each in its own commit:

- `raven acp` answered `session/set_config_option` (and `session/new`)
with the process default as the model option's `currentValue`, because
it asked `model.options` without a session id. A client that renders
`currentValue` as the selected item snapped back to the old model after
every switch. Both call sites now ask about the session whose answer it
is.
- The acp probe resolved `argv[0]` alone, and a shim-launched preset's
command is an `npx` one that resolves on any machine with node, so Pi
read as connectable wherever `pi` was not installed and the connect
failed a minute later inside the adapter. `SHIM_REQUIRED_EXECUTABLES`
(in `presets.py`) names the local agent a shim reaches for with its
install command; the probe asks after it too and reports the row missing
with the install beside it. Only Pi is listed: `codex-acp` bundles its
agent, and `claude-agent-acp` runs the CLI its
`@anthropic-ai/claude-agent-sdk` pin carries as a per-platform optional
dependency (verified in the 0.66.0 package: the SDK's platform package
holds the `claude` binary, and the live run's "Claude Code 2.1.220" is
that bundled build, not the machine's), so neither wants an install.
- The Claude Code preset could not connect for an account whose Claude
settings name a recent model: `claude-agent-acp@0.66.0` bundles CLI
2.1.220 through its SDK pin, and the CLI refuses such a model ("version
2.1.251 or newer is required"). The pin is now 0.79.0 (CLI 2.1.274),
measured: handshake with resume, fork and load, six model choices, five
modes; connect from the row in 9 s where 0.66.0 failed after 155 s.
- A row connected from the page recorded no capability snapshot, so it
read "capabilities not recorded -- run a test", stateless and menuless,
until a Test or a restart. The connect now records the handshake the
Test would (`record_capabilities`, shared by both), only when the record
is missing, stale or from before the menu.

- [ ] Fix
- [x] Feature
- [ ] Docs
- [ ] CI / tooling
- [ ] Refactor
- [ ] Other

Backend (worktree venv with the dev extra):

```
uv run pytest tests/test_rpc_subagents.py tests/test_rpc_subagents_model.py tests/test_rpc_schema_match.py \
  tests/test_subagent_manager.py tests/test_subagent_probe.py tests/test_subagent_dag_runner.py \
  tests/test_subagent_acp.py tests/test_subagent_builtin_agents.py tests/test_rpc_registration.py \
  tests/test_agent_loop_session_model.py tests/test_agent_loop_subagent_role.py \
  tests/test_subagent_direct_chat.py tests/test_subagent_workdir.py tests/test_agent_loop_web_tools.py \
  tests/test_cli_playbook_commands.py tests/test_config_live.py -q -p no:randomly
1471 passed
uv run pytest tests/test_acp_methods.py tests/test_acp_config_options.py tests/test_subagent_probe.py \
  tests/test_subagent_third_party.py tests/test_rpc_subagents.py -q      # the two fixes: 581 passed
uv run pytest tests/test_rpc_subagents_model.py tests/test_subagent_manager.py tests/test_provider_pool.py \
  tests/test_provider_wire_model.py tests/test_subagent_acp.py tests/test_rpc_subagents.py tests/test_acp_methods.py \
  tests/test_acp_config_options.py tests/test_subagent_probe.py tests/test_subagent_third_party.py -q
1345 passed                              # after the review round; 948 passed on the five files the rebase's auto-merge touched
make test-python                         # 24016 passed, 109 skipped (the full unit suite)
make lint-python                         # clean
```

Web and TUI:

```
npm run gen:check --prefix ui-web        # generated.ts matches the contract (189 methods)
npm run type-check --prefix ui-web       # clean
npm run lint --prefix ui-web             # 0 errors (5 pre-existing warnings in other domains)
npm test --prefix ui-web                 # 190 files, 2550 tests passed (after the rebase onto the current base)
node ui-web/scripts/check-class-namespace.mjs   # OK, extAgents pinned at 0 / 0
npm run lint:rpc --prefix ui-tui         # in sync
npm run lint:i18n --prefix ui-tui        # up to date
```

Served page (a test gateway from this branch, an isolated RAVEN_HOME
copied from a real one): roster of 5 connected / 4 available / 9 not
installed with display names; Raven-Research's pill lists the 55 choices
its handshake advertised, a pick lands on the row and reads back from
`subagents.list`; the built-in row's pick is stored as
`openrouter/anthropic/claude-opus-5` and clears back to null; the three
products whose snapshots predated the menu key were re-verified at
startup and got their menus.

Live regression on the same gateway: a product row switched off and back
on through the row control (the readiness ping relaunches it);
MiroThinker connected with a key typed in the sheet; an absent row's
install block, copy and re-check ("still not found"); a pick and a clear
on Raven-Code's own menu. Then, with the session on DeepSeek and
Raven-Research's row set to `deepseek/deepseek/deepseek-v4-flash` from
its menu, one direct-chat turn to a Raven-Research instance: the host
sent `session/set_config_option {configId: model}` with that value (acp
frame journal), the child logged `Session model:
deepseek/deepseek-v4-flash` and ran the turn on it (`Iteration 1/60
model=deepseek/deepseek-v4-flash`), and answered in 15 s. Connecting
Claude Code, Codex and Pi from the row each ended in the server's
refusal shown red on the row with Retry -- the machine's Claude Code CLI
is too old for the adapter's model, Codex is not logged in, Pi's
executable is not installed -- so their Test buttons were not reached.

- [x] Relevant tests pass locally
- [x] Relevant lint / type checks pass locally
- [ ] User-facing docs or screenshots are updated when needed

- Behaviour change: the agents page is redrawn; a built-in row's `model`
in config (previously masked by the turn binding on every dispatch) now
takes effect, paired with its own credential, and falls back to the
conversation's model when no credential can serve it (one warning in the
log). An acp row's `model` is new and off by default.
- The Pi row now reads "not installed" on a machine without the `pi`
executable, where it read "available" before; a hand-written row is not
held to this, only a row carrying the preset's provenance.
- An acp row whose capability snapshot predates the model menu reads
"attention" with a "run a test" detail until the boot-time re-verify or
a manual Test records the menu; before, it read the old "ready" with a
disabled model pill. A failed manual Test no longer drops a row's
recorded menu and statefulness.
- A built-in row's bare stored id (written by hand before this branch)
is now paired by keyword on read and re-stored prefixed on its next
write; a pick under a spec-less section is accepted only for an id that
section lists. A hand-written id whose prefix names no provider section
(`deepseek-ai/DeepSeek-V3`) keeps its provider derived by the pool --
the configured gateway, as such an id always ran.
- Rollback: revert the PR; the new config fields (`model` on an acp row,
a built-in override row) are ignored by the previous code.

- [ ] Security impact considered
- [x] Backward compatibility considered
- [x] Rollback path is clear for risky changes

N/A

---------

Co-authored-by: Claude (claude-fable-5-1) <noreply@anthropic.com>
@gloryfromca

Copy link
Copy Markdown
Member

Superseded by #612, which landed this work on main as 83 individual commits rather than one squash. The branch behind that PR is tagged backup/ui-web-linear-20260922.

Anything still targeting refactor/ui_web_architecture now needs to be retargeted at main: the rebuild is on main, and that branch no longer leads anywhere.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants