Commit Graph

632 Commits

Author SHA1 Message Date
Patrick Buckley 5b0255f468 feat(ui): service-hatch container system — pane-scoped shelf + modal dialog tier
One chrome vocabulary (machined head/foot strips on --code-bg, kind LED,
designation plate, center-fading hairline), two mounting points:

- .hatch--shelf: pane-scoped, NON-modal (dialog.show()). Mounts inside the
  pane's .hatch-host, docks right, dims only that pane via a lazy sibling
  .pane-scrim, and contains focus with inert on the pane's other children —
  rail/tabs/other panes stay live. Split panes work by construction; the
  bottom-sheet degradation is an @container query on the pane, not a
  viewport media query, so a narrow split degrades too. Controller-owned
  Escape defers to any document-modal dialog stacked above.
- .hatch--dialog: document-modal (showModal()) confirm/show-once tier;
  top layer stacks it above any shelf with no z-index ladder.

Smart-input primitives ship alongside (seg, chips, readout, capgrid,
match-strip, rawhatch, autofill) for the Phase-1 surfaces. data-busy locks
the container while a submit is in flight (LED pulses, dismissal refused).
Light-theme micro-text gets the .tab-menu-key one-step-up contrast pass.
Classic scripts reach the ESM controller via the window.TurnstoneHatch
bridge (toast.js pattern, handler-time only); invariants pinned in
tests/test_hatch_js.py incl. a markup-shape lint over dialog.hatch.
2026-06-10 13:31:53 -07:00
Patrick Buckley f086e38e37 feat(console): schedule preview endpoint — next-3-runs read-out backend
POST /v1/api/admin/schedules/preview validates {schedule_type, cron_expr,
at_time} with the same _validate_schedule_fields the CRUD path uses and
returns the next three croniter firings. Pure compute, no storage touch;
invalid input answers 200 {valid:false, error} because the schedule
editor renders it live while the user types. Registered ahead of the
{task_id} routes so the literal segment wins.
2026-06-10 13:31:53 -07:00
Patrick Buckley 8f415f9c68 docs(judge): say llm_fallback, not 'heuristic fallback', for cancelled items
Review feedback: the cancel-path docstrings described undone items as
degrading to 'heuristic fallback verdicts', but the emitted and
persisted tier is llm_fallback (heuristic content relabeled). Aligned
all eight occurrences — including the pre-existing _deliver_fallbacks
docstring — so docs, logs, and audit rows use one vocabulary.
2026-06-09 20:00:55 -07:00
Patrick Buckley fb282fab73 fix(judge): honor the cancel_on_approval=False run-to-completion contract
judge.cancel_on_approval=False (the default) promises the daemon
evaluates every tool call to completion so all verdicts are available
for later review. Two sites conspired to break that: the approval
gate's finally set the cancel event unconditionally the moment a
decision landed, and _evaluate_single's poll loop honors the event
regardless of config — so every item the sequential judge hadn't
reached degraded to a heuristic llm_fallback row. On a 22-call
parallel batch, approving after the third verdict silently downgraded
the other 19; the elaborate late-verdict machinery in
on_intent_verdict was effectively dead code.

Make the event a pure abort signal whose firing policy lives with the
caller: the gate fires it only when cancel_on_approval is enabled,
while generation supersede (next batch) and close() keep firing it
unconditionally, bounding a stale daemon to one batch of real work.
_run_judge drops its own config second-guessing — a fired event always
fast-forwards the remainder to fallbacks (every call still gets
exactly one verdict), and the fallback reason no longer claims 'user
approval' for supersede/close aborts.
2026-06-09 20:00:55 -07:00
Patrick Buckley d0cede5e13 fix(storage): per-row conflict tolerance on bulk verdict insert
The async judge daemon can UPSERT a fallback row — reusing a heuristic
verdict_id from the batch approve_tools is about to bulk-insert —
before the bulk write runs. With a plain INSERT, that single PK
collision aborted the entire statement, and the caller's best-effort
try/except silently discarded every heuristic row in the batch.

Insert ON CONFLICT (verdict_id) DO NOTHING on both backends: siblings
survive a mid-batch collision, and the colliding row keeps the
daemon's llm_fallback tier upgrade instead of regressing to the
heuristic stamp (the documented preferred outcome). Regression test
runs against both storage backends via --storage-backend.
2026-06-09 20:00:55 -07:00
Patrick Buckley effdb8f365 fix(judge): persist superseded late verdicts for the audit trail
ChatSession._on_verdict guards on judge-generation identity so a stale
verdict can't ride a reused call_id into the Smart-Approvals cache —
but it dropped those verdicts entirely, before persistence. Every
ruling the sequential judge delivered after the next turn began left
intent_verdicts claiming the judge never answered.

Route superseded verdicts to a new persist-only hook
(SessionUIBase.on_superseded_intent_verdict): the row lands with
user_decision="superseded" while every live surface stays untouched
(no SSE, no replay cache, no pending-decision park). The hook is
duck-typed; display-only UIs (CLI/eval) don't define it and keep the
plain drop. upsert_intent_verdict already excludes user_decision from
its on-conflict SET, so a superseded fallback upgrading its heuristic
row in place cannot clobber a decision already stamped there.
2026-06-09 20:00:55 -07:00
Patrick Buckley 461d01f72a fix(ui): ship risk-none intent verdicts on history replay
The /history decoration layer suppressed intent-verdict rows with
risk_level="none" from the wire payload, on the assumption the client
filtered them anyway. It never did: buildConvVerdict renders a badge
for every verdict it receives, so the live SSE path painted all judge
verdicts while rehydration silently dropped the benign majority — a
22-call parallel batch came back from a restart showing only the 3
flagged calls.

Ship every stored row and let the client render replay exactly as it
rendered the live stream. The output-guard chip pair (showOutputWarning
+ merge-on-clean) already suppresses consistently on BOTH sides and is
unchanged.
2026-06-09 20:00:55 -07:00
Patrick Buckley 9fefe830d1 fix(tls): validate init() retry params
attempts < 1 made init() return successfully without fetching CA or
cert — a silent no-op leaving the client uninitialized. Fail fast with
ValueError instead; negative base_delay rejected on the same guard.
2026-06-09 18:37:46 -07:00
Patrick Buckley a3ff07a86d fix(tls): mTLS-aware container healthcheck + boot-time init retry
A whole-stack restart races every node against the console for the CA
fetch (compose re-enforces depends_on ordering only on `up`): losers
logged one warning and served plain HTTP for their lifetime, while
winners served mTLS that the plain-HTTP container healthcheck could
never probe — leaving "healthy" plaintext nodes and "unhealthy"
working ones.

- TLSClient.init() grows attempts/base_delay retry (server passes 6
  attempts, ~31 s backoff) absorbing the boot race; per-attempt CA-fetch
  failures log warning + debug traceback instead of error tracebacks.
- healthcheck.py falls back to HTTPS when the plain probe fails,
  presenting the node's own cert as the client cert with the cluster CA
  pinned; dials localhost because the internal CA issues DNS SANs only.
  Default plain-HTTP deployments are unchanged.
- The server writes boot PEMs under a fixed root (TURNSTONE_TLS_PEM_DIR,
  default <tmpdir>/turnstone-tls) so the probe can find them; boot
  clears stale dirs and refuses a symlinked/foreign-owned root; renewal
  rewrites the PEM dir so the probe's client cert never outlives the
  served cert.
- /health reports tls: "active"|"fallback" (absent when TLS is
  disabled) so a silently downgraded node is observable.
2026-06-09 18:37:46 -07:00
Patrick Buckley b1c526a170 style: ruff-format the appended drawer test guard 2026-06-09 13:37:35 -07:00
Patrick Buckley 221e804ef3 test(ui): de-modulize the renderer harness sources
test_renderer_js drives renderer.js behaviorally through node via
vm.runInThisContext — script semantics, which choke on the import/
export syntax renderer.js and utils.js now carry (all 68 tests failed
at harness setup). The harness now evaluates _demodulize()d source:
imports drop (the shared vm context resolves cross-file bindings as
globals, exactly like the pre-module classic scripts) and export
keywords peel off.

Deliberately NOT switched to dynamic import(): the mermaid harness
pokes renderer-internal state (_mermaidState = 'ready') that script
evaluation exposes but a real module would encapsulate. Module
semantics are covered by test_shell_js's .mjs parse sweep; these
tests pin renderer behavior.
2026-06-09 13:37:35 -07:00
Patrick Buckley 539ed3ccbf test(ui): pin the collapse/drawer/popup-menu behavior + breakpoint pair
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.
2026-06-09 13:37:35 -07:00
Patrick Buckley 3bfb40f60f refactor(ui): retire dead split-pane remnants + dedupe popup-menu chrome
Dead-code removal, all provable (no JS creator / no reachable caller):

- interactive.js drops the !this._embedded branches: focus tracking, the
  right-click context menu, and the header with split/close buttons all
  referenced shell globals (setFocusedPane, splitPane, splitRoot,
  showPaneContextMenu, countLeaves, closePane) that exist nowhere since
  the step-6 fork collapse — reaching them was a guaranteed
  ReferenceError. The embedded flag goes with them (every pane is
  L-shell-hosted; pane--embedded is now unconditional), as do the
  call-less updateWsName() and the host adapter's getWsName seam.
- ui/static/style.css drops the orphaned split-pane/tab-bar vocabulary:
  .ws-tab*, #new-tab-btn, #split-btn, .split-handle, the pane-header/
  action-button block, the unused dropdown-in keyframe, and the dead
  entries in the reduced-motion list.
- ui/static/app.js drops the retired settings-gear menu remnants (state
  vars for a builder that no longer exists + an always-false Escape
  guard) and fixes a real crash: hideNewWsModal() focused the removed
  #new-tab-btn unguarded, throwing a TypeError on every create/fork
  modal close; focus now returns to the shell's [+] new-session button.
- pane.js exports openPopupMenu — items, positioning (flip + clamp),
  dismissal, aria-expanded mirroring, and arrow-key roving in one place.
  The tab-action dropdown delegates to it, and shell.js's footer user
  menu replaces its hand-rolled duplicate (which lacked Tab-close and
  arrow roving — it inherits both).

test_interactive_pane_js.py: the embedded-gate pins flip to retired-
symbol pins (the gate is gone, not gated).
2026-06-09 13:37:35 -07:00
Patrick Buckley 2320c6d13c refactor(ui): migrate the shared_static substrate to ES modules
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.
2026-06-09 13:37:35 -07:00
Patrick Buckley 3fc65577f5 fix(ui): tab-menu verbs follow the live node when the controller is dead
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").
2026-06-09 11:57:18 -07:00
Patrick Buckley 4b1536be2c fix(ui): ws lifecycle round 2 — dead-session revive + proxied tab-menu verbs
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).
2026-06-09 11:57:18 -07:00
Patrick Buckley 114ada791b feat(providers): add Claude Fable 5 to the Anthropic provider
- claude-fable-5 capability entry: 1M context / 128K output, adaptive
  thinking (summarized display), effort low..max incl. xhigh, no
  sampling params, web + tool search, vision, reasoning replay, native
  mid-conversation system messages
- document the Fable 5 wire quirk at the capability table: an explicit
  thinking={"type": "disabled"} is a 400 on this model; the adaptive
  branch never emits "disabled", so adaptive-or-omitted is preserved
- widen the native mid-conversation-system comments from opus-4-8-only
  to opus-4-8 + fable-5 (protocol, provider, tool_advisory, prompts,
  session)
- raise the anthropic SDK floor 0.39 -> 0.108: 0.39 predates every
  named kwarg the provider sends (output_config 0.77, top-level
  cache_control 0.83, mid-conversation system blocks 0.105); 0.108
  adds claude-fable-5
- tests: capability assertions for claude-fable-5 + dated-variant
  prefix match
2026-06-09 10:50:58 -07:00
Patrick Buckley c4dec213de fix(ui): address /review of the workstream-lifecycle change
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.
2026-06-08 10:08:30 -07:00
Patrick Buckley 71d3ed6abe fix(ui): repair interactive/proxied workstream lifecycle (reload, create, launcher)
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.
2026-06-08 10:08:30 -07:00
Patrick Buckley b14cc93d37 chore(ui): apply /review findings — guard menu listener, fold tab repaint, drop dead status-bar code
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.)
2026-06-08 10:08:30 -07:00
Patrick Buckley 9653b57eaf fix(ui): interactive tool cards no longer collapse to an empty stripe
The real regression behind "parallel tool calls don't show / tool cards get
overwritten, leaving a thin stripe with a coloured pixel on the left": the
.conv-* convergence put an `overflow:hidden` card (.conv-batch) into the
interactive pane's SCROLLING flex-column message list
(.pane--embedded .pane-messages, overflow-y:auto).  An overflow:hidden flex
item's `min-height:auto` resolves to 0, so flexbox squished the tool batch to
~2px (just its left border) once the column filled — while plain .msg blocks
(overflow visible) kept their height.  That asymmetry is why the COORDINATOR
(different container) and main's old `.ts-approval` block (a .msg, overflow
visible) were never hit, and why it impacted ALL models — it was never a
local-model id-collision (that earlier theory + fix were reverted).

Fix: pin every message child to flex-shrink:0 so the column scrolls instead of
collapsing cards.  Reproduced + verified in the real console shell (headless):
the parallel-bash tool batch went from 2px (collapsed) to 244px (full content)
once a multi-turn conversation fills the column.  Guarded in test_conversation_css.
2026-06-08 10:08:30 -07:00
Patrick Buckley 1cadc541d6 fix(ui): footer shows the username, not the internal user_id uuid
whoami returned only user_id (an opaque uuid), so the rail footer rendered the
uuid.  whoami now resolves the user record by id and returns the human
username/display_name (best-effort — a storage miss just omits it).  The client
stores data.username (no fallback to user_id: a uuid is worse than the generic
"account" placeholder).  Hardened against a malformed user record (isinstance
dict guard) so a bad row can't 500 whoami; test stubs get_user + asserts the
display name is surfaced.
2026-06-08 10:08:30 -07:00
Patrick Buckley 30cb9e6097 fix(ui): L-shell step 7 — wire coordinator approval keyboard shortcuts (designer P2)
The console twin of the interactive.js approval-key fix: the coordinator's
tool-batch card shows kbd hints (Enter approve / D deny / Shift+A approve-all) but
they did nothing — the keys were never wired (the standalone routed approval keys
through the app.js global keydown + getFocusedPane, retired in the fork collapse).

Add a pane-owned keydown on `root` that resolves the current pending batch:
- _currentPendingBatch() finds the last .conv-batch with a still-pending
  [data-needs-approval="1"] row whose actions aren't already disabled — the
  in-flight double-fire guard (a second key during the resolve is a no-op).
- Enter -> approve, D/Esc -> deny, Shift+A -> approve-all, routed to the existing
  _resolveBatchAction path.
- A focus guard skips when an input/textarea/contenteditable is focused, so the
  keys never hijack composer typing (the coordinator has no feedback field, unlike
  interactive, so no feedback special-case).

Verified end-to-end against the real coord pane (keydown -> _currentPendingBatch ->
_resolveBatchAction -> approveWorkstream -> postJSON -> authFetch, stubbed at the
HTTP boundary): Enter/D/Shift+A fire the right verb, the double-fire + focus guards
hold, errs:[] + a coordinator JS guard.  Live keypress confirm rides the merge gate.
2026-06-08 10:08:30 -07:00
Patrick Buckley 0f137f570f feat(ui): L-shell step 7 — auth-gate openPane (coordinator scope)
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.
2026-06-08 10:08:30 -07:00
Patrick Buckley 9f10e8e81d feat(ui): L-shell step 7 — [+] new-session button in the tab-bar tail
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.
2026-06-08 10:08:30 -07:00
Patrick Buckley e60f5a3108 feat(ui): L-shell step 7 — live tab state-glyphs (Tier-1, shape+colour)
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.
2026-06-08 10:08:30 -07:00
Patrick Buckley 7448792251 feat(ui): L-shell step 7 — tab-action dropdown (three-verb close + per-persona verbs)
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).
2026-06-08 10:08:30 -07:00
Patrick Buckley 9cb2b5d831 fix(ui): L-shell step 6 — wire interactive approval keyboard shortcuts (designer P2)
The converged .conv-* card advertises y/n/a (+Enter/Esc) kbd hints, but the keys
did nothing in the L-shell: the only handler was the old standalone app.js global
keydown gated on getFocusedPane(), which the fork collapse stubbed to null — so
Approve/Deny/Approve-all were mouse-only (the chips over-promised), and that dead
block also queried the retired .ts-approval-feedback class.

Wire the keys pane-owned on this.el (every embedded L-shell pane), restoring the
pre-regression behavior + using the converged .conv-feedback: when a pending
approval is up, in the feedback field Enter approves (with feedback) / Esc denies
and other keys type; elsewhere y|Enter approve, n|Esc deny, a = approve-all. The
composer is disabled while pending, so the feedback field is the only typing
surface. This fixes both the standalone and the console interactive pane (shared
interactive.js); the coordinator pane's keys (console-only) are a separate
merge-gate item. Verified: node-check, the headless shell harness still builds
clean (errs:[]), 102 JS guards green incl. a new wiring guard. The live keypress
-> resolve confirm rides the owed live-backend pass.
2026-06-08 10:08:30 -07:00
Patrick Buckley 80d201a67b chore(ui): L-shell step 6 (5e.2f) — retire dead split-pane CSS from ui/static
Removes the structurally-dead CSS the L-shell superseded — 794 lines: the tab
bar (.ws-tab*, #tab-bar, #split-btn, .ws-tab-dropdown-*), the binary split-pane
machinery (.split-*, #split-root, .pane-ctx-*), the old approval/verdict card
(.ts-approval-*, .verdict-*, the judge spinner), the fixed .dashboard-overlay,
and the retired appbar/settings-overlay bits — plus their [data-theme=light]
overrides.  The standalone now styles its conversation from the shared sheets
(chat/conversation/interactive.css); the dashboard table + saved list were
always shared (base/cards.css).

Method: a conservative token-diff — a rule is dropped only when EVERY selector's
class/id token is absent (word-boundary, comments stripped) from the standalone
runtime (index.html + every JS it loads, incl. the vendored hljs/katex/mermaid
so their runtime-built classes aren't mistaken for dead).  Mixed/any-live rules
are kept verbatim (no reformatting), so ~50 dead-but-harmless rules that share a
generic token like `.active` survive — safe over-keep.  The markdown / syntax /
math / diagram theme lives ONLY in this style.css (the shared sheets don't carry
it), so the hljs/katex/mermaid families are protected from removal.

Also fixes four dead tab-DOM pokes in app.js (editWorkstreamTitle /
confirmDeleteWorkstream read the title from the workstreams roster now, not the
retired .ws-tab .tab-name; the cancel handlers drop the gone .tab-chevron focus
restore).

Verified: braces balanced (370/370), the headless harness still builds clean
(errs:[]), git diff confirms zero live dashboard/render rules removed, and the
css_specificity_audit (manifest synced to the standalone's new sheet set) shows
the SAME 10 pre-existing findings before/after — zero new cascade flips (removing
a rule for a non-existent selector can't change any live element's cascade).
121 JS guards green, ruff/mypy clean.
2026-06-08 10:08:30 -07:00
Patrick Buckley cc508cf481 feat(ui): L-shell step 6 — standalone adopts the L-shell, retire ui/static split-pane
The renovation META-GOAL: a standalone turnstone-server now serves the SAME
capability-parameterised L-shell the console serves (caps {cluster:false,
orchestration:false}), collapsing the console/static vs ui/static fork. No server
change was needed — turnstone/server.py already mounts ui/static at /static; this
changes what ui/static CONTAINS.

ui/static/index.html -> the L-shell skeleton: a hidden #header the shell
relocates (status -> rail, theme/logout -> footer), #main as the Dashboard pane
body (launcher + workstreams table + saved list), a one-panel #view-admin hosting
MCP connections (reusing the #settings-mcp-* table ids), the modals, and the caps
block flipped to {cluster:false, orchestration:false, brandSub:server}. The
split-pane chrome (#tab-bar/#split-root/#split-btn), admin.js/governance.js, and
the separate interactive.js module tag are gone (shell.js imports it).

ui/static/app.js -> a single-node TS_APP/TS_ADMIN/showHome provider (-1417 lines):
- TS_APP.{getClusterState, onRender, bucketByParent, boot}: getClusterState
  synthesizes a one-node cluster from the flat /v1/api/events/global roster;
  boot() is shell-driven (no parse-time auto-run).
- TS_ADMIN: a one-tab Manage IA (Extensions > Connections) whose openTab opens
  the Admin pane + renders the MCP table — the floating settings gear is retired.
- The binary split-pane machinery (layout tree, splitPane/renderLayout, tab bar,
  context menu, tab dropdown, STANDALONE_HOST, createPane, the gear menu) is
  deleted; the keep surfaces (dashboard, global SSE, new-ws modal, MCP
  consent/connections, health/theme/kb) are rewired onto PaneManager + the rail
  (switchTab/renderTabBar/showDashboard become thin shims; sessions open as
  interactive panes).

interactive.js -> the window.InteractivePane bridge is retired (the shell imports
the factory in both deployments; nothing reads the global anymore).

JS guards re-pointed to the L-shell reality (gear/split-pane/window-bridge guards).
126 JS guards green, ruff/mypy clean, node clean. Verified in a headless harness:
the standalone shell builds caps-off (rail = Workspaces + Manage > Connections, no
Cluster), the Dashboard pane adopts #main, TS_APP/TS_ADMIN wired, zero uncaught JS
errors. The owed merge-gate passes (designer both personas + live-backend +
/review) are unchanged.
2026-06-08 10:08:30 -07:00
Patrick Buckley f3a0954b76 refactor(ui): L-shell step 6.0 — make shell.js deployment-agnostic
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.
2026-06-08 10:08:30 -07:00
Patrick Buckley eaf9f96ad1 feat(ui): L-shell step 5e.2e — remove the interactive pane header (embedded)
Same as the coordinator: in the L-shell the embedded interactive pane's header
content (workstream name + INTERACTIVE persona tag) is redundant — the tab shows
the name and the rail (Workspaces) shows name + state + the INT/COORD persona.
So the embedded pane builds no header and the conversation reclaims the height.

The header build is gated behind !this._embedded (the standalone split-pane,
retired in step 6, keeps its split/close header). The --skip-permissions
SECURITY banner moves off the header to messagesEl (the console host's
warningTarget now matches the default host) so it's preserved, not dropped.
updateWsName null-guards the absent header. Dead .pane--embedded .pane-header /
.pane-ws-name / .pane-persona-tag CSS removed.

Both panes are now header-less in the L-shell — name/state/persona live in the
tab + rail. 103 JS guards green (test_embedded_chrome_is_gated updated for the
no-header reality), node-check clean.
2026-06-08 10:08:30 -07:00
Patrick Buckley 94f1c83a5c test(ui): L-shell step 5e.2c — repoint interactive guards to the .conv-* vocab
The cutover delegated the pane's verdict/warning/status DOM to the shared
conversation.js builders, so three test_app_js guards pinning the old
implementation needed updating to the new reality:
- replayHistory now calls buildConvVerdict(tc.verdict) (was renderVerdictBadge).
- risk normalization moved into the shared builders — the pane carries no raw
  `risk_level || "medium"` fallback and builds via buildConvVerdict /
  buildConvWarning (which route through normalizeRiskLevel in conversation.js).
- the error pill converged onto .conv-status--error (was .ts-approval-badge--error).

Also dropped the now-dead normalizeRiskLevel import from interactive.js (the
builders own normalization; the pane no longer calls it directly). 123 frontend
JS/CSS guards green.
2026-06-08 10:08:30 -07:00
Patrick Buckley ff7d378235 feat(ui): L-shell step 5e.2c — interactive emitter onto shared .conv-* builders
Re-vocabularize interactive.js's approval card onto the shared conversation.js
builders, converging it with the coordinator onto ONE neutral .conv-* card:
buildToolDiv -> buildConvRow + buildConvCmd, renderVerdictBadge ->
buildConvVerdict, _buildOutputWarningEl -> buildConvWarning; showInlineToolBlock
/ announceToolBlock build the .conv-batch shell + head + rows + buildConvActions
(with the inline feedback + recommended glow); resolveApproval / the auto path
-> buildConvStatus; updateVerdictBadge replaces the badge via buildConvVerdict;
the history-replay branch synthesizes the live `item` shape so replay renders
the SAME .conv-row. ~124 ts-approval-* / verdict-* references gone (a final
no-stale-vocab assert guarded it); dead toggleVerdictDetail removed.

The card chrome converges; interactive's richer post-execution result subsystem
(.tool-output collapse/stream + .media-* embeds) is KEPT as-is — those are
live-execution affordances the read-only coordinator history doesn't need.
Block state classes move to the BEM modifiers (.conv-batch--approved/--denied/
--error/--auto); per-pane keybindings (y/n/a) preserved via the builder kbd
hint. The now-dead old card CSS (.coord-tool-*/.ts-approval-*/.verdict-*) is
inert (matches nothing) and is removed in 5e.2f's CSS dedup.

Verified: node --check both emitters; the no-stale asserts; 60 JS guards green
(test_coordinator_page pinned vocab updated coord-tool-batch-> conv-batch). The
builders are behavior-tested (5e.2b). Designer + /review + live-backend run once
at the merge gate.
2026-06-08 10:08:30 -07:00
Patrick Buckley f79d8a2701 feat(ui): L-shell step 5e.2b — shared .conv-* card builders
Add the pure leaf DOM builders for the unified `.conv-*` card to
conversation.js (both panes import it): buildConvBatchShell / buildConvRow /
buildConvCmd / buildConvVerdict / buildConvWarning / buildConvButton /
buildConvActions / buildConvStatus / buildConvResult. The builders own only
the DOM + class vocabulary; everything stateful (the toolRows map, idempotent
upgrade-in-place, the early-paint announce shell, SSE routing) stays in each
pane and CALLS these in 5e.2c.

Parameterized by AFFORDANCE, not subclass: buildConvRow takes an indexLabel
(coordinator's parallel idx pill) or defers to buildConvCmd (interactive's
bash `$ cmd` + diff preview); buildConvActions takes per-pane keybinding hints
+ an optional feedback input (interactive) and per-pane resolve callbacks. The
persistent action unifies on "Approve all" (dashed --ok ghost), not the
coordinator's old "Always". Risk routes through normalizeRiskLevel so the
per-site `|| "medium"` fallbacks fold onto the canonical unknown->medium; the
judging spinner withholds the --{risk} class so its stripe stays neutral.

Additive — no emitter calls these yet (the re-vocabularize + delete is 5e.2c).
Verified: 48-assert headless-Chrome behavior harness (DOM shape, crit->critical
normalize, unknown->medium fold, expand toggle, action callbacks, JSON
pretty-print) + tests/test_conversation_js.py extended (11) + node --check.
2026-06-08 10:08:30 -07:00
Patrick Buckley bcc9e3dfbe feat(ui): L-shell step 5e.2a — neutral .conv-* approval-card sheet
Author shared_static/conversation.css: ONE neutral `.conv-*` approval-card
vocabulary that both panes will emit, converging the two forked cards
(coordinator's `.coord-tool-*` + interactive's `.ts-approval-*`/`.verdict-*`).
Based on the BRIEFING-blessed `.coord-tool-batch` idiom — neutral surface,
state left-stripe (warn pending / ok approved / err denied), uppercase kicker,
Approve = subtle --ok fill / Approve all = dashed --ok ghost / Deny = --err
(the DS hard-rule: approve uses --ok, never --warn) — with the interactive
affordances folded in (bash `$ cmd`, unified-diff preview, inline feedback,
recommended-button glow, auto-approved tag, the expandable verdict detail).

Converges onto the DS token vocabulary (--ok/--warn/--err, --ink-*, --panel*,
--hair*), not chat.css's legacy --green/--red/--cyan. Self-contained spinner
keyframe (conv-spin) so the sheet doesn't depend on coord-chrome.css's ts-spin,
which the standalone interactive pane never loads.

Additive only — no emitter uses `.conv-*` yet (the re-vocabularize + delete of
the old sheets is 5e.2c). Linked from the console + both standalone pages so
the card is styled the moment 5e.2c switches the emitters over.

Designer-reviewed (rendered both themes): applied the warning-chip wrap fix,
the medium-severity-weight fix (12% mix, not raw --warn-tint), ink-4 -> ink-3
on verdict-detail/tier content for light-mode AA, the bold severity label, and
the neutral judging-row stripe. Guard: tests/test_conversation_css.py (5).
2026-06-08 10:08:30 -07:00
Patrick Buckley 36c60e31e7 fix(ui): L-shell step 5e.1b — unify risk-level handling onto conversation.js
Both panes carried their own risk-level logic that disagreed on the fallback:
interactive's normalizeRiskLevel sent an unknown level to "medium" (and, lacking
the crit/med aliases, rendered a "crit" verdict as medium), while the
coordinator's _riskRank sent unknown to "high".  Lift one canonical normalize +
rank into conversation.js and route both panes through it.

- conversation.js: normalizeRiskLevel (aliases crit->critical / med->medium;
  unknown -> "medium"), riskRank, maxSeverityItem (keeps the no-verdict -> -1
  edge so an unassessed item never wins the max-severity pick).
- interactive.js: import normalizeRiskLevel, drop the local copy (its 3 callers
  unchanged); a "crit" verdict now renders critical instead of medium.
- coordinator.js: import maxSeverityItem, drop RISK_SEVERITY / _riskRank /
  _maxSeverityItem; an unknown-level item now ranks medium, not high.
- unknown -> medium is the deliberate fallback (per decision), not "high":
  medium is the neutral default both panes' displays already used.
- tests: conversation guards for the fallback + aliases + the no-verdict edge;
  the two pane guards now check the shared module.

The per-site risk->CSS-class display mappings (coordinator's inline chips and
renderApprovalBlock's deliberate unknown->crit over-alert pill) are left for the
5e.2 vocabulary reconcile.
2026-06-08 10:08:30 -07:00
Patrick Buckley 9aad278983 refactor(ui): L-shell step 5e.1a — shared conversation.js for the dup'd helpers
Stand up shared_static/conversation.js as the deduplicated conversational-pane
substrate both panes import (interactive via ./, the coordinator via /shared/ —
both ES modules since 5e.0).  First tenants are the byte-identical duplicates the
in-file comments flagged for the step-5e lift: stripAnsi, the watch-result card
builder, and the system-nudge marker.  No visible change — the builders return
the same DOM; each caller still appends + scrolls.

- conversation.js: stripAnsi (null-safe variant), buildWatchResultCard,
  buildSystemNudgeMarker.
- interactive.js / coordinator.js: import the three, drop their local copies,
  delegate appendWatchResult + the nudge marker through the shared builders.
- stripAnsi unified on the coordinator's null-safe form (interactive's threw on a
  non-string arg); identical output for string inputs.
- tests: new test_conversation_js.py pins the module; the two retry-walk guards
  now check the watch-result marker in conversation.js (it moved there).
- refreshes interactive.js's header comment, stale since 5e.0 made the
  coordinator an ES module too.
2026-06-08 10:08:30 -07:00
Patrick Buckley 6614b4a549 refactor(ui): L-shell step 5e.0 — migrate coordinator.js to an ES module
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).
2026-06-08 10:08:30 -07:00
Patrick Buckley 1d399703b1 fix(ui): L-shell step 5d — designer-review fixes (rail active-marker, tab glyph)
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.
2026-06-08 10:08:30 -07:00
Patrick Buckley da8ee0b018 feat(ui): L-shell step 5c — coordinator child-links open interactive panes
Rewire the coordinator's child ws links (deferred from step 4) to open the child
as a node-proxied interactive pane inside the console L-shell, instead of a
full-page new tab to /node/{id}/?ws_id=.

- A delegated click handler on the pane root catches .ws-link (children tree,
  renderChildRow) and .coord-ws-link (linkified tool output, renderToolOutput)
  clicks; both link types now carry data-ws-id + data-node-id. When a
  PaneManager is present it opens openPane('interactive', child_ws_id,
  {nodeId: child_node_id}) — the child's OWN node, so its stream proxies to that
  node even though the coordinator lives in the console.
- Progressive enhancement: the link's href (/node/{node}/?ws_id=) stays the
  standalone fallback — the standalone coordinator page has no PaneManager, so
  the new-tab nav stands. No innerHTML introduced (the tool-output linkifier
  still returns a string; only data-* attrs were added).

Verified: a harness running the console stack (coordinator.js + shell.js +
interactive.js) — open a coordinator pane, click a child link -> a node-proxied
interactive pane opens (/node/{child_node}/.../events), zero errors; 106
JS-guard tests (test_coordinator_page step-5c guard), node --check, prettier.
2026-06-08 10:08:30 -07:00
Patrick Buckley b9ba542ce6 feat(ui): L-shell step 5b — register the interactive pane in the console shell
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.
2026-06-08 10:08:30 -07:00
Patrick Buckley 9674e566d9 feat(ui): L-shell step 5a — extract interactive Pane into a shared ESM module
Lift the per-workstream conversational Pane (chat + approval cards + composer +
voice + tool/media/MCP-error/verdict rendering) out of ui/static/app.js into a
new shared ES module shared_static/interactive.js, so BOTH deployments can
mount it: the standalone turnstone-server UI (its split-pane shell stays in
app.js and builds panes via window.InteractivePane) and — next, in step 5b —
the console L-shell over a node-proxied Tier-2 stream.

- Transport seam: a per-pane `base` prefix ("" local, "/node/{id}" proxied)
  threads through every request; createInteractivePane derives it from nodeId
  (the LOCALITY invariant — an interactive session lives on a cluster node).
- Host seam: the couplings only the surrounding shell knows (workstream name,
  focus, stream-error recovery, the --skip-permissions banner target, the MCP
  consent badge) route through an injected host adapter; the standalone shell
  supplies the real one (refetchWorkstreamsAndReassign + STANDALONE_HOST), the
  console factory a Tier-1 / no-op one.
- Embedded chrome: the standalone split-pane affordances (focus tracking,
  context menu, split/close buttons) are gated behind !embedded; the embedded
  path adds the INTERACTIVE persona tag.
- First legacy pane lifted into a real module: it exports the factory for the
  console shell's import and bridges window.* for the still-classic standalone
  shell (which builds panes only after the workstream fetch, so the deferred
  module has run). coordinator.js + the shared substrate stay classic.

The whole tool-output / media / MCP-error / verdict cluster moved with the Pane
(used only by it); the consent-BADGE subsystem stays in the standalone shell,
reached via host.onConsentDetected.

Verified: 104 JS-guard tests + a headless harness running the real module
(standalone + embedded chrome, node-proxy transport, lifecycle, zero errors),
node --check, prettier, ruff.
2026-06-08 10:08:30 -07:00
Patrick Buckley 6e0901ee1b fix(ui): L-shell step 4 — designer-review fixes (pane-aware close, overflow, glyph, a11y)
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.
2026-06-08 10:08:30 -07:00
Patrick Buckley 05dbcf3818 feat(ui): L-shell step 4b — coordinator sessions as console panes
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.
2026-06-08 10:08:30 -07:00
Patrick Buckley 0231253c68 refactor(ui): L-shell step 4a — de-globalize coordinator.js into a pane factory
coordinator.js was a page-global IIFE keyed off <html data-ws-id>. Make it
multi-instantiable so the console shell can host coordinator sessions as panes
(one per ws_id) alongside dashboard/admin — the first conversational pane-content.

- IIFE -> `createCoordinatorPane(root, wsId)`: the ~40 module-state vars stay
  closure-local (now automatically per-instance), every #coord-* lookup is
  root-scoped (27 getElementById -> root.querySelector), ws_id is a constructor arg.
- New lifecycle: `connect` (= init), `destroy` (closes the EventSource + clears the
  6 timers + the prune interval + the IntersectionObserver — the IIFE had no
  teardown, so a backgrounded pane would leak an SSE and fire into detached DOM),
  `onLogin` (re-arm after a 401), `closeSession`.
- Drop the page-global collision points: `window.coordSend`/`coordCloseSession`
  -> local fns (the close button binds per-instance; its inline onclick is removed);
  `window.onLoginSuccess` -> the returned `onLogin` (the console shell will fan
  login out to every pane; standalone keeps the single hook).
- Standalone coordinator page = one pane filling the body: a thin bootstrap calls
  `createCoordinatorPane(document.body, ws_id).connect()`.

Console-local transport (the coordinator endpoints) stays inline — coordinators
always live in the console; transport is parameterized only when the shared
ConversationalPane base is lifted (after step 5). The chrome builder, CSS
migration, and console pane registration are 4b.

Verified: node --check; a headless smoke (the real factory instantiates against a
provided root, runs connect()'s snapshot/history/children/tasks/SSE on stubs, then
destroy()s — zero uncaught errors); test_coordinator_page.py (13, incl. a new
factory-shape guard); ruff.
2026-06-08 10:08:30 -07:00
Patrick Buckley 1f254d764a fix(ui): L-shell step 3 — designer-review fixes (admin-pane inset, rail seed, a11y)
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).
2026-06-08 10:08:30 -07:00
Patrick Buckley 1001293217 refactor(ui): L-shell step 3b — delete the retired admin sidebar + mobile drawer
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.
2026-06-08 10:08:30 -07:00
Patrick Buckley 9c4ec4855d feat(ui): L-shell step 3a — Admin pane + rail Manage groups
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).
2026-06-08 10:08:30 -07:00
Patrick Buckley b453ac5bb3 feat(ui): L-shell step 2b — persona-unified dashboard launcher + both-kinds saved list
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).
2026-06-08 10:08:30 -07:00