Skip to content

Phase B: harness/web improvements (re-open after merge mishap) - #102

Merged
ytallo merged 53 commits into
mainfrom
feat/harness-phase-b
May 7, 2026
Merged

ytallo merged 53 commits into
mainfrom
feat/harness-phase-b

Conversation

@ytallo

@ytallo ytallo commented May 7, 2026 •

Copy link
Copy Markdown
Contributor

Re-opens #101. That PR was technically marked merged, but its base never retargeted from feat/harness-phase-a to main after Phase A landed, so the merge commit landed on the dead branch and Phase B's commits never reached main. This PR is the same content, retargeted to main correctly.

What's in the box

WS transport cutover + composer power + bus function palette + live status panel + session UX, layered on Phase A's (#100) foundation.

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

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 with backpressure (atomic in-flight counter, drop+resync on overflow, ≤10/s coalesce on cost).
  • 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, /repair, /fork, /export md|json. Skills appear as /<skill-id>.
  • @-mention browses files from cwd via shell::filesystem::ls. Plain monospace path insert.
  • ↑ 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.

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 — Skipped

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.

Bug fixes from end-to-end testing

Three fix commits at the tip address bugs surfaced by running the live demo:

  • chore(harness): add approval-gate to demo.sh WORKERS; commit session-tree Cargo.lock — Phase A oversight (demo.sh predated approval-gate addition).
  • chore(harness/web): tsc noEmit so Vite owns the JS emit — without this, tsc emits stale .js next to .tsx and Vite's resolver picks the .js, so dev runs serve pre-Phase-B compiled output.
  • fix(harness/web): agent_end shape, dedupe, and main layout
    • Backend (turn-orchestrator/crates/harness-types/src/agent_event.rs) emits bare AgentMessage[]; reducer now tolerates both bare and wrapped 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.

Plus: fix(tests): clippy::map_unwrap_or in nonce() helpers — required for CI's Rust 1.95 strict-warnings.

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 (incl. CI's Rust 1.95 lints)
  • 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 — none are blockers, all have rationale:

  1. llm-budget::summary doesn't exist — cost pump synthesizes from budget::list; 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).

Summary by CodeRabbit

  • New Features

    • Added function palette (Cmd-J) to browse and trigger engine functions with recent history and sensitive-operation warnings.
    • Introduced session export to Markdown and JSON formats.
    • Added session fork and repair capabilities via /fork and /repair commands.
    • Enhanced composer with slash-command autocomplete and @-mention file browsing.
    • Implemented real-time status updates for approvals, costs, and worker health.
    • Added per-session working directory persistence.
  • UI/UX

    • Session list now grouped by date (today, yesterday, earlier).
    • Added footer status showing cost and worker availability.
    • Per-message fork and copy actions.

ytallo added 30 commits May 6, 2026 21:26
- inbox::* -> session-inbox::*
- hooks::publish_collect -> hook-fanout::publish_collect

Updates README, registry/index.json, turn-orchestrator callsites,
and whitelists per-worker config.yaml files in .gitignore.
…nd description

- Introduced constants for skill ID, metadata, and sub-skills in `lib.rs`.
- Updated the description in `start.rs` to remove an unnecessary argument.
- Implemented skill registration with retry logic and graceful shutdown handling in `main.rs`.
Replace Duration::from_secs(180)/(60) and from_secs(3 * 60) with
from_mins(3)/from_mins(1) in skill-register retry loops for
auth-credentials, llm-budget, and session-tree.
- Changed trigger type from "subscribe" to "durable:subscriber" in multiple modules.
- Updated function IDs to use the "iii::durable::publish" format for consistency across the codebase.
Phase A item #3. Includes new test expected_workers_includes_approval_gate
and the existing drift test passes.
ytallo added 21 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.
CI runs Rust 1.95 with stricter lints. Replace .map(...).unwrap_or(0)
with .map_or(0, ...) in both harness/tests/common/mod.rs and
turn-orchestrator/tests/common/mod.rs nonce() helpers.
# Conflicts:
#	harness/src/main.rs
#	harness/web/src/App.tsx
#	harness/web/src/reducer.ts
#	harness/web/src/types.ts
@coderabbitai

coderabbitai Bot commented May 7, 2026 •

Copy link
Copy Markdown

Review Change Stack

Caution

Review failed

The pull request is closed.

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 232c8ab4-e3bf-441f-acf8-9753a1cfc30f

📥 Commits

Reviewing files that changed from the base of the PR and between c1379c8 and fd713c5.

📒 Files selected for processing (41)
  • harness/scripts/demo.sh
  • harness/src/fanout.rs
  • harness/src/lib.rs
  • harness/src/main.rs
  • harness/tests/bridge_info.rs
  • harness/web/package.json
  • harness/web/src/App.tsx
  • harness/web/src/bridge.ts
  • harness/web/src/components/Composer.tsx
  • harness/web/src/components/FootStatus.tsx
  • harness/web/src/components/FunctionPalette.tsx
  • harness/web/src/components/MessageActions.tsx
  • harness/web/src/components/SessionList.tsx
  • harness/web/src/components/SessionView.tsx
  • harness/web/src/components/StatusStrip.tsx
  • harness/web/src/components/StatusTab.tsx
  • harness/web/src/export.test.ts
  • harness/web/src/export.ts
  • harness/web/src/iii-client.test.ts
  • harness/web/src/iii-client.ts
  • harness/web/src/menuItems.test.ts
  • harness/web/src/menuItems.ts
  • harness/web/src/palette.test.ts
  • harness/web/src/palette.ts
  • harness/web/src/reducer.ts
  • harness/web/src/sessions.test.ts
  • harness/web/src/sessions.ts
  • harness/web/src/styles.css
  • harness/web/src/types.ts
  • harness/web/src/useAgentStream.ts
  • harness/web/src/useCommandMenu.test.ts
  • harness/web/src/useCommandMenu.ts
  • harness/web/src/useConnection.test.ts
  • harness/web/src/useConnection.ts
  • harness/web/src/useGlobalShortcut.test.ts
  • harness/web/src/useGlobalShortcut.ts
  • harness/web/src/useStatus.test.ts
  • harness/web/src/useStatus.ts
  • harness/web/src/workspace.test.ts
  • harness/web/src/workspace.ts
  • harness/web/tsconfig.json

📝 Walkthrough

Walkthrough

This PR introduces a per-browser subscription fanout layer for the harness that pushes live state changes (sessions, approvals, costs, workers) to connected browsers via WebSocket, and replatforms the web UI from SSE to a push-based architecture using the iii-browser-sdk. It includes new UI components for command menus, function palettes, message forking/repair, session export, and live status dashboards.

Changes

Harness Fanout & State Management

Layer / File(s) Summary
Fanout Types & Contracts
harness/src/fanout.rs
Introduces FanoutState subscription registry, BrowserOutbound per-browser tracking, and PushOutcome/PushKind enums.
Polling Loops & Event Forwarding
harness/src/fanout.rs
Spawns agent event stream pump and sessions/approval/cost/workers polling loops that forward diffs to subscribed browsers via push_to_browser.
Pure Helpers & Extraction
harness/src/fanout.rs
Implements diff_workers, diff_approvals, summarize_budgets, and payload extractors for workers, approvals, and session IDs.
Queue Management & Backpressure
harness/src/fanout.rs
Central push_to_browser path enforces per-browser queue caps, coalesces cost ticks, resyncs on overflow.
Harness Registration & Wiring
harness/src/lib.rs
Expands HarnessFunctionRefs with bridge_info, ui::subscribe, ui::unsubscribe; spawns and manages fanout pumps.
Integration Tests & Demo
harness/tests/bridge_info.rs, harness/scripts/demo.sh, harness/src/main.rs
Bridge info test, engine probe, demo worker list update.

Web UI Replatforming

Layer / File(s) Summary
Web Type Contracts
harness/web/src/types.ts, harness/web/package.json, harness/web/tsconfig.json
Adds Workspace interface, optional cwd/last_message_summary to SessionRow, widens agent_end messages; updates iii-browser-sdk to 0.11.7-next.1; enables noEmit.
III Client Bootstrap
harness/web/src/iii-client.ts, harness/web/src/iii-client.test.ts
Lazy singleton wrapper around iii-browser-sdk with bridge::info bootstrap, WebSocket composition, browser ID generation, worker registration, and test seams.
Connection & Status Hooks
harness/web/src/useConnection.ts, harness/web/src/useStatus.ts
Connection state hook tracking iii-client transitions; status hook managing live approval, cost, worker snapshots and rolling event buffer.
Command Menu & Function Palette
harness/web/src/useCommandMenu.ts, harness/web/src/menuItems.ts, harness/web/src/palette.ts, harness/web/src/components/FunctionPalette.tsx
Headless reducer for slash/at/history modes, built-in commands, skills parsing, palette filtering/ranking, recent persistence, sensitivity gating, and modal UI with drill-in.
Session & Message State
harness/web/src/reducer.ts, harness/web/src/sessions.ts, harness/web/src/workspace.ts
Message deduplication by content hash, session grouping by date, path truncation, and workspace persistence via bridge state operations.
Stream & Export Utilities
harness/web/src/useAgentStream.ts, harness/web/src/export.ts
Stream hook switched to iii WebSocket subscription with event envelope normalization; export to Markdown and JSON with session trees.
Global Shortcuts
harness/web/src/useGlobalShortcut.ts
Shortcut spec, event matching with cross-platform modifiers, window-level keydown listener hook.
Main App Component
harness/web/src/App.tsx
Refactored to use iii-client streams and subscriptions, message entry IDs, session cwd, command callbacks, fork/repair/export actions, status tab, and Cmd-J function palette.
Session List & View
harness/web/src/components/SessionList.tsx, harness/web/src/components/SessionView.tsx
SessionList prefers session-tree::list with legacy fallback and grouped date rendering; SessionView wired with entry IDs and fork callback.
Message Actions & Composer
harness/web/src/components/MessageActions.tsx, harness/web/src/components/Composer.tsx
Per-message fork/copy actions; Composer expanded with slash/at menus, async @-directory browsing, and command interception.
Status & Footer Components
harness/web/src/components/StatusStrip.tsx, harness/web/src/components/StatusTab.tsx, harness/web/src/components/FootStatus.tsx
StatusStrip for pending approvals, StatusTab with workers/cost/events, FootStatus showing cost and worker chips.
Bridge & Configuration
harness/web/src/bridge.ts
Error wrapping for non-BridgeError exceptions.
Styles
harness/web/src/styles.css
New styles for app header input, flex layout, composer popover, function palette, session grouping, message actions, status strip/chips, and status tab.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Suggested reviewers

  • sergiofilhowz

🐰 A harness reborn in streams so bright,
With fanout pumps and push all night,
Commands slash, and functions drill,
Sessions fork by worker's will!
WebSockets flow where SSE once fell,
Live dashboards dance—a review's tale to tell! ✨

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/harness-phase-b

@ytallo
ytallo merged commit 75f075f into main May 7, 2026
6 of 7 checks passed
ytallo added a commit that referenced this pull request May 8, 2026
Resolves squash-merge fake conflicts: Phase A/B content (PR #100, #102)
landed on main as squash commits while feat/iii-native-harness retained
the original commits + the tool->function rename + iii-native-harness work.

Resolutions favor feat side for files where Phase A/B content was the
sole source of conflict; main's unique content (skills filesystem support,
storage worker, etc.) merged automatically.

Removed turn-orchestrator/src/states/tools.rs (renamed to functions.rs).
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.

1 participant