Skip to content

Phase B: harness/web improvements (WS transport + composer + palette + status + session UX) - #101

Merged
ytallo merged 17 commits into
feat/harness-phase-afrom
feat/harness-phase-b
May 7, 2026
Merged

Phase B: harness/web improvements (WS transport + composer + palette + status + session UX)#101
ytallo merged 17 commits into
feat/harness-phase-afrom
feat/harness-phase-b

Conversation

@ytallo

@ytallo ytallo commented May 7, 2026

Copy link
Copy Markdown
Contributor

Stacked on #100 (Phase A foundation). Merge order: Phase A first, then this.

Summary

Replaces the harness/web HTTP+SSE stack with WebSocket transport via iii-browser-sdk, then layers composer power (slash menu, @-mention, history), a Cmd-J function palette, a live status panel (workers / cost / approvals), session UX (fork from message, export md/json, /repair), and per-session workspace cwd on top.

Spec: docs/superpowers/specs/2026-05-06-harness-web-improvements-design.md

What's in the box

Step A — Backend prep

  • bridge::info returns relative WS path so reverse-proxy / HTTPS deployments compose the URL from window.location.
  • harness-ui-fanout (new module in iii-harness) — per-browser subscription registry.
  • ui::subscribe / ui::unsubscribe bus functions.
  • iii-worker-manager is engine-built-in (verified) — not added to EXPECTED_WORKERS.

