Tool results only ever rendered as plain text in the transcript. This
adds the model-driven rich-preview lane every comparable surface has,
in turnstone's developer-tool idiom: a preview pane that opens BESIDE
the conversation, keyboard-operable, sandboxed, never replacing the
transcript that spawned it.
Backend
- New built-in open_preview(target, kind?, title?): resolves an http(s)
URL, a file path, or attachment:<id> to bytes; classifies into
web/pdf/image/table/text/markdown (magic bytes > MIME hint >
extension > UTF-8 fallback, legacy-charset pages transcoded); caps
size per kind; persists content-addressed with kind="preview" —
refcounted and GC'd with the workstream, skipped by trajectory
reconstruction so preview bytes can never materialize onto the wire.
URL targets gate like web_fetch (network egress); paths/attachments
run unprompted like read_file.
- New core.web.fetch_with_ssrf_guard: manual redirect walk that
SSRF-screens every hop BEFORE requesting it (follow_redirects=True
checked nothing between hops); adopted by both open_preview and
web_fetch. URL userinfo is stripped before the descriptor or the
stored bytes see it; <base href> is injected doctype-safely so
relative assets resolve without quirks mode.
- The preview descriptor rides the tool turn's meta side channel with
ONE shape on every boundary: the live tool_result SSE event, the
conversations.meta column, and the /history projection. Cancelled
batches commit an already-announced preview (blob + meta) instead of
stranding the open pane on a permanent 404.
- New GET {ws}/attachments/{id}/preview (read scope, same ownership
gate as /content) serves the STORED type with per-MIME hardening:
bare CSP sandbox for text/html (renderable, scriptless, opaque
origin), no CSP for application/pdf (Chromium's viewer refuses
sandboxed contexts), full default-src 'none' otherwise; filenames
fold to latin-1-safe ASCII. The console /node proxy now forwards
CSP/nosniff/disposition/cache-control instead of dropping them.
- History loads exclude preview blobs from the bulk content fetch at
the query (they were read and discarded on every load).
Frontend
- New "preview" pane type registered in the shared shell (server +
console): openPaneBeside placement, per-kind renderers — fully
sandboxed iframe for pages, browser PDF viewer, sortable tables
(CSV/TSV/JSON, ragged-file safe, 5k-row cap), rendered markdown,
text — plus back/forward history with arrow keys, reload persistence
via pane meta, and backoff auto-retry (0.9s..7.2s) bridging the gap
between the live descriptor and the batch fold that commits its blob.
- Tool results carrying a descriptor render a credential-redacted
preview chip (the reopen + replay affordance); live results auto-open
the pane only while the originating pane holds focus.
Docs: docs/tools.md + prompts/tools.md. Tests: policy unit tests, tool
prepare/exec (mocked fetch), serving route + proxy header pass-through,
storage exclusion on both backends, cancel-path commit, JS static
guards; a headless-Chrome harness drives the real module graph (32 DOM
assertions).
New shared ES6+ module (redact_credentials.js) provides comprehensive
visual credential censorship matching the backend output guard patterns:
- PEM private key blocks, connection strings, Bearer tokens
- OpenAI / GitHub / AWS / Google API key formats
- Query-string and JSON-style credential values
- JSON secret keys (api_key, password, token, authorization, etc.)
- ENV secret lines (SECRET_KEY=, DATABASE_URL=, etc.)
Integrated into both frontend surfaces:
- interactive.js: replaces legacy minimal _redactApiKeys function
- conversation.js::buildConvResult (shared substrate, used by coordinator)
- coordinator.js::renderToolOutput fallback paths
Backend parity: added 'authorization' to the JSON secret regex in
output_guard.py so the output guard flags and redacts Authorization
headers in JSON tool output.
Tests: ported the runtime smoke test from the removed _redactApiKeys
to import the new module directly; added redact_credentials.js to the
var-free and const-reassign guard bundles.
The pane/workstream accelerators only worked on macOS. They were bound to
Ctrl, which on Windows/Linux IS the browser's own accelerator: Ctrl+T,
Ctrl+W and Ctrl+1-9 were swallowed by the browser (new tab / close tab /
switch tab) and never reached the page. macOS browsers own Cmd instead, so
Ctrl was free there and everything appeared to work.
On top of that the shortcuts were declared in three places that had drifted
apart — the "?" overlay, each app.js keydown handler, and the tab-menu
badges in shell.js. The console fell to convTabMenu's node-proxy fallback
lane, which dropped every shortcut badge (and Fork), so its tab menu showed
no accelerators and Ctrl+W there just closed the browser tab.
Choose the modifier per platform (Ctrl on macOS, Alt on Windows/Linux) and
make shell.js the single source of truth for the per-pane accelerators: a
stable accel registry drives both the platform-aware badge and one shared
keydown handler that invokes the ACTIVE pane's own menu item, so a badge
can't advertise a chord the handler ignores and each surface contributes
only what it supports (the console omits Fork; it has no fork surface yet).
Each surface's app.js keeps only its global accels (new / switch /
dashboard); the console regains switch + dashboard to match. Mod+W now
uniformly means Close pane (drop the tab, session keeps running), matching
its badge and the universal Ctrl+W convention — previously the standalone's
Ctrl+W stopped the session. The previously-dead "Refresh title / Ctrl+Shift+R"
is wired, and Ctrl+T / Ctrl+D yield to text editing while a field is focused
(macOS transpose / delete-forward).
The rank guard now derives needs_approval through the real _prepare_tool
on a bash call under an allowlisting persona instead of scripting the
flag, and asserts the approval gate actually fires. The row-shape guard's
source-grep is replaced with behavioral collector tests driving both
ws_created lanes (poll-diff and SSE relay), plus a proxy-forward twin and
a saved-list value assertion that would catch positional column mix-ups.
Receiving-side stamping gets its first HTTP coverage: create with an
explicit persona under workstreams.create only (selection needs no
persona perm), kind-mismatch and unknown-name 400s, omitted-persona
default stamping, the 503 on a failed default lookup, and the clean-None
legacy lane. Resume adoption is pinned end to end: a corrupt target stamp
leaves the session fully intact, an MCP-on stamp is refused when the
client was persona-gated at construction, and an MCP-off stamp drops the
live surface (listeners deregistered, toolsets reset). Soft-set
tool_search expansion recomposes the prompt exactly once; legacy sessions
never recompose.
Compaction legs run real flows now: spill plus the recall-pointer variant
under memory-off with recall visible vs hidden, and a full stamp
surviving compaction-then-resume. Migration 063 gains the downgrade
config-cleanup case (stamps removed, creative_mode preserved) and the
conversion idempotency case (already-stamped creative rows don't crash
the upgrade).
RBAC coverage goes cross-perm: read-only and write-only principals hit
every verb (a wrong-perm-name regression in any handler is now visible),
archive and default-flip succeed through PATCH, persona.* strings
round-trip the role editors and the overrides overlay, and the production
route table is asserted directly (no DELETE registered). Endpoint/storage
fixtures move off migration-seed names; storage hardening tests cover the
size caps, corrupt-row reads, the TypeError-to-ValueError ordering, the
duplicate-name race mapping, and the single-default backstop. Shell
asserts pin the new picker surfaces and drop the last persona-as-kind
wording.
Review pass over the branch surfaced real defects, all fixed here with
regression guards:
- Fork-resume (resume_ws) adopts the SOURCE workstream's stamp,
resolved pre-construction so all four levers (including the
construction-time MCP gate) bind the fork; a corrupt source stamp is
a loud 400, an unstamped legacy source forks unstamped — never the
kind default. Watch-restore and CLI --resume thread the stamp the
same way, closing an MCP leak where a restored MCP-off workstream
re-merged the catalog.
- SessionManager.open parses the stamp inside the install guard so a
corrupt stamp releases the reserved slot; a retry reproduces the
loud error instead of 'already tracked'.
- Mid-session resume() adopting a stamp rebuilds the tool_search
pathway to match (hard set drops it, soft set force-constructs it);
soft persona sets survive the global tool-search setting being off.
- Memory nudges gate on actual memory-tool VISIBILITY, not just the
memory lever, so an allowlist that hides the tool also silences the
nudges that point at it; post-compaction resume gets a no-recall
nudge variant when the pointer would dangle.
- Console PATCH: explicit null flags from UpdatePersonaRequest no
longer archive the persona or flip levers on a rename; multi-kind
personas survive a shelf edit; admin list ships the per-kind
tool_inventory so the shelf checklist tracks the server inventory
instead of a hardcoded JS list; admin CRUD moved off the event loop.
- Migration 063 converts legacy creative_mode workstreams to the full
writer stamp (downgrade removes all persona keys).
- REPL: /new passes the persona; /workstreams unpacks the widened row.
- Shared resolve_persona_for_kind is the single eligibility rule for
the HTTP handler, CLI, and spawn precheck; spawn_batch memoizes the
persona lookup; ToolSearchManager.is_expanded gives the visibility
tail an O(1) probe.
Coordinators carry LLM/auto titles like interactive workstreams but had no
way to regenerate or rename them. Port the interactive "Refresh title" (LLM
regenerate) + "Edit title" (manual alias) dropdown actions by lifting the
two handlers — the last shared verbs that weren't yet lifted — and opting
coordinators in.
- session_routes.py: add make_refresh_title_handler / make_set_title_handler
factories (cfg pattern, mirroring make_close_handler). set_title resolves
the workstream BEFORE the alias write and 404s when the kind has no
tenant_check storage gate and the in-memory manager doesn't own it:
set_workstream_alias is a global, kind-unscoped UPDATE, so this prevents
an operator renaming a workstream the coord manager doesn't own (e.g. an
interactive ws via the coord route) and the silent-200 on a bogus id.
- server.py: re-point the interactive bundle to the lifted handlers; drop
the standalone refresh_workstream_title / set_workstream_title.
- console/server.py: wire refresh_title / set_title into the coord bundle
(gated by the existing admin.coordinator operator check).
- shell.js: enable titleVerbs on the coordinator pane's tab menu; the
base-aware lane posts to the console-origin coord routes.
Tests: coord refresh/set-title (regenerate, operator-gate, 404 unknown,
alias store + broadcast, empty, conflict, cross-kind reject); interactive
title tests re-pointed to the lifted handlers for lift-parity; shell.js
coord-menu assertion.
- DRY the launcher create body: the multipart (meta + file parts) vs JSON
framing was duplicated in _createCoordinator and _createInteractive — extract
_createWorkstreamFetchOpts so the create wire shape lives in one place.
- Correct the proxy comment: the forwarded owner uid comes from the
authenticated ws_body (as on the JSON path), not the caller's meta; the proxy
token source is console-proxy, not console.
The console creates interactive sessions by proxying to the owning node via
/v1/api/cluster/workstreams/new, which only forwarded JSON — so a file staged in
the launcher was blocked with "Attachments aren't supported for interactive
sessions yet". The node create endpoint already accepts multipart (meta JSON +
file parts) on interactive_endpoint_config; only the proxy lacked it.
Teach create_workstream to accept multipart: parse meta + files (same caps as
the node), pick the node exactly as before (auto / pool / pinned), and forward
the files instead of re-serialising JSON. _createInteractive sends multipart
when files are staged (mirroring _createCoordinator) and the launcher gate is
removed. The files-need-a-task guard already ensures an initial turn to
dispatch them on.
A console interactive pane is node-proxied — every request rides the pane's
transport base ("/node/{id}"). The attachment controller hardcoded bare
/v1/api/workstreams/... paths, so upload / list / delete / preview landed on
the console's OWN coord route, which resolves ws_id via coord_mgr.get() and
404s as "coordinator not found". The standalone server (base="") was
unaffected, which masked the bug.
Thread the pane base through: createAttachmentController and
buildAttachmentPreview take an optional getBase / base, and the interactive
pane wires this._base into both. Coordinator panes and the standalone server
pass "" and stay origin-mounted as before.
_buildHandle hard-coded aria-valuemin/max at 10/90 (inherited from the
old ui/static implementation) while the actual drag/keyboard clamp is
_ratioBounds — the cell minimums against the split node's OWN px region
(a 1200px host really clamps at ~17/83; nested splits sit tighter), so
assistive tech was told a wider range than the separator allows.
aria-valuenow/min/max are now all written in _applyLayout's handle loop
from _ratioBounds(h.node) — one writer, refreshed on every drag,
keyboard nudge, and structural change. A bare window resize can stale
the advertised range until the next interaction (no resize listener by
design — % insets make resizes free), still strictly truer than a
constant. The max>=min guard covers a host shrunk below two cell
minimums, where the bounds legitimately cross.
The per-pane ✕/− chip floats at the pane's top-right — exactly where
the coordinator sidebar's toggle row and Children refresh button sit,
so the chip covered them. Pane-hosted coordinators now start the
sidebar content 44px down (padding, not margin, so the column's left
border still runs the full pane height); the standalone coordinator
page has no chip and keeps the 14px default.
Dual designer review (one primed on the branch context, one cold), all
measured findings applied:
- The per-pane chip was a mode-error trap: identical glyph at the
identical locus, reversible in split mode (hide cell) but destructive
single-pane (close pane). Now − hides, ✕ closes, and the close mode
wears a danger hover/focus ring so the irreversible action telegraphs
before the click lands.
- Single-pane chip anchored to the VIEWPORT: an unpositioned section
resolves absolutes to <body>, so the chip only coincidentally landed
near the pane corner. .panes > section.pane is now position:relative
in both modes (all pane-content absolutes verified to anchor to their
own local relative parents).
- Light-theme AA (measured): .shown tab underline 55% mix composited to
2.34:1 -> 80% (~3.7:1 light / ~5:1 dark); focused-cell ring 2.60:1 on
light -> 75% mix override there (dark keeps 55% at 3.75:1).
- Chip: border --hair-2 measured ~1.3:1 (invisible) -> --ink-4; 22px
target under WCAG 2.5.8's 24px floor -> 28px; right offset clears the
message scrollbar gutter; light resting glyph one ink step up.
- Focus bar inset 1px from cell sides (no doubled-accent stripe where
it butted a separator at the T-junction); greyscale font smoothing on
the tail glyphs (subpixel RGB fringed the box-drawing characters).
Rejected with rationale: aria-pressed on the split buttons (they are
one-shot verbs — splitting again nests — not mode toggles).
Four refinements from first live use:
- Per-pane ✕ chip, top-right of every visible pane. Split mode: hide
that cell (closeCell — the tab stays, the sibling absorbs the space).
Single-pane: close the pane outright (withheld from the unclosable
Dashboard). The click decides at click time; the label tracks the
mode. Manager-injected into the pane section — content untouched.
- Coordinator child links open BESIDE the coordinator (openPaneBeside:
split right of the focused cell, seeded with the child pane) instead
of replacing it — the parent stays on screen. Degrades to the plain
focused-cell swap when the split is denied (cap / narrow viewport).
splitFocused() gained an optional explicit-fill parameter for this.
- Tier-1 ws_closed now CLOSES the open interactive pane (tab gone, a
split cell collapses) — the coordinator-closes-its-child flow,
matching the standalone's pane-auto-close. The dead-banner lane
stays for streams that die without a ws_closed (node crash/network),
where the session may still be revivable.
- Paint bug: the focused-cell ring was an inset box-shadow on the
section, which paints in the element's own background layer — UNDER
opaque children touching the edges, so the status bar / composer
strip occluded it. The ring now rides a click-transparent ::after
overlay above pane content; the 2px top bar sits above the ring line.
The livepass shell surface's demo panes grew a .ws-status-bar footer so
the occlusion bug class stays visible to future passes.
Revives the split-pane feature retired with ui/static (step 6), rebuilt
on PaneManager: an optional binary layout tree (null = the one-pane-per-
tab behaviour, unchanged) renders visible panes as %-inset cells — no
reparenting, so live stream DOM, scroll state and media survive layout
changes. Tabs stay global: the active tab is the focused cell, a
backgrounded tab swaps into it, clicking inside a visible pane focuses
its cell, .shown marks visible-unfocused tabs. Separators resize by
pointer-capture drag and arrow keys (role=separator + aria-value*); the
tree persists in the working-set blob and rehydrate prunes leaves whose
pane did not restore. Limits: 6 cells, 200x150 cell minimums, denials
toast the manager's reason.
Affordance: Split right / Split down / Unsplit buttons in the tab-bar
tail replace the redundant [+] (the permanent Dashboard tab is the
launcher) — deliberately no contextmenu override this time. The dead
TS_APP.focusLauncher seam goes with it.
Measured chrome: the focused cell wears a 2px accent top bar (no thin
tinted ring clears 3:1 in both themes) plus a 55%-mix inset ring;
separators rest at --ink-4 with solid-accent hover/drag/focus; .shown
tabs carry an accent underline; the tail cluster is fenced and lifted
to --ink-3.
scripts/livepass.py grows a third surface: shell/livepass.html boots
the real shell.js + pane.js and drives ?split=right|down|three|none
(+ &theme=light), stamping SPLIT-READY-<cells> / SPLIT-FAILED-<reason>.
* fix(ui): re-home MCP consent badge on the Manage Connections row
The L-shell renovation retired the standalone settings gear (#settings-btn).
The MCP pending-consent badge anchored to that gear via _refreshConsentBadge,
which null-guarded silently — so since the renovation pending consent requests
had no indicator (the badge was invisible).
Re-home the badge on the rail's Manage row where the MCP/connections surface
lives in both deployments:
- rail.js gains a generic setRowBadge(tabKey, count, label?) hook + a `badge`
builder: a small ⚠-glyph + count chip (never colour alone) using the DS warn
tokens. mountManage registers row + owning-group-head refs and re-applies live
counts across a (re)mount. When the owning group is collapsed, the count also
mirrors onto the group head so a hidden row never hides the signal. rail.js
stays agnostic — it owns the mechanism, the caller owns the meaning.
- shell.js (the ESM bridge) re-exports setRowBadge on window.TS_SHELL so the
classic ui/static/app.js subsystem can drive it without importing the module.
- The standalone consent subsystem keeps its shell-level ownership: _refresh-
ConsentBadge now drives setRowBadge on the Connections tab, fed by both the
loadPendingConsents hydrate/poll load and live onConsentDetected notifications.
- The shared interactive pane host bridges onConsentDetected to the new
window.TS_APP.onConsentDetected seam (undefined on the console, so the console
pane stays a no-op there); panes only notify.
- The dead colour-only gear badge CSS (.settings-consent-badge, red dot) is
removed; the new chip lives in shell.css as token-only .rail-badge so it
flips themes by construction.
Console MCP tab (Extensions > mcp) and standalone Connections tab
(Extensions > connections) both badge correctly. Pins extended in
test_shell_js.py + test_app_js.py.
* fix(ui): drop the unused head ref from the rail badge row map
Review feedback: _rowEls stored each row's group-head element but every
head consumer resolves it through _groupEls; keeping the duplicate DOM
ref made the remount state shape harder to reason about.
Review follow-ups: the new rail collapse and mobile drawer had no
committed guards (the repo pattern is per-step string assertions in
test_shell_js.py) and openPopupMenu — now load-bearing for both the
tab dropdown and the footer user menu — was unpinned.
- test_rail_collapse_glyph_strip: persistence key, toggle +
aria-controls, class-flip seam, cpill-label/manage-glyph companions,
52px desktop-scoped CSS.
- test_mobile_drawer_off_canvas: burger, scrim, rail-open flip,
pane-activation auto-close, off-canvas translateX + visibility:hidden.
- test_popup_menu_shared_helper: the export + both consumers (the user
menu's prefer-up path included).
- shell.css: the 769/768 media blocks are a matched pair CSS cannot
express as a shared token — both now carry a cross-referencing
change-both comment.
utils/toast/kb/cards/auth/renderer/composer/composer_attachments/
composer_queue/status_bar convert from classic scripts (implicit globals,
IIFE wrappers) to ES modules with explicit exports. Parse-time
cross-dependencies become real imports (auth/kb/cards -> utils,
auth/cards -> toast, cards -> auth, renderer -> utils), which deletes the
implicit script-order contract those files relied on. utils stays
import-free (bottom of the graph); its two upward calls (setMarkdown ->
renderer, export -> toast/auth) late-bind through window at call time to
avoid import cycles.
Each module installs a transitional window bridge for the still-classic
bundles (console app/admin/governance, ui app, inline onclick=), which
only touch the globals at boot/event time — verified by a column-0 /
IIFE-body audit of all four consumers, and including the audit-missed
initLogin() that both app.js boot paths call. theme.js stays classic:
deferring it would flash the wrong theme before first paint. Vendored
katex/hljs/mermaid stay classic and lazily typeof-guarded.
interactive.js and shell.js drop their bare-global reads for real imports
(authFetch, showToast, Composer, StatusBar, queue/attachment controllers,
streaming renderer, setMarkdown). The three HTML entries load the
substrate as module tags (same positions, same version_html stamping);
classic admin/governance/app still parse first, modules evaluate before
shell.js calls TS_APP.boot().
Tests: auth/kb/utils move from test_app_js's classic node-check sweep to
test_shell_js's module-semantics sweep, which now covers all 15 shared
modules (sink scan excludes renderer.js, the sanctioned HTML producer;
the no-var ratchet covers the var-free subset). The const-reassign guard
re-includes the converted files plus the shell modules.
A dead interactive controller's base goes stale once its node loses or
re-homes the ws, but menuBase() returned it first — so the close/delete
404-as-success lanes could silently drop a tab whose session is alive on
the node it re-homed to. Mirror the revive path: when isDead(), lead with
the live Tier-1 node and fall back to the stale base only when the ws is
gone cluster-wide (its 404 then correctly reads as "already closed").
Two reported console bugs, one shared root: a pane can outlive its
session, and nothing brought the two back together.
Reconnect: an interactive pane whose stream died (ws closed/evicted
elsewhere, node restart, re-home) could never reconnect while its tab
existed — openPane() on an existing pane was focus-only, the
controller's connect() is one-shot, and its 5s recovery loop re-dialed
the SAME node forever (infinite 404 polling through the console proxy).
The only workaround was closing the tab before resuming.
- createInteractivePane now tracks terminal failure: 3 consecutive
CLOSED recovery beats -> give up (stream closed, timers + any pending
history load invalidated, status bar "Disconnected", opts.onDead
fired once). host.onStreamOpen (new hook) resets the counter;
isDead()/markDead()/base join the controller surface; onLogin
ignores a dead controller — revive owns recovery, so a deliberately
closed session is never resurrected by a timer.
- PaneManager.openPane fires pane.onReopen(extra) when it targets an
ALREADY-OPEN pane — the explicit-intent signal (saved-list resume,
rail row, child link) that activate() can't carry (hooks no-op on the
active pane, and onActivate also fires on plain tab switches).
getPane() added for cross-cutting lifecycle signals.
- The shell paints a click-to-reconnect banner on give-up — and
immediately on Tier-1 ws_closed via the new
TS_SHELL.notifySessionClosed seam (the console keeps the tab, unlike
the standalone's auto-close, so the conversation stays readable).
Reopen/banner-click revives: tear down the dead controller,
re-resolve through the origin-first POST /open lane, rebuild. The
forced resolve skips BOTH beginConnect fast paths (a stale Tier-1 row
must not bypass /open) while a live node leads the hint chain (an
origin-first /open then reuses a genuinely-live session instead of
loading a duplicate on the old meta node). The standalone lane POSTs
its local /open on revive too — /events 404s on an unloaded ws.
- Coordinator parity: the factory exposes reconnect() (acts only on a
missing/CLOSED stream; OPEN is healthy, CONNECTING is already being
worked) and the pane's onReopen drives it — the saved-list resume
POSTs /open before openPane, so a fresh stream is all it needs.
Tab menu: a node-proxied interactive pane's dropdown gated every verb
on classic globals that only exist in ui/static/app.js, so the console
got a nearly-empty menu whose one surviving verb (Export) hit the
console origin and 404'd. convTabMenu gains a base-aware fallback lane:
verbs POST against the pane's OWN transport base (controller's exact
base -> persisted node hint -> live Tier-1 node; a verb is omitted
while no base is resolvable — never aimed at the wrong origin).
Close/Delete confirm first (window.confirm, the coordinator precedent)
and treat 404 as intent-satisfied (nothing left to stop/delete -> drop
the tab). exportWorkstreamDownload takes the base. The standalone
keeps its globals lane (incl. Fork) byte-identical, and an empty verb
section no longer renders a leading separator.
Verified: 189 JS-pin tests; two headless-Chrome live-DOM harnesses
driving the real modules — console 16/16 (connect -> ws_closed ->
banner -> reopen revives on a new node with the fresh hint -> give-up
stops retrying -> live-node-led resolve), standalone 10/10 (globals
menu intact, revive POSTs /open exactly once, no cluster resolve).
Multi-stage review (find → verify → sanity) of b8914854 found one critical
bug plus four minor + one nit; all confirmed against source and fixed:
- CRITICAL — the interactive launcher's "Specific node" pick was unusable:
selecting a node fired the composer `change` event → onChange →
_applyLauncherFields → _populateLauncherNodes → setOptionChoices, which
rebuilds the <select> and reset it to the placeholder, wiping the selection
the instant it was made (submit then failed "Choose a node…"). Fix:
_populateLauncherNodes snapshots the current pick before the rebuild and
restores it after (setOptionValue does not dispatch `change`, so no loop).
- perf — every interactive open blocked first paint on a POST /open round-trip,
even on the hot rail / active-row paths where the ws is already live. The
pane now connects DIRECTLY when the Tier-1 snapshot already names the owning
node; only the dormant / reload case (snapshot empty) resolves + opens. This
resolves the uniform-vs-gated /open question left open last change; refresh
safety is unchanged (a reload activates before the snapshot lands → nodeForWs
null → resolve path).
- bug — an errored resolve (capacity / no node free) had no in-place retry
(re-clicking the active tab is a no-op); the error status line is now
click-to-retry.
- quality — resolveInteractiveNode surfaced each failure twice (toast + in-pane
line) with drifted wording; dropped the toasts, the in-pane status line is the
single source of truth.
- quality — corrected a setOptionFieldVisible comment that cited a nonexistent
"flex/grid rule" (the row is `display: contents`).
- nit — buildController skips the redundant sessionStorage re-persist when the
resolved node already matches the persisted hint.
Guard tests extended (bug-1 capture/restore, the live-direct path); the
behavioral harnesses were strengthened to fire a real selection rebuild and to
exercise the live-direct vs reload-resolve split that the first round missed.
Workstream-lifecycle bugfixes on the L-shell:
- Node-proxied interactive panes now SURVIVE a browser reload. On first
activate a pane resolves its owning node and (re)opens the session there
before streaming — the node /events stream 404s on a ws not loaded on its
node, so a rehydrated pane could not just connect blind. Resolution is
origin-first via the new TS_APP.resolveInteractiveNode seam (POST /open with
a rendezvous /route fallback). PaneManager now persists a pane's resolved
nodeId as opaque meta and hands it back on rehydrate, so a reload restores
the pane onto the SAME node even before the Tier-1 snapshot has populated —
the exact timing that used to strand it on base="" (the console, not a node).
- Both launcher personas open the new session as a PANE, not a full-page nav
(coordinator -> coordinator pane; interactive -> node-proxied pane); the
full-page nav stays only as the shell-absent fallback. Every interactive
entry point (create, active row, rail, saved row, child link, reload) now
funnels through one resolve-open-connect path, folding away the bespoke
restoreInteractiveSession helper.
- The interactive launcher gains a node-selection strategy (Least loaded |
Specific node, with a live node picker fed from the cluster snapshot) and a
persona-aware task hint — the shared composer no longer shows
"...coordinator orchestrate?" when the interactive persona is selected.
Guards updated to pin the new wiring; the stale console landing test (asserting
the renovation-retired bottom-bar node picker) is corrected to the rail.
From the multi-stage review of this session's changes (all minor):
- bug-1: the footer user-menu's deferred document-listener attach now bails if the
menu was already closed (closeUserMenu nulls the cleanup ref), closing a latent
listener-leak window.
- perf-1: fold paintConvTabGlyphs + paintConvTabTitles into one paintConvTabs — a
single findWs per stateful tab per Tier-1 render instead of two scans.
- q-2: remove the dead StatusBar.paint modelEl branch + its modelInfo arg (both
callers dropped it when the model moved to the composer chip) and the orphaned
.ws-sb-model CSS rules.
(q-1, the parallel-head string-helper extraction, follows separately.)
openPane now auth-gates pane CREATION via an optional per-type canOpen predicate
(deny -> no pane; focusing an already-open pane is never re-gated). PaneManager
stays generic — it holds a _gates map and consults canOpen/onDeny; the shell
supplies the gate.
The coordinator type gates on the admin.coordinator scope — the SAME
sessionStorage-backed _hasCoordPermission helper the launcher + saved-list use.
Because every coordinator open path (rail click, child-link, rehydrate, [+]
launcher) routes through openPane, this gates them all at once — closing the gap
where a rail click opened a coordinator pane a user lacked scope for (it then
404'd server-side). Perms live in sessionStorage so they survive a refresh →
rehydrate gates correctly (an operator's persisted coord pane restores, a
non-operator's is skipped). The backend enforces the scope too; this just avoids
opening a doomed pane.
Verified: 31/31 mechanism harness (gate allow/deny/onDeny) + real-stack console
wiring (authorized operator opens the coord pane; a no-permission stub denies a
new coord pane, gateDenied:true, errs:[]) + a shell JS guard + CSS audit baseline.
The right-floated tabbar tail (empty since the scaffold) gets a [+] button that
focuses the persona launcher — the Dashboard pane hosts the unified
coordinator/interactive launcher, and a new session needs a task prompt, so "new
session" composes there. Cross-deployment via window.showHome (both the console
and standalone expose it) with a pm.openPane("dashboard") fallback; reuses the
scaffold's .tab-add styling. Auth stays the launcher's concern (it gates each
persona option), so focusing it is always safe.
Verified: renders in the real shell (standalone harness DOM) + a shell JS guard.
Conversational tabs now show a live shape+colour state glyph (● ◐ ⚠ ✗ ○) instead
of the static ◆/○ placeholders the header removal (5e.2e) left behind — driven by
the SAME Tier-1 source + builder the rail uses, so tab and rail always agree.
- rail.js: export the glyph() builder (one source of truth for the mapping).
- pane.js: ShellPane.stateful + PaneManager.setTabGlyph/statefulTabs — generic
(PaneManager owns no glyph vocabulary; the shell passes the built element).
A stateful pane builds no static glyph; the shell paints a live .ui-glyph.
- shell.js: stateForWs() reads the Tier-1 snapshot; paintConvTabGlyphs() repaints
every stateful tab on each Tier-1 render (subscribed to TS_APP.onRender) + per
pane on activate. Coordinator + interactive panes are now stateful.
- shell.css: .tab .tab-glyph spacing (static + live); live glyphs keep their own
.ui-glyph-* state colour (no .tab .glyph override).
SINGLE WRITER: the tab glyph is written only by the Tier-1 path (the pane's Tier-2
stream drives its body, not the tab) — no two-tier race, no stale open-time
placeholder on reconnect (BRIEFING L144-147). A coordinator-telemetry-parity gap
(open Q#2) would stale tab + rail equally, consistently.
Verified: 27/27 mechanism harness (8 new glyph asserts) + real-stack wiring
harnesses (console coord ui-glyph-running / int ui-glyph-idle; standalone int
ui-glyph-running — matching the stubbed Tier-1 state, errs:[]) + 26 shell JS
guards + CSS audit at baseline.
PaneManager tabs gain a caret opening a generic, keyboard-navigable action
dropdown — recovering the affordances the pane-header removal (5e.2e) dropped.
The mechanism is generic; the item set is pane-type AND deployment derived.
- pane.js: the caret (a <span>, not a nested <button>) + _openTabMenu/_closeTabMenu
— singleton, right-anchored under the caret with overflow flip + viewport clamp,
Arrow/Home/End/Esc/Tab nav, ContextMenu/Shift+F10 + right-click open.
- shell.css: the .tab-menu chrome promoted to the SHARED sheet (both deployments),
recovered from the retired .ws-tab-dropdown design but translated onto the DS
token vocabulary (--panel-2/--hair-2/--ink-*/--err).
- shell.js: convTabMenu wires each type by capability/feature-detection —
coordinator: Export · Close pane · Close workstream (its controller's
closeSession — the Export + end removed from its header land here)
standalone interactive: Refresh/Edit/Fork · Export · Close pane ·
Close workstream · Delete (classic ui/static globals)
console interactive: Export · Close pane (those globals are standalone-only)
admin: Close pane
Three-verb close is load-bearing: Close pane (drop tab) != Close workstream
(stop session) != Delete (destroy + unsave).
Designer-reviewed both personas, dark+light: resting danger cue on Delete (never
colour-alone), elevated --panel-2 surface, accent-wash hover, light key-hint AA,
viewport y-clamp + max-height.
Verified: 19/19 mechanism harness + real-stack wiring harnesses (all three menus,
errs:[]) + 25 shell JS guards + CSS audit at baseline (zero new flips).
Two changes so the SAME shell mounts on a standalone turnstone-server (step 6's
META-GOAL: collapse the console/static vs ui/static fork):
- The coordinator pane import is LAZY + gated on caps.orchestration. A static
`import ... from "/static/coordinator/coordinator.js"` 404s on a standalone
server (whose /static is ui/static, no coordinator file) and aborts the whole
shell module. It's now `await import()` inside mountShell, before rehydrate,
registered only when the deployment has orchestration (the console); a
persisted coordinator pane then degrades to a rehydrate skip. mountShell is
now async.
- The interactive pane's nodeId is gated on caps.cluster. Node-proxy transport
only exists in a cluster deployment; on a single-node standalone every session
is LOCAL, so nodeId stays null -> the pane uses base="" (no /node/<id> hop),
even though the synthesized one-node clusterState names a node.
Console behaviour is identical (orchestration:true -> the import runs + the
coordinator registers; cluster:true -> the nodeId ternary's true branch is the
original expression). Verified both personas build clean in a headless harness:
console = [Cluster, Workspaces, Manage] + coordinator registered, no errors;
standalone = caps off, no Cluster, no coordinator, no errors.
Lift coordinator.js off the window.createCoordinatorPane bridge onto a real ESM
export, so the upcoming shared conversational module (5e) is import-consumed on
both sides rather than through a classic window global. The console shell and the
standalone page's bootstrap both import the factory now; zero behaviour change.
- coordinator.js: export the factory, drop the window bridge — no classic
consumer remains (unlike interactive.js, whose ui/static app.js still uses its
global).
- shell.js: import the coordinator factory by URL (mirrors the interactive
import) and call it directly.
- console index.html: stop script-tagging coordinator.js; shell.js's import loads
it (a classic tag chokes on the top-level export).
- coordinator/index.html: the standalone bootstrap becomes a module that imports
the factory — a classic eager IIFE ran before the deferred module loaded it.
- tests: pin the new ESM seam (export, shell import, module bootstrap).
Designer pass on the new console interactive pane. Two clean fixes; the rest of
the findings are scoped to their planned steps (see below).
- Rail Workspaces `.open` marker now tracks the ACTIVE pane instead of being
hardcoded to Dashboard — the rail map and the tab bar were disagreeing about
what's focused (opening a session never moved the rail highlight). PaneManager
gains getActive() + onActiveChange() and fans out on activate/close; the rail
keys `.open` off the active pane's rawId and re-renders on activation (not just
on the next Tier-1 snapshot).
- The active tab's glyph brightens (--ink-4 -> --ink-2) so an open session's `○`
placeholder doesn't read permanently "idle" beside its live (running ●) rail
row. (Tab glyphs go fully live in step 7.)
Deferred (planned elsewhere, not regressions): the tab CLOSE affordance is step 7
(the brief's three-verb `.ws-tab-dropdown`); the interactive/coordinator HEADER
consistency is what the step-5e base lift unifies (a shared header parameterized
by affordances), so partial coordinator-header surgery now would be a half-measure.
Verified: a headless screenshot (rail `.open` now on the active session, slim
header + persona tag render clean) + 107 JS-guard tests (test_shell_js 5d guard),
node --check, prettier, ruff.
Wire the shared interactive Pane (5a) into the console L-shell as a ws_id-keyed,
node-proxied conversational pane.
- shell.js IMPORTS createInteractivePane (interactive is a real ES module, so
the shell consumes it the modern way; the legacy coordinator pane stays on the
window.* seam — the incremental "pulled by the adopting pane" modernization).
registerType('interactive') mirrors the coordinator: build on mount, connect
on activate (idempotent) + login re-arm, deactivate on tab-away (stops
focus-stealing while the stream stays live), destroy on close.
- The node-proxy target is DERIVED from the Tier-1 snapshot (nodeForWs), so a
rehydrated pane needs no persisted node_id; a rail click / child link can pass
{nodeId} as an open-time hint. openPane(type, id, extra) threads that hint to
the factory (not persisted).
- rail.js: interactive session clicks now openPane('interactive', ws.id,
{nodeId: ws.node}) instead of full-page nav to /node/{id}/.
- interactive.css (new, shared): the embedded slim-header layout (scoped to
.pane--embedded so it never collides with the ShellPane's own .pane section —
the brief's namespace watch-out) + the conversational rendering (tool output /
media / MCP-error / verdict / output-guard cards) COPIED from ui/static. The
shared chat.css .msg/.ts-approval base is left untouched, so the coordinator
pane is unaffected; step 5e unifies the vocabularies, and ui/static keeps its
copy for the standalone until step 6.
Verified: an integration harness running the REAL shell.js + rail.js +
interactive.js (register -> rail-open -> embedded chrome -> node-proxy SSE
/node/{id}/.../events -> /history replay into real .msg turns -> destroy, zero
errors) + a screenshot; 105 JS-guard tests (test_shell_js step-5 guard),
node --check, prettier, ruff.
Four findings from the step-4 designer pass on the coordinator pane.
- P1 (bug): the `end` button ran `window.location.href = "/"`, which inside the
L-shell reloaded the WHOLE console — every other pane destroyed, all their
Tier-2 streams dropped. Thread an `onClose` through the factory; the console
pane passes `() => pm.close(pane.id)` so `end` closes that tab (and runs the
controller teardown via onClose→destroy); the standalone page passes none and
keeps the console redirect.
- P2: the pane root carries both `.pane-body` (overflow:auto) and
`.coord-chrome-root` (flex column), so the generic pane scroller redundantly
wrapped the sticky appbar. `.pane-body.coord-chrome-root { overflow: hidden }`
(scoped to this pane type) — the coord chrome owns its own scroll regions.
- P3: the coordinator tab glyph `●` collided with the rail's running state-dot
vocabulary (a static dot reading as "live"); swap to `◆` (a shape marker that
pairs with dashboard's `◇`), pending the real state-glyph in step 7.
- P3: the destructive `end` button had only a title; add aria-label
"End coordinator session".
Verified via the harness (clicking `end` closes the pane without reloading;
glyph `◆`; aria-label present; zero errors); guards pin the pane-aware close.
test_shell_js + test_coordinator_page (97 green), ruff.
The console can now host coordinator sessions as ws_id-keyed panes alongside
dashboard/admin — step 4 complete (the de-globalization landed in 4a).
- coordinator.js: `buildCoordChrome(root, opts)` builds the coordinator chrome
programmatically (createElement, no innerHTML); the factory builds it on
instantiate, so the SAME factory serves the standalone page and a console pane.
`opts.standalone` adds the page-level bits a pane doesn't want (the Console
back-link, the theme toggle, the shared #toast).
- index.html (standalone): goes thin — a bootstrap calling
createCoordinatorPane(document.body, ws_id, {standalone:true}); the ~500-line
inline <style> is migrated to coord-chrome.css (its lone page-level body rule
scoped to .coord-chrome-root) so the console can load the same chrome CSS.
- shell.js: registerType('coordinator') keyed by ws_id — onMount builds the
controller into the pane body, onActivate opens its Tier-2 SSE once, onClose
destroys it. Plus a window.TS_LOGIN fan-out registry so every pane re-arms its
own stream on re-auth (app.js's single onLoginSuccess becomes one subscriber).
- rail.js: coordinator clicks → openPane('coordinator', ws_id) instead of
full-page nav (interactive sessions stay interim full-page until step 5).
- console/index.html: loads the coordinator controller + chrome CSS + the shared
composer/renderer deps it needs.
Child links → openPane('interactive', ws_id) are deferred to step 5 (the
interactive pane doesn't exist yet); coordinator transport stays console-local
inline (parameterized only when the shared ConversationalPane base is lifted).
Verified end-to-end with a headless harness running the real shell.js + rail.js +
coordinator.js: opening a coordinator pane registers the type, the rail row opens
it, buildCoordChrome populates the pane, the Tier-2 SSE connects, destroy() tears
down — zero uncaught errors; renders cleanly (appbar + chat + children/tasks
sidebar + status bar). test_shell_js + test_coordinator_page (97 green), ruff.
Five findings from the step-3 designer pass; the P3 chevron-rotation (taste) was
skipped — the text-swap is already motion-safe.
- P1: the adopted #view-admin had no inset, so the first admin section-header
butted the tab-bar hairline + rail edge. Add `padding:16px 0 0 16px` on
`.pane-body > #view-admin` (.admin-content keeps its right pad).
- P2: the rail Manage active-marker never seeded from getActiveTab(), so a
PaneManager.rehydrate-restored Admin pane showed no active group/row until a
re-click. mountManage now takes the PaneManager, seeds the marker + expands the
owning group when the Admin pane is already open (new PaneManager.hasPane()).
- P2: the active-row band was byte-identical to the amber `.row.open` of live
sessions (distinct only by a 2px stripe). Give it its own neutral idiom —
`--panel-2` fill + a hairline `inset 2px` marker — so "which admin tab" reads
as different in kind from "which session is live".
- P3: `.gcount` pinned right with `margin-left:auto` (was incidental via flex).
- P3: strip the dangling `role="tabpanel"`/`aria-labelledby="tab-*"` from the 18
adopted admin panels (their sidebar buttons were deleted in 3b); the 9 legit
tabpanels elsewhere are untouched.
Verified via the headless harness (rail / admin-open / rehydrate states) +
test_shell_js.py guards (the aria strip is now pinned).
The rail's Manage groups replaced the in-pane admin sidebar in 3a; this removes
the now-dead markup, JS, and CSS that it leaves behind.
- index.html: drop the #admin-sidebar nav (6 groups / 18 buttons) + the mobile
#admin-sidebar-backdrop; #admin-layout now wraps #admin-content alone.
- admin.js: delete the mobile off-canvas drawer (_mobileSidebarOpen,
_injectMobileToggle, _toggleMobileSidebar, the Escape-to-close + arrow-nav +
resize-sync handlers) and switchAdminTab's now-dead .admin-nav active loop +
breadcrumb write.
- style.css: remove the .admin-sidebar* / .admin-nav* / .admin-mobile-toggle*
rules, the mobile off-canvas @media block, and the dead reduced-motion entries.
- shell.css: drop the .pane-body .admin-sidebar hide rule (nothing to hide now).
.admin-layout / .admin-content / #view-admin stay (the Admin pane adopts them).
Verified: no residual sidebar/mobile refs, CSS braces balanced, admin.js parses,
headless render unchanged, and test_shell_js.py pins the removal.
Admin becomes a singleton pane and the rail's Manage section becomes its
navigation; the in-pane sidebar is retired.
- shell.js registers an `admin` pane type that adopts #view-admin (the 18
tabpanels) on first open; the dashboard pane keeps #main.
- admin.js: new ADMIN_IA seam (window.TS_ADMIN) — the group→tab map, a shared
adminTabAllowed() gate (mirrors the legacy showAdmin permission gate, incl.
the ungated node list), an active-tab subscription, and openTab. showAdmin is
now a thin delegator (openPane('admin') + switchAdminTab); the in-#main view
toggle, breadcrumb write, history push, and mobile-hamburger injection go.
- rail.js: mountManage() builds the six collapsible .grp groups from the seam,
permission-filtered, routing a row click through openTab — never touching
admin DOM.
- app.js: home/drill re-focus the Dashboard pane instead of blanking the moved
#view-admin.
- shell.css: the .grp vocabulary + admin-pane layout (in-pane sidebar hidden,
#view-admin fills the pane).
The legacy #admin-sidebar is hidden via CSS pending its deletion in 3b; this is
the additive, independently-runnable half. Verified with a headless-Chrome
harness driving the real shell.js + rail.js over a stubbed seam, plus the
test_shell_js.py guards (19 passing).
The dashboard body becomes a persona-unified launcher (start a coordinator OR an interactive session from one composer) and the saved list spans both kinds; the redundant active-coordinators table is dropped (the rail covers it now).
Backend — the console /v1/api/workstreams/saved now returns both kinds: session_routes.py extracts _collect_saved_rows (shared by the refactored, behaviour-preserving make_saved_handler) + adds make_unified_saved_handler (merges per-kind queries — run concurrently via asyncio.gather — sorted by updated desc). The operator gate (admin.coordinator) is applied once; no new exposure (operators already see every session). console/server.py mounts it with [coordinator, interactive] cfgs.
Frontend — a persona toggle routes submit by kind: coordinator -> console-local POST /v1/api/workstreams/new; interactive -> node-proxy POST /v1/api/cluster/workstreams/new (auto placement). Each option is scope-gated (admin.coordinator / workstreams.create); attachments stay coordinator-only. The saved list gains a KIND tag column + kind-routed activation (coordinator -> /open + /coordinator; interactive -> /node/{id}/?ws_id=) and stays operator-gated. The active-coordinators table + _renderHomeView/_activeCoordsFromClusterState are removed.
Tests: make_unified_saved_handler coverage (tests/test_saved_handler_unified.py, synthetic fixtures, no DB) + a console-launcher static guard (tests/test_shell_js.py). Reviewed via the multi-stage pipeline; findings applied (client/server gate match, concurrent queries, chip-CSS dedup, static guards, stale-comment cleanup).
The rail's Cluster + Workspaces sections (step-1 stub labels) now render live from the Tier-1 clusterState, and the legacy bottom #cluster-status-bar is retired — the rail replaces it (the L-shell has no bottom bar).
New shared_static/rail.js (ESM): renders Cluster (health pills wired to drillDownByState + a node list with version/drift) and Workspaces (the session tree — coordinators with children nested via the shared _bucketByParent, COORD/INT persona tags, state = shape+colour via ui-base .ui-glyph-*).
app.js exposes a minimal Tier-1 seam on window.TS_APP (getClusterState + onRender + the rail's nav actions); renderFromState fires subscribers. No physical clusterState extraction — the seam closures see the live binding. shell.js builds the Cluster/Workspaces render targets and mounts rail.js before boot so it catches the first snapshot.
Retire the bottom bar: delete the #cluster-status-bar markup + renderStatusBar / renderNodePicker / the node-picker helpers + STATE_ORDER (~310 lines), and the .stale toggles in connectSSE (the rail-conn #status-bar carries connection state now). buildNodeInfoFromSnapshot / recomputeOverview / _bucketByParent stay — the rail reuses them. The dashboard body still carries its active-coordinator table transiently; 2b reshapes it into the persona launcher + unified saved list.
Step 1 of the console renovation: a full-height left rail, a top tab bar, and a generic pane host that shows one pane per tab. Existing console content is hosted unchanged inside it as the default Dashboard pane.
New shared_static ES modules (the first ESM citizens; classic scripts keep loading alongside them): pane.js (PaneManager + ShellPane — typed-window host with openPane/activate/close, sessionStorage rehydrate, a WAI-ARIA tablist with roving tabindex + arrow-key nav, reconcile-in-place tabs); shell.js (builds the rail/tab-bar/pane-host, reparents #main + #status-bar with ids preserved so connectSSE needs no rewire, relocates the header controls into the rail footer, drives the app boot); shell.css (chrome ported from the layout mock to base.css tokens).
console index.html loads the shell module + capability flags + stylesheet; app.js's bottom init is wrapped into window.TS_APP.boot, which the deferred shell module drives (it runs after the classic scripts). Cluster health, the Workspaces tree, admin, and conversational pane types arrive in later steps; the rail sections are labelled stubs.