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.