Step B — WS transport cutover

  • iii-client.ts façade over iii-browser-sdk@0.11.7-next.1 with useConnection() hook (drives reconnect chrome).
  • useAgentStream migrated from EventSource to client.on(\"ui::session::event\") push.
  • 4-second sessions poll replaced by ui::sessions::changed push.
  • Browser registers as a real iii worker — handlers under <functionId>::<browserId> so the fanout can target a specific browser.
  • Backend fanout subscribers wire agent::events stream → ui::session::event and a 1-second state::list diff → ui::sessions::changed.

Step C — Workspace

  • One header field for cwd. System-prompt prefix. /cwd <path> slash command.
  • Advisory only — real path enforcement deferred to policy-denylist (separate change).

Step D — Composer power

  • useCommandMenu headless reducer (slash / at / history modes).
  • Composer rewritten with mode-switched popover.
  • Built-in commands: /new, /clear, /cwd, /model, /provider, /help. Skills appear as /<skill-id>.
  • @-mention browses files from cwd via shell::filesystem::ls. Plain monospace path insert (no styled chips).
  • history walk in empty composer. Esc///@/Enter/Tab precedence honored per spec's Key Precedence table.

Step E — Live status panel

  • Single Approvals chip in header.
  • Cost USD + workers N/M up in app-foot.
  • Status tab: workers table + 200-line events feed + budget breakdown.
  • Backend pushes for ui::approval::requested|resolved, ui::cost::tick, ui::workers::changed with backpressure (atomic in-flight counter, drop+resync on overflow, ≤10/s coalesce on cost).

Step F — Bus function palette

  • Cmd-J global palette over engine::functions::list.
  • JSON-first editor (no schema-driven form generator in v1).
  • Recent invocations in localStorage (capped 20, deduped by function id).
  • Sensitive-call confirm gate for policy::*, auth::set_*, shell::filesystem::write|mkdir|rm.
  • Copy-as-curl emits bridge::trigger POST snippet.

Step G — Session UX

  • Session list reads from session-tree::list (Phase A) with state::list fallback for drift.
  • Messages read through loadMessagesWithEntryIds (Phase A).
  • Fork-from-message via existing session-tree::fork (disabled with tooltip when entry_id is null).
  • Export md/json — pure client-side download via Blob.
  • /repair slash command calls session-tree::reconcile.

Step H — Cleanup

  • Skipped intentionally. After step B, bridge.ts is a 30-line Promise façade over iii-client — kept as the public API surface; deletion would be cosmetic churn across ~15 files.

Test plan

  • harness/web vitest: 121 passing across 12 files (reducer, loadMessages, workspace, useCommandMenu, menuItems, useGlobalShortcut, palette, sessions, export, iii-client, useConnection, useStatus)
  • tsc --noEmit clean
  • npm run build clean (~219 kB JS, ~31 kB CSS)
  • cargo test --lib --tests clean across harness, session-tree, turn-orchestrator
  • cargo clippy --lib --tests -- -D warnings clean
  • Phase A E2E test still passes (regression guard)
  • End-to-end smoke against demo.sh stack: WS connection only, no SSE, no 4s polling

Pragmatic deviations from spec

Worth flagging for review — none are blockers, all have rationale:

  1. llm-budget::summary doesn't exist — cost pump synthesizes from budget::list client-side; by_provider populated on tab open via budget::usage.
  2. approval::list_pending is per-session — pump iterates known sessions instead of one global call.
  3. state::changed topic doesn't exist — sessions diff uses 1s state::list poll.
  4. Backpressure uses atomic in-flight counter, not mpsc queues. On overflow drops the new event + emits ui::session::resync::<browser_id>.
  5. bridge.ts retained as the Promise-style public API surface (skipped step H).

Bug fixes from end-to-end testing

Three follow-up commits at the tip address bugs surfaced by running the live demo:

  • cde05d6chore(harness): add approval-gate to demo.sh WORKERS; commit session-tree Cargo.lock (Phase A oversight)
  • 0312ce4chore(harness/web): tsc noEmit so Vite owns the JS emit (build hygiene)
  • deead38fix(harness/web): agent_end shape, dedupe, and main layout
    • Backend emits bare AgentMessage[] for agent_end.messages; reducer now tolerates both shapes.
    • Content-hash dedupe in pushUnkeyed prevents the message_end + agent_end double-render.
    • .main switched from grid (template-rows blew up with 8 children) to flex column.

Commit history

15 commits over Phase B + 3 fix commits = 18 total ahead of Phase A's tip.

ytallo added 15 commits May 6, 2026 23:56
…eleton

- New harness::fanout module: per-browser subscription registry
  (BrowserId -> {session_ids, all-sessions sentinel}). 4 unit tests cover
  per-session routing, all-sessions fallthrough, eviction, and global-only
  filtering.
- New bridge::info function: returns the relative WS path (/iii/ws),
  protocol "ws", and the engine_url the harness was started with so
  reverse-proxy / HTTPS browser clients compose URLs from window.location
  while native callers still get a usable ws:// URL.
- New ui::subscribe / ui::unsubscribe functions: add/remove a browser's
  interest in a session (or null = all sessions / non-session topics).
  Real subscribers (agent::events, state diffs, llm-budget,
  harness::status) wire in later steps.
- HarnessFunctionRefs gains bridge_info, subscribe_fn, unsubscribe_fn;
  unregister_all updated.
- register_with_iii_with_engine_url is the new entry point that takes the
  engine URL through; register_with_iii kept as a default-URL shim so the
  existing test surface stays unchanged.
- iii-worker-manager is intentionally NOT added to EXPECTED_WORKERS: it is
  built into the iii engine itself (engine's builtin_defaults.rs), not a
  discrete worker crate. Comment added near EXPECTED_WORKERS to document
  the assumption.
- New tests/bridge_info.rs integration test (engine-backed; auto-skips
  when no engine is reachable).
Cmd-J (Ctrl-J on Linux/Win) opens a global modal listing every function on
the bus. Drill-in shows description + a raw JSON payload editor that posts
through /bridge/trigger. Sensitive functions (policy::*, auth::set_*, shell
filesystem writes) get a two-step Enter-to-confirm Send. Recent invocations
(last 20) persist in localStorage so the next open pre-fills the payload.

Files:
- useGlobalShortcut.ts + test: cross-platform Cmd/Ctrl binder with pure
  matchesShortcut predicate so the matching logic is testable in node env.
- palette.ts + test: isSensitive, workerFromId, filterPalette (mirrors the
  menuItems.ts ranking — substring beats fuzzy), loadRecent / pushRecent
  (dedupe by function_id, cap at 20, survives quota errors), curlForBridge.
- components/FunctionPalette.tsx: list view with Functions/Recent tabs,
  arrow-key nav, ARIA dialog/listbox/option, focus trap (filter focused on
  open, prior focus restored on close), drill-in with JSON textarea, response
  pre, copy-as-curl, sensitive confirm flow, prefers-reduced-motion respect.
- App.tsx: wires paletteOpen state + useGlobalShortcut + footer hint.
- styles.css: .palette-* classes (backdrop, list, drill, sensitive chip).
…esWithEntryIds; group + subtitle

Switch the rail to session-tree::list (with state::list drift fallback when
total === 0), group rows by Today/Yesterday/Earlier, and render the workspace
cwd as a left-truncated subtitle. Per-message kebab actions render fork +
copy; fork is disabled with a tooltip when entry_id is null (drift case).

- sessions.ts: pure groupByDate + truncatePath helpers (vitest covered).
- export.ts: client-side exportMd/exportJson via Blob downloads of
  session-tree::messages and session-tree::tree responses.
- MessageActions.tsx: per-message kebab; copy uses navigator.clipboard.
- SessionList.tsx: session-tree::list reader + grouped sections + subtitle.
- SessionView.tsx: thread MessageActions through with parallel entry_ids.
- types.ts: SessionRow gains optional cwd + last_message_summary.
- styles.css: .session-group(s|-h), .session-subtitle, .msg-action(s).
App.tsx swaps the message loader to loadMessagesWithEntryIds and tracks a
parallel messageEntryIds array so fork buttons know which messages have
real entry_ids. Stream-driven updates pad the array with null until WS
migration (step B) carries entry_ids on the wire.

- /repair calls session-tree::reconcile with the state::* snapshot, then
  reloads to surface the new entry_ids.
- /fork forks from the most recent message with an entry_id.
- /export md|json downloads via the new export module.
- Per-message MessageActions wires onForkFromMessage which calls
  session-tree::fork and switches the active session to the new id.
- menuItems: /repair, /fork, /export md, /export json built-ins.
- Composer: dispatcher + slash-menu accept paths for the new commands.
…pressure

Adds three new long-lived fanout pumps and per-browser backpressure to the
existing harness fanout:

- spawn_approval_poll: 1s poll of approval::list_pending across all known
  sessions; emits ui::approval::requested / ui::approval::resolved on diff.
- spawn_cost_poll: 2s poll of budget::list, sums spent_usd into a daily
  total + by-period breakdown, emits ui::cost::tick on change. The
  designed llm-budget::summary function does not exist in the worker, so
  the summary is synthesized client-side; this is the closest equivalent.
- spawn_workers_poll: 5s poll of engine::workers::list, joined against
  EXPECTED_WORKERS to mark missing workers as down; emits
  ui::workers::changed on diff.

Backpressure is enforced per-browser:
- AtomicU64 in_flight counter capped at 1024; on overflow we roll back the
  failed push, emit a single deduped ui::session::resync, and wait.
- Cost ticks gated to ≥100ms apart per browser (≤10/s) via a Mutex<Option<Instant>>.

Pure helpers (should_emit_cost_tick, diff_workers, diff_approvals,
summarize_budgets, extract_worker_status) are pub(crate) and unit-tested
without spawning tasks. All four required Step E tests pass:
- fanout_pump_coalesces_cost_ticks_to_10_per_second
- fanout_pump_drops_oldest_and_emits_resync_on_overflow
- fanout_approval_pump_emits_resolved_on_removal
- fanout_workers_poll_diffs_correctly
Adds the live status surface backed by Step E's fanout pushes:

- useStatus: subscribes once per page to ui::approval::requested,
  ui::approval::resolved, ui::cost::tick, ui::workers::changed and owns
  the rolling 200-event buffer. Returns {pendingApprovals, cost, workers,
  events, hydrated, clearEvents}.
- StatusStrip (header): single Approvals chip with count, pulses on new
  approvals, grays out + shows reconnecting dot when WS drops, click
  jumps to the status tab.
- FootStatus (app-foot): cost USD + workers N/M up chips. Compact,
  mono, color-tone matches state (ok/warn/muted) plus reconnect dot.
- StatusTab: workers table (text status for a11y), inline 80x16 SVG
  cost sparkline, hydrated budget breakdown via budget::list +
  budget::usage, rolling events feed with filter chips
  (all/approval/cost/workers) + pause/resume/clear, "live updates
  paused" banner after >5s disconnect with auto re-hydration on reconnect.

App.tsx adds the new "status" tab to the existing tab nav, the right-
side header group with StatusStrip + StatusPill, and the FootStatus
chips in the footer. styles.css adds chip / table / feed / sparkline
styles tuned to the existing design tokens.

10 useStatus tests cover subscription wiring, dedup, snapshot
replacement, malformed ignore, 200-entry cap, clear, hydrated flag,
and unsubscribe on unmount. Full vitest suite: 121 pass.
…tree Cargo.lock

Phase A added approval-gate to EXPECTED_WORKERS + iii.worker.yaml but missed
the demo.sh script. Surfaced when starting the live demo: 15 workers spawned
instead of 16. session-tree Cargo.lock catches up with the serial_test +
which dev-deps added in Phase A.
`npm run build` is `tsc && vite build`. Without noEmit, tsc emits compiled
.js next to every .ts/.tsx, and Vite's resolver picks them over the .tsx
sources on subsequent dev runs. End-to-end debugging surfaced this as crashes
in components running pre-Phase-B compiled output.
Three bugs surfaced by end-to-end testing the live harness:

- AgentEvent.agent_end.messages widened to (AgentMessage | {entry_id?, message})[]:
  backend (turn-orchestrator/crates/harness-types/src/agent_event.rs) emits bare
  AgentMessage[]. Reducer now detects bare-vs-wrapped per item and routes
  accordingly. Defensive guard in pushUnkeyed rejects anything missing a role
  so future similar mismatches degrade silently instead of crashing the tree.

- Content-hash dedupe in pushUnkeyed (role:timestamp:content-length, matches
  pre-Phase-B approach). Without it, message_end (per-message during the turn)
  + agent_end (full transcript at end) double every reply.

- .main switched from grid grid-template-rows: auto 1fr auto auto to flex
  column with .view as flex: 1. The grid template only described 4 rows but
  the chat tab renders 8 children, so the 1fr landed on ControlsBar and the
  controls strip stretched to fill the viewport with the chips visually
  centered in empty space.
@coderabbitai

coderabbitai Bot commented May 7, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 4890efd2-2104-49af-a600-e4d8055b1218

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/harness-phase-b

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@ytallo
ytallo merged commit edb38ec into feat/harness-phase-a May 7, 2026
7 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants