;
+ on(
+ functionId: string,
+ handler: (payload: P) => void | Promise,
+ ): () => void;
+ addConnectionStateListener(
+ handler: (state: IIIConnectionState) => void,
+ ): () => void;
+ dispose(): Promise;
+}
+
+interface Deps {
+ fetchBridgeInfo: () => Promise;
+ makeBrowserId: () => string;
+ registerWorker: (url: string) => ISdk;
+ composeWsUrl: (info: BridgeInfoResponse) => string;
+}
+
+let _clientPromise: Promise | null = null;
+let _deps: Deps = defaultDeps();
+
+function defaultDeps(): Deps {
+ return {
+ fetchBridgeInfo,
+ makeBrowserId,
+ registerWorker: (url) => registerWorker(url),
+ composeWsUrl,
+ };
+}
+
+/**
+ * Get (and lazily create) the shared iii-client for this page.
+ */
+export function getIiiClient(): Promise {
+ if (!_clientPromise) {
+ _clientPromise = bootstrap(_deps);
+ }
+ return _clientPromise;
+}
+
+/**
+ * Tear down the shared client. Safe to call multiple times.
+ */
+export async function disposeIiiClient(): Promise {
+ if (!_clientPromise) return;
+ const client = await _clientPromise.catch(() => null);
+ _clientPromise = null;
+ if (client) await client.dispose();
+}
+
+/**
+ * Test seam: replace the bootstrap dependencies and wipe the cached client.
+ * Tests should call `__resetIiiClientForTests()` in afterEach to restore.
+ */
+export function __setIiiClientDepsForTests(overrides: Partial): void {
+ _deps = { ..._deps, ...overrides };
+ _clientPromise = null;
+}
+
+export function __resetIiiClientForTests(): void {
+ _deps = defaultDeps();
+ _clientPromise = null;
+}
+
+async function bootstrap(deps: Deps): Promise {
+ const info = await deps.fetchBridgeInfo();
+ const wsUrl = deps.composeWsUrl(info);
+ const browserId = deps.makeBrowserId();
+ const sdk = deps.registerWorker(wsUrl);
+
+ return wrapSdk(sdk, browserId);
+}
+
+function wrapSdk(sdk: ISdk, browserId: string): IiiClient {
+ // Track per-functionId unregister fns so dispose() can clean them all up
+ // even if individual `on()` callers forgot.
+ const handlerUnregisters = new Set<() => void>();
+
+ function call(
+ functionId: string,
+ payload: Record = {},
+ ): Promise {
+ return sdk.trigger({
+ function_id: functionId,
+ payload,
+ });
+ }
+
+ function on(
+ functionId: string,
+ handler: (payload: P) => void | Promise,
+ ): () => void {
+ const id = `${functionId}::${browserId}`;
+ // Wrap to satisfy the SDK's RemoteFunctionHandler signature (returns
+ // Promise). We never want to send a result back.
+ const wrapped: RemoteFunctionHandler = async (data: unknown) => {
+ await handler(data as P);
+ return null;
+ };
+ const ref = sdk.registerFunction(id, wrapped);
+ let active = true;
+ const unregister = () => {
+ if (!active) return;
+ active = false;
+ handlerUnregisters.delete(unregister);
+ try {
+ ref.unregister();
+ } catch {
+ // SDK already disposed; nothing to do.
+ }
+ };
+ handlerUnregisters.add(unregister);
+ return unregister;
+ }
+
+ function addConnectionStateListener(
+ handler: (state: IIIConnectionState) => void,
+ ): () => void {
+ return sdk.addConnectionStateListener(handler);
+ }
+
+ async function dispose(): Promise {
+ for (const unregister of [...handlerUnregisters]) {
+ unregister();
+ }
+ await sdk.shutdown();
+ }
+
+ return {
+ browserId,
+ call,
+ on,
+ addConnectionStateListener,
+ dispose,
+ };
+}
+
+function composeWsUrl(info: BridgeInfoResponse): string {
+ // If the backend gave us a fully-qualified engine URL (single-tenant local
+ // default), prefer it. Otherwise compose from `window.location` so
+ // reverse-proxy / HTTPS deployments keep working without configuration.
+ if (info.engine_url && info.engine_url.length > 0) {
+ return info.engine_url;
+ }
+ const proto = info.protocol === "wss" ? "wss" : "ws";
+ if (typeof window === "undefined") {
+ throw new Error(
+ "iii-client: cannot compose ws url without window.location and engine_url",
+ );
+ }
+ return `${proto}://${window.location.host}${info.ws_path}`;
+}
+
+function makeBrowserId(): string {
+ // crypto.randomUUID() is available in all evergreen browsers.
+ if (
+ typeof crypto !== "undefined" &&
+ typeof crypto.randomUUID === "function"
+ ) {
+ return `harness-${crypto.randomUUID()}`;
+ }
+ // Fallback for environments without crypto.randomUUID (older WebViews).
+ const rand = Math.random().toString(36).slice(2, 10);
+ return `harness-${Date.now().toString(36)}-${rand}`;
+}
+
+async function fetchBridgeInfo(): Promise {
+ const res = await fetch("/bridge/trigger", {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ function_id: "bridge::info", payload: {} }),
+ });
+ if (!res.ok) {
+ throw new Error(`bridge::info failed: ${res.status} ${res.statusText}`);
+ }
+ const data = (await res.json()) as Partial;
+ if (
+ typeof data.ws_path !== "string" ||
+ (data.protocol !== "ws" && data.protocol !== "wss") ||
+ typeof data.engine_url !== "string"
+ ) {
+ throw new Error("bridge::info returned malformed response");
+ }
+ return {
+ ws_path: data.ws_path,
+ protocol: data.protocol,
+ engine_url: data.engine_url,
+ };
+}
diff --git a/harness/web/src/menuItems.test.ts b/harness/web/src/menuItems.test.ts
new file mode 100644
index 000000000..e6e5770e0
--- /dev/null
+++ b/harness/web/src/menuItems.test.ts
@@ -0,0 +1,95 @@
+import { describe, it, expect } from "vitest";
+import {
+ BUILT_IN_COMMANDS,
+ filterCommands,
+ skillsIndexToMenuItems,
+} from "./menuItems";
+import type { MenuItem } from "./useCommandMenu";
+
+const item = (id: string, label = id): MenuItem => ({
+ kind: "builtin",
+ id,
+ label,
+});
+
+describe("filterCommands", () => {
+ it("empty query returns all items in original order", () => {
+ const result = filterCommands(BUILT_IN_COMMANDS, "");
+ expect(result.map((x) => x.id)).toEqual(BUILT_IN_COMMANDS.map((x) => x.id));
+ });
+
+ it("ranks exact-prefix matches first", () => {
+ const items = [item("/clear"), item("/cwd"), item("/help")];
+ const result = filterCommands(items, "/c");
+ // Both /clear and /cwd are prefix matches; original order preserved by stable sort.
+ expect(result[0].id).toBe("/clear");
+ expect(result[1].id).toBe("/cwd");
+ expect(result.find((x) => x.id === "/help")).toBeUndefined();
+ });
+
+ it("substring beats fuzzy", () => {
+ const substring = item("/xfoobar"); // contains "foo"
+ const fuzzy = item("/fxoxo"); // matches f-o-o as fuzzy but no "foo" substring
+ const items = [fuzzy, substring];
+ const result = filterCommands(items, "foo");
+ expect(result[0].id).toBe("/xfoobar");
+ });
+
+ it("below-threshold matches drop out (no fuzzy on label fallback for nonsense)", () => {
+ const items = [item("/foo")];
+ const result = filterCommands(items, "xyz");
+ expect(result).toEqual([]);
+ });
+
+ it("fuzzy on id matches in-order chars", () => {
+ const items = [item("/provider"), item("/help")];
+ const result = filterCommands(items, "pvr");
+ expect(result.map((x) => x.id)).toEqual(["/provider"]);
+ });
+});
+
+describe("skillsIndexToMenuItems", () => {
+ it("returns [] for null", () => {
+ expect(skillsIndexToMenuItems(null)).toEqual([]);
+ });
+
+ it("returns [] for empty string", () => {
+ expect(skillsIndexToMenuItems("")).toEqual([]);
+ });
+
+ it("parses well-formed lines with em-dash", () => {
+ const md = [
+ "# skills",
+ "",
+ "- [tdd](iii://skills/tdd) — Write tests first",
+ "- [refactor](iii://skills/refactor) — Clean up dead code",
+ "",
+ ].join("\n");
+ const out = skillsIndexToMenuItems(md);
+ expect(out.length).toBe(2);
+ expect(out[0].kind).toBe("skill");
+ expect(out[0].id).toBe("/tdd");
+ expect(out[0].label).toBe("/tdd");
+ expect(out[0].description).toContain("Write tests first");
+ expect((out[0].meta as { uri: string }).uri).toBe("iii://skills/tdd");
+ });
+
+ it("parses lines with plain hyphen separator", () => {
+ const md = "- [foo](iii://skills/foo) - description here";
+ const out = skillsIndexToMenuItems(md);
+ expect(out.length).toBe(1);
+ expect(out[0].id).toBe("/foo");
+ });
+
+ it("skips non-skill lines silently", () => {
+ const md = [
+ "Some intro paragraph",
+ "- not a skill link",
+ "- [valid](iii://skills/valid) — yes",
+ "- [external](https://example.com) — no",
+ ].join("\n");
+ const out = skillsIndexToMenuItems(md);
+ expect(out.length).toBe(1);
+ expect(out[0].id).toBe("/valid");
+ });
+});
diff --git a/harness/web/src/menuItems.ts b/harness/web/src/menuItems.ts
new file mode 100644
index 000000000..2d0c8edbb
--- /dev/null
+++ b/harness/web/src/menuItems.ts
@@ -0,0 +1,138 @@
+// Slash-menu items: built-in commands + skills parsed from the iii://skills
+// markdown index. Plus a fuzzy filter the popover applies as the user types.
+//
+// The filter ranking is intentionally simple — the slash menu has at most a
+// few dozen entries, so we sort in-memory on every keystroke.
+
+import type { MenuItem } from "./useCommandMenu";
+
+export const BUILT_IN_COMMANDS: MenuItem[] = [
+ { kind: "builtin", id: "/new", label: "/new", description: "Start a new session" },
+ { kind: "builtin", id: "/clear", label: "/clear", description: "Clear current draft" },
+ {
+ kind: "builtin",
+ id: "/cwd",
+ label: "/cwd",
+ description: "Set working directory: /cwd ",
+ },
+ {
+ kind: "builtin",
+ id: "/model",
+ label: "/model",
+ description: "Switch model: /model ",
+ },
+ {
+ kind: "builtin",
+ id: "/provider",
+ label: "/provider",
+ description: "Switch provider: /provider ",
+ },
+ { kind: "builtin", id: "/help", label: "/help", description: "Show shortcuts" },
+ {
+ kind: "builtin",
+ id: "/repair",
+ label: "/repair",
+ description: "Repair session-tree drift via session-tree::reconcile",
+ },
+ {
+ kind: "builtin",
+ id: "/fork",
+ label: "/fork",
+ description: "Fork session at last message",
+ },
+ {
+ kind: "builtin",
+ id: "/export md",
+ label: "/export md",
+ description: "Export current session as markdown",
+ },
+ {
+ kind: "builtin",
+ id: "/export json",
+ label: "/export json",
+ description: "Export current session as JSON",
+ },
+];
+
+const SCORE_PREFIX = 100;
+const SCORE_SUBSTRING = 50;
+const SCORE_FUZZY_ID = 10;
+const SCORE_FUZZY_LABEL = 5;
+const SCORE_THRESHOLD = 5;
+
+/**
+ * In-order character match: every char of needle appears (in order, not
+ * necessarily contiguous) in haystack. Case-insensitive.
+ */
+function fuzzyMatches(haystack: string, needle: string): boolean {
+ if (needle.length === 0) return true;
+ let h = 0;
+ let n = 0;
+ while (h < haystack.length && n < needle.length) {
+ if (haystack[h] === needle[n]) n += 1;
+ h += 1;
+ }
+ return n === needle.length;
+}
+
+function scoreItem(item: MenuItem, query: string): number {
+ const id = item.id.toLowerCase();
+ const label = item.label.toLowerCase();
+ const q = query.toLowerCase();
+
+ if (id.startsWith(q)) return SCORE_PREFIX;
+ if (id.includes(q)) return SCORE_SUBSTRING;
+ if (fuzzyMatches(id, q)) return SCORE_FUZZY_ID;
+ if (fuzzyMatches(label, q)) return SCORE_FUZZY_LABEL;
+ return 0;
+}
+
+/**
+ * Filter + rank items by query. Empty query returns items in original order
+ * (no ranking). Items scoring below threshold are dropped.
+ */
+export function filterCommands(items: MenuItem[], query: string): MenuItem[] {
+ if (query.length === 0) return items.slice();
+ const scored = items
+ .map((item, idx) => ({ item, score: scoreItem(item, query), idx }))
+ .filter((x) => x.score >= SCORE_THRESHOLD);
+ // Stable sort: higher score first, original index breaks ties.
+ scored.sort((a, b) => {
+ if (b.score !== a.score) return b.score - a.score;
+ return a.idx - b.idx;
+ });
+ return scored.map((x) => x.item);
+}
+
+// Each line of the rendered iii://skills index looks like:
+// - [name](iii://skills/) —
+// or with a hyphen-minus instead of em-dash. We keep the regex permissive
+// but anchor it on the `iii://skills/` URI so non-skill lines are skipped.
+const SKILL_LINE = /^-\s+\[([^\]]+)\]\((iii:\/\/skills\/[^)]+)\)\s*[—\-]\s*(.+)$/;
+
+/**
+ * Parse the markdown body returned by `skill::fetch iii://skills` into
+ * MenuItems. Lines that don't match the expected shape are skipped silently.
+ * Returns [] if the index hasn't loaded yet.
+ */
+export function skillsIndexToMenuItems(index: string | null): MenuItem[] {
+ if (index == null) return [];
+ const out: MenuItem[] = [];
+ for (const raw of index.split("\n")) {
+ const line = raw.trim();
+ if (!line.startsWith("-")) continue;
+ const m = SKILL_LINE.exec(line);
+ if (!m) continue;
+ const [, name, uri, description] = m;
+ // Derive the id portion of the URI (everything after `iii://skills/`).
+ const idPart = uri.slice("iii://skills/".length);
+ out.push({
+ kind: "skill",
+ id: `/${idPart}`,
+ label: `/${idPart}`,
+ description: `${name} — ${description}`,
+ meta: { uri },
+ });
+ }
+ return out;
+}
diff --git a/harness/web/src/palette.test.ts b/harness/web/src/palette.test.ts
new file mode 100644
index 000000000..1eec20546
--- /dev/null
+++ b/harness/web/src/palette.test.ts
@@ -0,0 +1,224 @@
+import { afterEach, beforeEach, describe, expect, it } from "vitest";
+import {
+ type FunctionEntry,
+ type RecentInvocation,
+ curlForBridge,
+ filterPalette,
+ isSensitive,
+ loadRecent,
+ pushRecent,
+ workerFromId,
+} from "./palette";
+
+// ---- minimal localStorage shim for the node test env ----
+//
+// vitest runs with `environment: "node"` (see vitest.config.ts), so there's
+// no global `localStorage`. The palette helpers gracefully degrade when it's
+// missing, which we test explicitly below — but we also need a working shim
+// for the round-trip tests on pushRecent / loadRecent.
+class MemoryStorage {
+ private store = new Map();
+ getItem(k: string): string | null {
+ return this.store.has(k) ? (this.store.get(k) as string) : null;
+ }
+ setItem(k: string, v: string): void {
+ this.store.set(k, v);
+ }
+ removeItem(k: string): void {
+ this.store.delete(k);
+ }
+ clear(): void {
+ this.store.clear();
+ }
+}
+
+const installStorage = () => {
+ (globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
+ new MemoryStorage();
+};
+const uninstallStorage = () => {
+ delete (globalThis as unknown as { localStorage?: unknown }).localStorage;
+};
+
+describe("isSensitive", () => {
+ it.each([
+ ["policy::deny", true],
+ ["policy::list", true],
+ ["auth::set_token", true],
+ ["auth::set_anything", true],
+ ["shell::filesystem::write", true],
+ ["shell::filesystem::mkdir", true],
+ ["shell::filesystem::rm", true],
+ ["shell::filesystem::ls", false],
+ ["shell::filesystem::read", false],
+ ["state::get", false],
+ ["harness::status", false],
+ ["auth::status", false],
+ ["models::list", false],
+ ])("%s -> %s", (id, expected) => {
+ expect(isSensitive(id)).toBe(expected);
+ });
+});
+
+describe("workerFromId", () => {
+ it("returns prefix before ::", () => {
+ expect(workerFromId("session-tree::list")).toBe("session-tree");
+ expect(workerFromId("shell::filesystem::ls")).toBe("shell");
+ });
+ it("returns (unknown) when no separator", () => {
+ expect(workerFromId("flat")).toBe("(unknown)");
+ expect(workerFromId("")).toBe("(unknown)");
+ });
+});
+
+describe("filterPalette", () => {
+ const fn = (function_id: string, description = ""): FunctionEntry => ({
+ function_id,
+ description,
+ });
+
+ it("empty query returns items in original order", () => {
+ const items = [fn("a::x"), fn("b::y")];
+ expect(filterPalette(items, "").map((i) => i.function_id)).toEqual([
+ "a::x",
+ "b::y",
+ ]);
+ });
+
+ it("ranks substring on id ahead of substring on description", () => {
+ const idMatch = fn("harness::status", "completely unrelated text");
+ const descMatch = fn("session-tree::list", "harness related description");
+ const result = filterPalette([descMatch, idMatch], "harness");
+ expect(result[0].function_id).toBe("harness::status");
+ expect(result[1].function_id).toBe("session-tree::list");
+ });
+
+ it("ranks prefix above substring", () => {
+ const prefix = fn("foo::bar");
+ const substring = fn("xx::xfooz");
+ const result = filterPalette([substring, prefix], "foo");
+ expect(result[0].function_id).toBe("foo::bar");
+ });
+
+ it("drops below-threshold entries", () => {
+ const items = [fn("alpha::beta", "gamma")];
+ expect(filterPalette(items, "zzz")).toEqual([]);
+ });
+
+ it("matches on worker prefix", () => {
+ const items = [fn("session-tree::list"), fn("harness::status")];
+ const result = filterPalette(items, "session");
+ expect(result[0].function_id).toBe("session-tree::list");
+ });
+});
+
+describe("loadRecent", () => {
+ afterEach(() => {
+ uninstallStorage();
+ });
+
+ it("returns [] when localStorage is undefined", () => {
+ uninstallStorage();
+ expect(loadRecent()).toEqual([]);
+ });
+
+ it("returns [] when key is missing", () => {
+ installStorage();
+ expect(loadRecent()).toEqual([]);
+ });
+
+ it("returns [] when stored value is invalid JSON", () => {
+ installStorage();
+ localStorage.setItem("harness.palette.recent", "{not json");
+ expect(loadRecent()).toEqual([]);
+ });
+
+ it("returns [] when stored value is not an array", () => {
+ installStorage();
+ localStorage.setItem("harness.palette.recent", JSON.stringify({}));
+ expect(loadRecent()).toEqual([]);
+ });
+
+ it("filters out malformed entries", () => {
+ installStorage();
+ const mixed = [
+ { function_id: "a::b", payload: {}, ts: 1, ok: true },
+ { function_id: 42 }, // wrong type
+ null,
+ { function_id: "c::d", payload: null, ts: 2, ok: false },
+ ];
+ localStorage.setItem("harness.palette.recent", JSON.stringify(mixed));
+ const out = loadRecent();
+ expect(out.length).toBe(2);
+ expect(out[0].function_id).toBe("a::b");
+ expect(out[1].function_id).toBe("c::d");
+ });
+});
+
+describe("pushRecent", () => {
+ beforeEach(() => {
+ installStorage();
+ });
+ afterEach(() => {
+ uninstallStorage();
+ });
+
+ const inv = (id: string, ts: number): RecentInvocation => ({
+ function_id: id,
+ payload: {},
+ ts,
+ ok: true,
+ });
+
+ it("dedupes by function_id, keeping the new entry at the head", () => {
+ pushRecent(inv("a::x", 1));
+ pushRecent(inv("b::y", 2));
+ const after = pushRecent(inv("a::x", 3));
+ expect(after.map((r) => r.function_id)).toEqual(["a::x", "b::y"]);
+ expect(after[0].ts).toBe(3);
+ });
+
+ it("caps at 20 entries", () => {
+ for (let i = 0; i < 25; i++) {
+ pushRecent(inv(`fn${i}::x`, i));
+ }
+ const final = loadRecent();
+ expect(final.length).toBe(20);
+ // Newest first
+ expect(final[0].function_id).toBe("fn24::x");
+ expect(final[19].function_id).toBe("fn5::x");
+ });
+
+ it("survives setItem throwing (quota exceeded)", () => {
+ const throwing = {
+ getItem: () => null,
+ setItem: () => {
+ throw new Error("QuotaExceededError");
+ },
+ removeItem: () => undefined,
+ clear: () => undefined,
+ };
+ (globalThis as unknown as { localStorage: typeof throwing }).localStorage =
+ throwing;
+ // Must not throw
+ expect(() => pushRecent(inv("a::b", 1))).not.toThrow();
+ });
+});
+
+describe("curlForBridge", () => {
+ it("builds a POST against /bridge/trigger with the function_id + payload", () => {
+ const out = curlForBridge("models::list", {});
+ expect(out).toContain("curl -X POST http://127.0.0.1:3111/bridge/trigger");
+ expect(out).toContain("'content-type: application/json'");
+ expect(out).toContain('"function_id": "models::list"');
+ expect(out).toContain('"payload": {}');
+ });
+
+ it("escapes single quotes in payload values", () => {
+ const out = curlForBridge("shell::run", { cmd: "echo 'hi'" });
+ // The single quote inside the JSON body must be escaped using the
+ // standard '\'' shell-quoting trick so the command can be pasted into a
+ // real shell.
+ expect(out).toContain("'\\''hi'\\''");
+ });
+});
diff --git a/harness/web/src/palette.ts b/harness/web/src/palette.ts
new file mode 100644
index 000000000..fc56fd755
--- /dev/null
+++ b/harness/web/src/palette.ts
@@ -0,0 +1,170 @@
+// Pure helpers for the bus function palette (Cmd-J).
+//
+// Kept pure so the FunctionPalette component reduces to event wiring +
+// rendering. Filter ranking mirrors menuItems.ts::filterCommands so the two
+// in-app fuzzies behave the same. Recent-list persistence is intentionally
+// localStorage-only — Phase B doesn't sync invocation history across
+// browsers, and the function palette is a power-user tool.
+
+export interface FunctionEntry {
+ function_id: string;
+ description?: string;
+ // Pass-through bag for anything else engine::functions::list returns
+ // (request_format, metadata, etc.) so callers can render extra fields
+ // without changes here.
+ [extra: string]: unknown;
+}
+
+export interface RecentInvocation {
+ function_id: string;
+ payload: unknown;
+ ts: number;
+ ok: boolean; // true = bridge call succeeded, false = errored
+}
+
+const SENSITIVE_PATTERNS: readonly RegExp[] = [
+ /^policy::/,
+ /^auth::set_/,
+ /^shell::filesystem::write$/,
+ /^shell::filesystem::mkdir$/,
+ /^shell::filesystem::rm$/,
+];
+
+/**
+ * True iff calling `fnId` would mutate state or escalate authority. The
+ * palette's Send button is replaced by a two-step Enter-to-confirm prompt
+ * for these. List is intentionally narrow — we lean toward over-confirm
+ * later, not bypass-now.
+ */
+export function isSensitive(fnId: string): boolean {
+ return SENSITIVE_PATTERNS.some((re) => re.test(fnId));
+}
+
+/**
+ * Extract the worker prefix from a `worker::action` style function id.
+ * Returns `"(unknown)"` when the id has no `::` separator.
+ */
+export function workerFromId(fnId: string): string {
+ const idx = fnId.indexOf("::");
+ return idx > 0 ? fnId.slice(0, idx) : "(unknown)";
+}
+
+// ---- filter ranking (mirrors menuItems.ts) ----
+
+const SCORE_PREFIX = 100;
+const SCORE_SUBSTRING_ID = 60;
+const SCORE_SUBSTRING_DESC = 40;
+const SCORE_FUZZY_ID = 10;
+const SCORE_FUZZY_DESC = 5;
+const SCORE_THRESHOLD = 5;
+
+function fuzzyMatches(haystack: string, needle: string): boolean {
+ if (needle.length === 0) return true;
+ let h = 0;
+ let n = 0;
+ while (h < haystack.length && n < needle.length) {
+ if (haystack[h] === needle[n]) n += 1;
+ h += 1;
+ }
+ return n === needle.length;
+}
+
+function scoreEntry(entry: FunctionEntry, q: string): number {
+ const id = entry.function_id.toLowerCase();
+ const worker = workerFromId(entry.function_id).toLowerCase();
+ const desc = (entry.description ?? "").toLowerCase();
+
+ if (id.startsWith(q) || worker.startsWith(q)) return SCORE_PREFIX;
+ if (id.includes(q) || worker.includes(q)) return SCORE_SUBSTRING_ID;
+ if (desc.includes(q)) return SCORE_SUBSTRING_DESC;
+ if (fuzzyMatches(id, q)) return SCORE_FUZZY_ID;
+ if (fuzzyMatches(desc, q)) return SCORE_FUZZY_DESC;
+ return 0;
+}
+
+/**
+ * Filter + rank palette entries by query. Empty query returns items in
+ * original order. Sort is stable: original index breaks score ties so the
+ * UI doesn't reshuffle when scores are equal.
+ */
+export function filterPalette(
+ items: FunctionEntry[],
+ query: string,
+): FunctionEntry[] {
+ if (query.length === 0) return items.slice();
+ const q = query.toLowerCase();
+ const scored = items
+ .map((item, idx) => ({ item, score: scoreEntry(item, q), idx }))
+ .filter((x) => x.score >= SCORE_THRESHOLD);
+ scored.sort((a, b) => {
+ if (b.score !== a.score) return b.score - a.score;
+ return a.idx - b.idx;
+ });
+ return scored.map((x) => x.item);
+}
+
+// ---- recent invocations ----
+
+const RECENT_KEY = "harness.palette.recent";
+const RECENT_CAP = 20;
+
+function isRecentInvocation(v: unknown): v is RecentInvocation {
+ if (typeof v !== "object" || v === null) return false;
+ const r = v as Record;
+ return (
+ typeof r.function_id === "string" &&
+ typeof r.ts === "number" &&
+ typeof r.ok === "boolean"
+ );
+}
+
+/**
+ * Read the recent-invocation list from localStorage. Returns `[]` for any
+ * shape we can't parse — keep failures invisible since this is recovery
+ * data, not a source of truth.
+ */
+export function loadRecent(): RecentInvocation[] {
+ if (typeof localStorage === "undefined") return [];
+ try {
+ const raw = localStorage.getItem(RECENT_KEY);
+ if (!raw) return [];
+ const parsed: unknown = JSON.parse(raw);
+ if (!Array.isArray(parsed)) return [];
+ return parsed.filter(isRecentInvocation);
+ } catch {
+ return [];
+ }
+}
+
+/**
+ * Insert `entry` at the head of the recent list, dedupe by function_id (keep
+ * the newer one), cap at RECENT_CAP. Returns the new list. Persists to
+ * localStorage if available; failures are swallowed (storage full / disabled).
+ */
+export function pushRecent(entry: RecentInvocation): RecentInvocation[] {
+ const current = loadRecent();
+ const filtered = current.filter((r) => r.function_id !== entry.function_id);
+ const next = [entry, ...filtered].slice(0, RECENT_CAP);
+ if (typeof localStorage !== "undefined") {
+ try {
+ localStorage.setItem(RECENT_KEY, JSON.stringify(next));
+ } catch {
+ // Storage full / disabled — drop silently.
+ }
+ }
+ return next;
+}
+
+/**
+ * Build a `curl` invocation that hits the harness bridge with the same
+ * function_id + payload. Quotes single quotes inside the JSON body using the
+ * standard `'\''` shell escape so the command can be pasted into a real
+ * shell.
+ */
+export function curlForBridge(fnId: string, payload: unknown): string {
+ const body = JSON.stringify({ function_id: fnId, payload }, null, 2);
+ const escaped = body.replace(/'/g, "'\\''");
+ return `curl -X POST http://127.0.0.1:3111/bridge/trigger \\
+ -H 'content-type: application/json' \\
+ -d '${escaped}'`;
+}
diff --git a/harness/web/src/reducer.ts b/harness/web/src/reducer.ts
index d55d97b74..41612adc9 100644
--- a/harness/web/src/reducer.ts
+++ b/harness/web/src/reducer.ts
@@ -38,7 +38,23 @@ function upsertMessage(state: StreamState, entryId: EntryId, message: AgentMessa
};
}
+function unkeyedKey(m: AgentMessage): string {
+ // Content-hash key. Same role + timestamp + content-length collapse to one
+ // entry, so reconnect replays and message_end echoes don't duplicate.
+ // Matches the pre-Phase-B dedupe approach.
+ return `${m.role}:${m.timestamp ?? 0}:${JSON.stringify(m.content).length}`;
+}
+
function pushUnkeyed(state: StreamState, message: AgentMessage): StreamState {
+ // Defensive: never let an undefined slot reach consumers (SessionView etc.
+ // map over messages and assume each is a real object).
+ if (!message || typeof message !== "object" || !("role" in message)) {
+ return state;
+ }
+ const key = unkeyedKey(message);
+ if (state.unkeyedMessages.some((m) => unkeyedKey(m) === key)) {
+ return state;
+ }
return { ...state, unkeyedMessages: [...state.unkeyedMessages, message] };
}
@@ -48,12 +64,28 @@ export function applyEvent(state: StreamState, event: AgentEvent): StreamState {
return { ...state, status: "running" };
case "agent_end": {
+ // agent_end carries the canonical full transcript at end-of-turn.
+ // Route each item: entry-id-bearing items upsert into messageMap (idempotent),
+ // bare items push into unkeyedMessages (deduped by content hash).
+ // The dedupe in `pushUnkeyed` is what prevents the user-visible "repeated
+ // answers" bug — message_end fires per-message during the turn, then
+ // agent_end fires with the full transcript at end; without dedupe we'd
+ // see every message twice.
let s: StreamState = { ...state, status: "ended" };
- for (const pair of event.messages) {
- if (pair.entry_id !== undefined) {
- s = upsertMessage(s, pair.entry_id, pair.message);
+ for (const item of event.messages) {
+ // Tolerant of two shapes: bare AgentMessage (current backend) or
+ // {entry_id?, message} (forward-compat). Detect by presence of `role`.
+ const looksBare = item && typeof item === "object" && "role" in item;
+ if (looksBare) {
+ s = pushUnkeyed(s, item as AgentMessage);
} else {
- s = pushUnkeyed(s, pair.message);
+ const pair = item as { entry_id?: EntryId; message: AgentMessage };
+ if (!pair.message) continue;
+ if (pair.entry_id !== undefined) {
+ s = upsertMessage(s, pair.entry_id, pair.message);
+ } else {
+ s = pushUnkeyed(s, pair.message);
+ }
}
}
return s;
diff --git a/harness/web/src/sessions.test.ts b/harness/web/src/sessions.test.ts
new file mode 100644
index 000000000..b2a23e9a5
--- /dev/null
+++ b/harness/web/src/sessions.test.ts
@@ -0,0 +1,77 @@
+import { describe, it, expect } from "vitest";
+import type { SessionRow } from "./types";
+import { groupByDate, truncatePath } from "./sessions";
+
+function row(id: string, updated_at_ms: number): SessionRow {
+ return { session_id: id, state: "stopped", turn_count: 1, updated_at_ms };
+}
+
+describe("groupByDate", () => {
+ // Anchor "now" at 2026-05-07 12:00 local time so the buckets are stable
+ // regardless of when the test runs.
+ const now = new Date(2026, 4, 7, 12, 0, 0).getTime();
+ const startToday = new Date(2026, 4, 7, 0, 0, 0).getTime();
+ const startYesterday = startToday - 24 * 60 * 60 * 1000;
+
+ it("buckets today by start-of-day boundary", () => {
+ const rows = [
+ row("today-noon", now),
+ row("today-midnight", startToday),
+ row("yesterday-late", startToday - 1),
+ ];
+ const result = groupByDate(rows, now);
+ expect(result.today.map((r) => r.session_id)).toEqual([
+ "today-noon",
+ "today-midnight",
+ ]);
+ expect(result.yesterday.map((r) => r.session_id)).toEqual(["yesterday-late"]);
+ expect(result.earlier).toEqual([]);
+ });
+
+ it("buckets yesterday by previous start-of-day", () => {
+ const rows = [
+ row("yesterday-noon", startYesterday + 12 * 60 * 60 * 1000),
+ row("yesterday-edge", startYesterday),
+ row("two-days-ago", startYesterday - 1),
+ ];
+ const result = groupByDate(rows, now);
+ expect(result.yesterday.map((r) => r.session_id)).toEqual([
+ "yesterday-noon",
+ "yesterday-edge",
+ ]);
+ expect(result.earlier.map((r) => r.session_id)).toEqual(["two-days-ago"]);
+ });
+
+ it("preserves input order within each bucket", () => {
+ const rows = [row("a", now), row("b", now - 1), row("c", now - 2)];
+ const result = groupByDate(rows, now);
+ expect(result.today.map((r) => r.session_id)).toEqual(["a", "b", "c"]);
+ });
+
+ it("returns empty buckets for empty input", () => {
+ expect(groupByDate([], now)).toEqual({ today: [], yesterday: [], earlier: [] });
+ });
+});
+
+describe("truncatePath", () => {
+ it("returns path unchanged when within limit", () => {
+ expect(truncatePath("/short", 12)).toBe("/short");
+ expect(truncatePath("/twelve/char", 12)).toBe("/twelve/char");
+ });
+
+ it("truncates from the left with `…/` prefix", () => {
+ expect(truncatePath("/very/long/path/to/file", 12)).toBe("…/th/to/file");
+ });
+
+ it("preserves the tail (rightmost characters)", () => {
+ const out = truncatePath("/users/yt/workspaces/personal/motia/workers", 20);
+ expect(out.startsWith("…/")).toBe(true);
+ expect(out.endsWith("workers")).toBe(true);
+ expect(out.length).toBe(20);
+ });
+
+ it("degenerates safely when max is tiny", () => {
+ expect(truncatePath("/abcdef", 2)).toBe("ef");
+ expect(truncatePath("/abcdef", 1)).toBe("f");
+ });
+});
diff --git a/harness/web/src/sessions.ts b/harness/web/src/sessions.ts
new file mode 100644
index 000000000..6d0b4c4d8
--- /dev/null
+++ b/harness/web/src/sessions.ts
@@ -0,0 +1,64 @@
+// Pure helpers for the session rail.
+//
+// `groupByDate` partitions the rail into Today / Yesterday / Earlier sections
+// keyed off `updated_at_ms`. Time of day is normalized to start-of-day in the
+// host timezone so a session updated at 23:59 yesterday isn't bucketed with
+// today's work just because Date.now() is close.
+//
+// `truncatePath` is the subtitle helper: long absolute paths get clipped from
+// the LEFT (the meaningful part is the tail, e.g. `…/web/src`), prefixed with
+// `…/` so the user knows there's more above.
+
+import type { SessionRow } from "./types";
+
+export type DateGroup = "today" | "yesterday" | "earlier";
+
+export interface GroupedSessions {
+ today: SessionRow[];
+ yesterday: SessionRow[];
+ earlier: SessionRow[];
+}
+
+const DAY_MS = 24 * 60 * 60 * 1000;
+
+function startOfDay(ms: number): number {
+ const d = new Date(ms);
+ d.setHours(0, 0, 0, 0);
+ return d.getTime();
+}
+
+/**
+ * Bucket sessions into today / yesterday / earlier based on `updated_at_ms`.
+ * Caller passes `now` for deterministic tests (defaults to `Date.now()`).
+ * Order within each bucket is preserved from input.
+ */
+export function groupByDate(
+ rows: SessionRow[],
+ now: number = Date.now(),
+): GroupedSessions {
+ const today = startOfDay(now);
+ const yesterday = today - DAY_MS;
+ const out: GroupedSessions = { today: [], yesterday: [], earlier: [] };
+ for (const row of rows) {
+ const day = startOfDay(row.updated_at_ms);
+ if (day >= today) out.today.push(row);
+ else if (day >= yesterday) out.yesterday.push(row);
+ else out.earlier.push(row);
+ }
+ return out;
+}
+
+/**
+ * Truncate from the LEFT so the tail (most informative segment) survives.
+ * Returns `path` unchanged if its length already fits within `max`.
+ * Otherwise returns `…/` where `` fits within `max - 2` chars.
+ *
+ * `max` is the total target length including the `…/` prefix.
+ */
+export function truncatePath(path: string, max: number): string {
+ if (path.length <= max) return path;
+ if (max <= 2) return path.slice(-Math.max(1, max));
+ const tailLen = max - 2; // reserve room for `…/`
+ const tail = path.slice(-tailLen);
+ return `…/${tail}`;
+}
diff --git a/harness/web/src/styles.css b/harness/web/src/styles.css
index f07f51654..fd8fbafe7 100644
--- a/harness/web/src/styles.css
+++ b/harness/web/src/styles.css
@@ -130,6 +130,28 @@ code,
color: var(--ink-3);
}
+.app-head-cwd {
+ margin-left: var(--space-3);
+ min-width: 240px;
+ padding: 4px 8px;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--ink-1);
+ background: transparent;
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+}
+
+.app-head-cwd:focus {
+ outline: none;
+ border-color: var(--accent);
+}
+
+.app-head-cwd:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
.app-body {
display: grid;
grid-template-columns: var(--rail-w) 1fr;
@@ -295,8 +317,14 @@ code,
/* ─── main ───────────────────────────────────────────────────────────────── */
.main {
- display: grid;
- grid-template-rows: auto 1fr auto auto;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+/* SessionView is the scrollable conversation area; everything else is auto-sized. */
+.main > .view {
+ flex: 1 1 auto;
min-height: 0;
}
@@ -790,6 +818,91 @@ code,
background: var(--paper-2);
}
+.composer-stack {
+ position: relative;
+ display: grid;
+}
+
+.composer-popover {
+ position: absolute;
+ bottom: calc(100% + var(--space-2));
+ left: 0;
+ right: 0;
+ max-height: 280px;
+ overflow-y: auto;
+ background: var(--paper);
+ border: 1px solid var(--rule-strong);
+ box-shadow: 0 6px 16px -8px oklch(0.2 0.012 var(--hue) / 0.18);
+ z-index: 10;
+}
+
+.composer-popover-head {
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.06em;
+ text-transform: lowercase;
+ color: var(--ink-3);
+ padding: 6px var(--space-3);
+ border-bottom: 1px solid var(--rule);
+ background: var(--paper-2);
+}
+
+.composer-popover-empty {
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--ink-faint);
+ padding: var(--space-3);
+}
+
+.composer-popover-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
+
+.composer-popover-row {
+ display: grid;
+ grid-template-columns: minmax(0, auto) 1fr;
+ gap: var(--space-3);
+ align-items: baseline;
+ padding: 6px var(--space-3);
+ cursor: pointer;
+ border-bottom: 1px solid var(--rule);
+}
+
+.composer-popover-row:last-child {
+ border-bottom: 0;
+}
+
+.composer-popover-row[data-active="true"] {
+ background: var(--accent-wash);
+}
+
+.composer-popover-row:hover {
+ background: var(--paper-3);
+}
+
+.composer-popover-label {
+ font-family: var(--mono);
+ font-size: 13px;
+ color: var(--ink);
+}
+
+.composer-popover-desc {
+ font-family: var(--body);
+ font-size: 12px;
+ color: var(--ink-3);
+ text-align: right;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.composer-popover-more .composer-popover-label {
+ color: var(--accent-ink);
+ font-style: italic;
+}
+
.composer-input {
width: 100%;
font-family: var(--body);
@@ -1259,3 +1372,719 @@ code,
flex: 1;
white-space: pre;
}
+
+/* ─── function palette (Cmd-J) ──────────────────────────────────────────── */
+
+.palette-backdrop {
+ position: fixed;
+ inset: 0;
+ background: oklch(0.22 0.012 var(--hue) / 0.45);
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+ padding-top: 10vh;
+ z-index: 1000;
+ animation: palette-fade 120ms ease-out;
+}
+
+.palette-backdrop[data-reduced-motion="true"] {
+ animation: none;
+}
+
+@keyframes palette-fade {
+ from { opacity: 0; }
+ to { opacity: 1; }
+}
+
+.palette {
+ width: min(720px, 92vw);
+ max-height: 76vh;
+ background: var(--paper);
+ border: 1px solid var(--rule-strong);
+ border-radius: 8px;
+ box-shadow: 0 24px 60px oklch(0.22 0.012 var(--hue) / 0.25);
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.palette-list-view {
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+.palette-tabs {
+ display: flex;
+ gap: var(--space-1);
+ padding: var(--space-3) var(--space-4) 0;
+}
+
+.palette-tab {
+ background: transparent;
+ border: 0;
+ padding: var(--space-2) var(--space-3);
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--ink-3);
+ cursor: pointer;
+ border-bottom: 2px solid transparent;
+}
+
+.palette-tab[data-active="true"] {
+ color: var(--ink);
+ border-bottom-color: var(--accent);
+}
+
+.palette-filter {
+ margin: var(--space-3) var(--space-4);
+ padding: var(--space-3);
+ font-family: var(--mono);
+ font-size: 13px;
+ background: var(--paper-2);
+ border: 1px solid var(--rule);
+ border-radius: 6px;
+ outline: none;
+}
+
+.palette-filter:focus {
+ border-color: var(--accent);
+}
+
+.palette-error {
+ margin: 0 var(--space-4) var(--space-3);
+ padding: var(--space-2) var(--space-3);
+ background: oklch(0.95 0.04 25);
+ border-left: 3px solid var(--red);
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--ink);
+}
+
+.palette-loading,
+.palette-empty {
+ margin: var(--space-3) var(--space-4);
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--ink-3);
+}
+
+.palette-options {
+ list-style: none;
+ margin: 0;
+ padding: 0 0 var(--space-2);
+ overflow: auto;
+ flex: 1;
+ min-height: 0;
+}
+
+.palette-option {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ grid-template-rows: auto auto;
+ column-gap: var(--space-3);
+ align-items: baseline;
+ padding: var(--space-2) var(--space-4);
+ cursor: pointer;
+ border-left: 2px solid transparent;
+}
+
+.palette-option[data-selected="true"] {
+ background: var(--accent-wash);
+ border-left-color: var(--accent);
+}
+
+.palette-option:hover {
+ background: var(--paper-2);
+}
+
+.palette-option-id {
+ font-family: var(--mono);
+ font-size: 13px;
+ color: var(--ink);
+}
+
+.palette-option-worker {
+ grid-column: 2;
+ grid-row: 1;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--ink-3);
+}
+
+.palette-option-desc {
+ grid-column: 1 / -1;
+ grid-row: 2;
+ font-size: 12px;
+ color: var(--ink-2);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.palette-chip {
+ display: inline-block;
+ padding: 2px 6px;
+ font-family: var(--mono);
+ font-size: 10px;
+ border-radius: 3px;
+ margin-left: var(--space-2);
+ vertical-align: middle;
+}
+
+.palette-chip-sensitive {
+ background: oklch(0.92 0.08 90);
+ color: oklch(0.34 0.12 60);
+ border: 1px solid oklch(0.78 0.12 75);
+}
+
+/* ---- drill-in ---- */
+
+.palette-drill {
+ display: flex;
+ flex-direction: column;
+ padding: var(--space-3) var(--space-4) var(--space-4);
+ overflow: auto;
+}
+
+.palette-drill-head {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ padding-bottom: var(--space-2);
+ border-bottom: 1px solid var(--rule);
+}
+
+.palette-back {
+ background: transparent;
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ padding: 4px 8px;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--ink-2);
+ cursor: pointer;
+}
+
+.palette-back:hover {
+ background: var(--paper-2);
+}
+
+.palette-drill-id {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-width: 0;
+}
+
+.palette-drill-fn {
+ font-family: var(--mono);
+ font-size: 14px;
+ color: var(--ink);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.palette-drill-worker {
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--ink-3);
+}
+
+.palette-drill-desc {
+ margin: var(--space-3) 0 var(--space-2);
+ font-size: 13px;
+ color: var(--ink-2);
+}
+
+.palette-drill-label {
+ margin-top: var(--space-3);
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--ink-3);
+}
+
+.palette-drill-textarea {
+ margin-top: var(--space-2);
+ padding: var(--space-3);
+ font-family: var(--mono);
+ font-size: 12px;
+ background: var(--paper-2);
+ border: 1px solid var(--rule);
+ border-radius: 6px;
+ resize: vertical;
+ min-height: 140px;
+ outline: none;
+}
+
+.palette-drill-textarea:focus {
+ border-color: var(--accent);
+}
+
+.palette-drill-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+ margin-top: var(--space-3);
+ flex-wrap: wrap;
+}
+
+.palette-send {
+ background: var(--accent);
+ color: var(--paper);
+ border: 0;
+ border-radius: 4px;
+ padding: 6px 14px;
+ font-family: var(--mono);
+ font-size: 12px;
+ cursor: pointer;
+}
+
+.palette-send:hover:not(:disabled) {
+ filter: brightness(0.95);
+}
+
+.palette-send:disabled {
+ opacity: 0.6;
+ cursor: progress;
+}
+
+.palette-send[data-confirming="true"] {
+ background: oklch(0.78 0.12 75);
+ color: oklch(0.22 0.012 var(--hue));
+}
+
+.palette-copy {
+ background: transparent;
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ padding: 6px 12px;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--ink-2);
+ cursor: pointer;
+}
+
+.palette-copy:hover {
+ background: var(--paper-2);
+}
+
+.palette-copy-hint {
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--ink-3);
+}
+
+.palette-response {
+ margin: var(--space-3) 0 0;
+ padding: var(--space-3);
+ background: var(--paper-2);
+ border: 1px solid var(--rule);
+ border-radius: 6px;
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--ink);
+ overflow: auto;
+ max-height: 240px;
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .palette-backdrop {
+ animation: none;
+ }
+}
+
+/* ─── session list: groups + subtitle ────────────────────────────────────── */
+
+.session-groups {
+ display: grid;
+ gap: var(--space-4);
+}
+
+.session-group {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.session-group-h {
+ font-family: var(--mono);
+ font-size: 10.5px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-faint);
+ margin: 0;
+ padding: 0 var(--space-3);
+}
+
+.session-subtitle {
+ display: block;
+ margin-top: 4px;
+ font-family: var(--mono);
+ font-size: 10.5px;
+ color: var(--ink-3);
+ letter-spacing: 0;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+/* ─── per-message actions ────────────────────────────────────────────────── */
+
+.msg-actions {
+ display: flex;
+ gap: var(--space-1);
+ margin-top: var(--space-2);
+ opacity: 0;
+ transition: opacity 120ms ease;
+}
+
+.msg:hover .msg-actions,
+.msg:focus-within .msg-actions {
+ opacity: 1;
+}
+
+.msg-action {
+ background: transparent;
+ border: 1px solid var(--rule);
+ color: var(--ink-3);
+ font-family: var(--mono);
+ font-size: 11px;
+ padding: 2px 8px;
+ border-radius: 3px;
+ cursor: pointer;
+ transition:
+ background 120ms ease,
+ color 120ms ease;
+}
+
+.msg-action:hover:not(:disabled) {
+ background: var(--accent-wash);
+ color: var(--accent-ink);
+}
+
+.msg-action:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+/* ─── status strip (header) + foot chips ─────────────────────────────────── */
+
+.app-head-right {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.status-strip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.status-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 4px 10px;
+ border: 1px solid var(--rule);
+ border-radius: 999px;
+ font-family: var(--mono);
+ font-size: 11px;
+ letter-spacing: 0.04em;
+ text-transform: lowercase;
+ background: transparent;
+ color: var(--ink-3);
+ cursor: pointer;
+ transition:
+ background 120ms ease,
+ color 120ms ease,
+ border-color 120ms ease;
+}
+
+.status-chip:hover {
+ background: var(--accent-wash);
+ color: var(--accent-ink);
+}
+
+.status-chip[data-tone="alert"] {
+ border-color: var(--accent);
+ color: var(--accent);
+}
+
+.status-chip[data-tone="muted"] {
+ color: var(--ink-3);
+}
+
+.status-chip[data-disconnected="true"] {
+ opacity: 0.55;
+ filter: grayscale(0.6);
+}
+
+.status-chip-label {
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-size: 10px;
+ color: inherit;
+}
+
+.status-chip-value {
+ font-weight: 600;
+ letter-spacing: 0;
+}
+
+.status-chip-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: var(--ink-faint);
+ display: inline-block;
+}
+
+@keyframes status-chip-pulse {
+ 0%,
+ 100% {
+ box-shadow: 0 0 0 0 transparent;
+ }
+ 50% {
+ box-shadow: 0 0 0 3px var(--accent-wash);
+ }
+}
+
+.status-chip[data-pulse="true"] {
+ animation: status-chip-pulse 1.4s ease-in-out infinite;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .status-chip[data-pulse="true"] {
+ animation: none;
+ }
+}
+
+.app-foot-spacer {
+ flex: 1;
+}
+
+.foot-chip {
+ display: inline-flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding: 2px 8px;
+ border: 1px solid var(--rule);
+ border-radius: 4px;
+ font-family: var(--mono);
+ font-size: 11px;
+ color: var(--ink-3);
+}
+
+.foot-chip[data-tone="ok"] {
+ color: var(--ink-1);
+}
+
+.foot-chip[data-tone="warn"] {
+ color: var(--accent);
+ border-color: var(--accent);
+}
+
+.foot-chip[data-disconnected="true"] {
+ opacity: 0.55;
+ filter: grayscale(0.6);
+}
+
+.foot-chip-label {
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-size: 10px;
+}
+
+.foot-chip-value {
+ font-weight: 600;
+}
+
+.foot-chip-dot {
+ width: 5px;
+ height: 5px;
+ border-radius: 50%;
+ background: var(--ink-faint);
+ display: inline-block;
+}
+
+/* ─── status tab ─────────────────────────────────────────────────────────── */
+
+.status-tab {
+ display: grid;
+ gap: var(--space-4);
+ padding: var(--space-4) var(--space-6);
+ min-height: 0;
+}
+
+.status-banner {
+ margin: 0;
+ padding: var(--space-3);
+ border: 1px solid var(--accent);
+ border-radius: 4px;
+ font-family: var(--mono);
+ font-size: 12px;
+ color: var(--accent-ink);
+ background: var(--accent-wash);
+}
+
+.status-grid {
+ display: grid;
+ gap: var(--space-4);
+ grid-template-columns: minmax(280px, 1fr) minmax(280px, 1fr);
+ grid-template-areas:
+ "workers cost"
+ "events events";
+}
+
+.status-card {
+ display: grid;
+ gap: var(--space-3);
+ padding: var(--space-3);
+ border: 1px solid var(--rule);
+ border-radius: 6px;
+ background: var(--paper);
+ min-height: 0;
+}
+
+.status-card:nth-of-type(1) {
+ grid-area: workers;
+}
+.status-card:nth-of-type(2) {
+ grid-area: cost;
+}
+.status-card.events-card {
+ grid-area: events;
+}
+
+.cost-sparkline {
+ margin-left: auto;
+ display: block;
+}
+
+.workers-table {
+ width: 100%;
+ border-collapse: collapse;
+ font-family: var(--mono);
+ font-size: 12px;
+}
+
+.workers-table th,
+.workers-table td {
+ text-align: left;
+ padding: 4px 8px;
+ border-bottom: 1px solid var(--rule);
+}
+
+.workers-table th {
+ font-weight: 600;
+ font-size: 10.5px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--ink-3);
+}
+
+.worker-name {
+ color: var(--ink-1);
+}
+
+.worker-status {
+ text-transform: lowercase;
+ color: var(--ink-3);
+}
+
+.worker-status[data-status="up"] {
+ color: var(--ink-1);
+}
+
+.worker-status[data-status="down"],
+.worker-status[data-status="stale"] {
+ color: var(--accent);
+}
+
+.events-controls {
+ display: flex;
+ gap: var(--space-1);
+ margin-left: auto;
+ flex-wrap: wrap;
+}
+
+.events-filter,
+.events-control {
+ background: transparent;
+ border: 1px solid var(--rule);
+ color: var(--ink-3);
+ font-family: var(--mono);
+ font-size: 10.5px;
+ padding: 2px 6px;
+ border-radius: 3px;
+ cursor: pointer;
+ letter-spacing: 0.04em;
+ text-transform: lowercase;
+}
+
+.events-filter[data-active="true"] {
+ background: var(--accent-wash);
+ color: var(--accent-ink);
+ border-color: var(--accent);
+}
+
+.events-filter:hover,
+.events-control:hover {
+ background: var(--accent-wash);
+ color: var(--accent-ink);
+}
+
+.events-feed {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ max-height: 360px;
+ overflow: auto;
+ font-family: var(--mono);
+ font-size: 12px;
+}
+
+.events-row {
+ display: grid;
+ grid-template-columns: auto auto 1fr;
+ gap: var(--space-2);
+ padding: 3px var(--space-2);
+ border-bottom: 1px solid var(--rule);
+ align-items: baseline;
+}
+
+.events-time {
+ color: var(--ink-faint);
+ font-size: 11px;
+}
+
+.events-kind {
+ color: var(--ink-3);
+ font-size: 10.5px;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ min-width: 64px;
+}
+
+.events-row[data-kind="approval"] .events-kind {
+ color: var(--accent);
+}
+
+.events-summary {
+ color: var(--ink-1);
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.budget-list.compact {
+ display: grid;
+ gap: var(--space-2);
+ list-style: none;
+ margin: 0;
+ padding: 0;
+}
diff --git a/harness/web/src/types.ts b/harness/web/src/types.ts
index b992a4cc0..2608bf49f 100644
--- a/harness/web/src/types.ts
+++ b/harness/web/src/types.ts
@@ -42,6 +42,18 @@ export interface SessionRow {
state: string;
turn_count: number;
updated_at_ms: number;
+ /** Working directory associated with the session, if any. */
+ cwd?: string | null;
+ /** First text snippet of the last message, used as a session preview. */
+ last_message_summary?: string | null;
+}
+
+// Per-session working directory. Advisory-only — surfaced in the system prompt
+// so the agent prefers paths under cwd. Path scoping/enforcement belongs to
+// `policy-denylist`, not the UI.
+export interface Workspace {
+ cwd: string;
+ set_at: number;
}
export interface HarnessStatus {
@@ -145,7 +157,11 @@ export type EntryId = string;
export type AgentEvent =
| { type: "agent_start" }
- | { type: "agent_end"; messages: { entry_id?: EntryId; message: AgentMessage }[] }
+ // Backend (turn-orchestrator/crates/harness-types/src/agent_event.rs) emits
+ // bare AgentMessage[]. The reducer's agent_end handler also tolerates the
+ // wrapped {entry_id?, message}[] shape for forward-compat with a future
+ // backend that threads entry_ids through.
+ | { type: "agent_end"; messages: (AgentMessage | { entry_id?: EntryId; message: AgentMessage })[] }
| { type: "turn_start" }
| { type: "turn_end"; message: AgentMessage; tool_results: unknown[]; entry_id?: EntryId }
| { type: "message_start"; message: AgentMessage; entry_id?: EntryId }
diff --git a/harness/web/src/useAgentStream.ts b/harness/web/src/useAgentStream.ts
index 8142c59e0..116f2ebe5 100644
--- a/harness/web/src/useAgentStream.ts
+++ b/harness/web/src/useAgentStream.ts
@@ -1,4 +1,5 @@
import { useEffect, useReducer } from "react";
+import { getIiiClient } from "./iii-client";
import { applyEvent } from "./reducer";
import {
INITIAL_STREAM_STATE,
@@ -13,30 +14,101 @@ function streamReducer(state: StreamState, action: Action): StreamState {
return applyEvent(state, action.event);
}
+interface SessionEventEnvelope {
+ session_id?: string;
+ event?: AgentEvent;
+ // For backwards-compat with payloads that put the AgentEvent fields at the
+ // top level alongside session_id.
+ type?: AgentEvent extends { type: infer T } ? T : string;
+ [k: string]: unknown;
+}
+
+function extractEvent(
+ payload: SessionEventEnvelope,
+ sessionId: string,
+): AgentEvent | null {
+ if (payload.session_id !== sessionId) return null;
+ if (payload.event && typeof payload.event === "object") {
+ return payload.event as AgentEvent;
+ }
+ // Fall through: treat the rest of the envelope (minus session_id) as the
+ // AgentEvent itself. The harness fanout in this step wraps frames as
+ // `{session_id, event}` — but allow flat shapes for forward-compat.
+ if (typeof payload.type === "string") {
+ const { session_id: _drop, ...rest } = payload;
+ void _drop;
+ return rest as unknown as AgentEvent;
+ }
+ return null;
+}
+
+/**
+ * Subscribe to live agent::events for `sessionId` over the iii WebSocket.
+ *
+ * Wire protocol:
+ * - Browser registers `ui::session::event::` once at startup
+ * (handled by useEffect below; the harness fanout calls our handler with
+ * `{session_id, event}` envelopes and we dispatch into the reducer when
+ * `session_id` matches the active session).
+ * - Browser calls `ui::subscribe { browser_id, session_id }` on session
+ * change so the fanout knows which sessions to forward.
+ */
export function useAgentStream(sessionId: string | null): StreamState {
const [state, dispatch] = useReducer(streamReducer, INITIAL_STREAM_STATE);
useEffect(() => {
- if (!sessionId) {
- dispatch({ kind: "reset" });
- return;
- }
dispatch({ kind: "reset" });
- const url = `/bridge/events?session_id=${encodeURIComponent(sessionId)}`;
- const es = new EventSource(url);
- es.onmessage = (e) => {
+ if (!sessionId) return;
+
+ let cancelled = false;
+ let off: (() => void) | undefined;
+ let subscribed = false;
+ let browserId: string | null = null;
+
+ void (async () => {
try {
- const data = JSON.parse(e.data) as AgentEvent;
- dispatch({ kind: "event", event: data });
+ const client = await getIiiClient();
+ if (cancelled) return;
+ browserId = client.browserId;
+
+ off = client.on(
+ "ui::session::event",
+ (payload) => {
+ const ev = extractEvent(payload, sessionId);
+ if (!ev) return;
+ dispatch({ kind: "event", event: ev });
+ },
+ );
+
+ await client.call("ui::subscribe", {
+ browser_id: browserId,
+ session_id: sessionId,
+ });
+ subscribed = true;
} catch (err) {
- console.warn("bad SSE frame", err);
+ // Connection bootstrap failure: surfaced via useConnection's status
+ // pill. Don't throw here — hooks must not crash the tree.
+ console.warn("[useAgentStream] subscribe failed", err);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ off?.();
+ if (subscribed && browserId) {
+ // Best-effort: tell the fanout to drop the subscription. Failures
+ // here are recoverable on the next tick (the fanout treats stale
+ // entries as harmless).
+ void getIiiClient().then((client) =>
+ client
+ .call("ui::unsubscribe", {
+ browser_id: browserId,
+ session_id: sessionId,
+ })
+ .catch(() => {}),
+ );
}
};
- es.onerror = () => {
- // EventSource auto-reconnects; nothing to do unless we want to surface a
- // status pill. Leave as-is for v1.
- };
- return () => es.close();
}, [sessionId]);
return state;
diff --git a/harness/web/src/useCommandMenu.test.ts b/harness/web/src/useCommandMenu.test.ts
new file mode 100644
index 000000000..916ffa506
--- /dev/null
+++ b/harness/web/src/useCommandMenu.test.ts
@@ -0,0 +1,177 @@
+import { describe, it, expect } from "vitest";
+import {
+ INITIAL_MENU_STATE,
+ reduce,
+ shouldOpenSlash,
+ shouldOpenAt,
+ extractQuery,
+ type MenuItem,
+} from "./useCommandMenu";
+
+const item = (id: string, label = id): MenuItem => ({
+ kind: "builtin",
+ id,
+ label,
+});
+
+describe("useCommandMenu reducer", () => {
+ it("INITIAL_MENU_STATE is mode=idle with empty items", () => {
+ expect(INITIAL_MENU_STATE.mode).toBe("idle");
+ expect(INITIAL_MENU_STATE.items).toEqual([]);
+ expect(INITIAL_MENU_STATE.selectedIndex).toBe(0);
+ expect(INITIAL_MENU_STATE.historyIndex).toBeNull();
+ });
+
+ it("open-slash sets mode=slash, items, selectedIndex=0", () => {
+ const items = [item("/new"), item("/cwd")];
+ const s = reduce(INITIAL_MENU_STATE, { kind: "open-slash", items });
+ expect(s.mode).toBe("slash");
+ expect(s.items).toEqual(items);
+ expect(s.selectedIndex).toBe(0);
+ });
+
+ it("open-at sets mode=at and resets state", () => {
+ const items: MenuItem[] = [{ kind: "file", id: "/tmp/a", label: "a" }];
+ const s = reduce(
+ { ...INITIAL_MENU_STATE, selectedIndex: 5 },
+ { kind: "open-at", items },
+ );
+ expect(s.mode).toBe("at");
+ expect(s.selectedIndex).toBe(0);
+ });
+
+ it("move clamps to bounds (no wrap)", () => {
+ const items = [item("a"), item("b"), item("c")];
+ let s = reduce(INITIAL_MENU_STATE, { kind: "open-slash", items });
+ s = reduce(s, { kind: "move", delta: -1 });
+ expect(s.selectedIndex).toBe(0); // clamps at top
+ s = reduce(s, { kind: "move", delta: 1 });
+ s = reduce(s, { kind: "move", delta: 1 });
+ s = reduce(s, { kind: "move", delta: 1 });
+ expect(s.selectedIndex).toBe(2); // clamps at bottom
+ });
+
+ it("move on empty items keeps selectedIndex=0", () => {
+ let s = reduce(INITIAL_MENU_STATE, { kind: "open-slash", items: [] });
+ s = reduce(s, { kind: "move", delta: 1 });
+ expect(s.selectedIndex).toBe(0);
+ });
+
+ it("filter updates query + items and resets selectedIndex to 0", () => {
+ const start = [item("a"), item("b"), item("c")];
+ let s = reduce(INITIAL_MENU_STATE, { kind: "open-slash", items: start });
+ s = reduce(s, { kind: "move", delta: 1 });
+ expect(s.selectedIndex).toBe(1);
+ s = reduce(s, { kind: "filter", query: "x", items: [item("z")] });
+ expect(s.query).toBe("x");
+ expect(s.items).toEqual([item("z")]);
+ expect(s.selectedIndex).toBe(0);
+ });
+
+ it("set-items preserves selectedIndex when possible, clamps when items shrink", () => {
+ let s = reduce(INITIAL_MENU_STATE, {
+ kind: "open-slash",
+ items: [item("a"), item("b"), item("c")],
+ });
+ s = reduce(s, { kind: "move", delta: 1 });
+ s = reduce(s, { kind: "move", delta: 1 });
+ expect(s.selectedIndex).toBe(2);
+ s = reduce(s, { kind: "set-items", items: [item("a")] });
+ expect(s.selectedIndex).toBe(0);
+ });
+
+ it("open-history captures historyDraft and starts at index 0", () => {
+ const s = reduce(INITIAL_MENU_STATE, {
+ kind: "open-history",
+ draft: "draft text",
+ });
+ expect(s.mode).toBe("history");
+ expect(s.historyDraft).toBe("draft text");
+ expect(s.historyIndex).toBe(0);
+ });
+
+ it("history-step clamps to history bounds", () => {
+ let s = reduce(INITIAL_MENU_STATE, { kind: "open-history", draft: "" });
+ // historyLen=3 → indices [0,1,2]
+ s = reduce(s, { kind: "history-step", delta: 1, historyLen: 3 });
+ expect(s.historyIndex).toBe(1);
+ s = reduce(s, { kind: "history-step", delta: 1, historyLen: 3 });
+ s = reduce(s, { kind: "history-step", delta: 1, historyLen: 3 });
+ expect(s.historyIndex).toBe(2); // clamps
+ s = reduce(s, { kind: "history-step", delta: -1, historyLen: 3 });
+ expect(s.historyIndex).toBe(1);
+ });
+
+ it("history-step on empty history is a no-op", () => {
+ let s = reduce(INITIAL_MENU_STATE, { kind: "open-history", draft: "x" });
+ s = reduce(s, { kind: "history-step", delta: 1, historyLen: 0 });
+ expect(s.historyIndex).toBe(0);
+ });
+
+ it("close resets to INITIAL_MENU_STATE", () => {
+ let s = reduce(INITIAL_MENU_STATE, {
+ kind: "open-slash",
+ items: [item("a"), item("b")],
+ });
+ s = reduce(s, { kind: "move", delta: 1 });
+ s = reduce(s, { kind: "close" });
+ expect(s).toEqual(INITIAL_MENU_STATE);
+ });
+
+ it("unknown action returns state unchanged", () => {
+ const fake = { kind: "absolutely_not_a_real_action" } as unknown as Parameters<
+ typeof reduce
+ >[1];
+ expect(reduce(INITIAL_MENU_STATE, fake)).toBe(INITIAL_MENU_STATE);
+ });
+});
+
+describe("trigger detection", () => {
+ it("shouldOpenSlash: true on empty input + /", () => {
+ expect(shouldOpenSlash("/", 1)).toBe(true);
+ });
+
+ it("shouldOpenSlash: true after newline", () => {
+ expect(shouldOpenSlash("hi\n/", 4)).toBe(true);
+ });
+
+ it("shouldOpenSlash: false mid-line", () => {
+ expect(shouldOpenSlash("hi /", 4)).toBe(false);
+ });
+
+ it("shouldOpenAt: true on empty + @", () => {
+ expect(shouldOpenAt("@", 1)).toBe(true);
+ });
+
+ it("shouldOpenAt: true after space", () => {
+ expect(shouldOpenAt("hi @", 4)).toBe(true);
+ });
+
+ it("shouldOpenAt: false when glued to a word", () => {
+ expect(shouldOpenAt("hi@", 3)).toBe(false);
+ });
+});
+
+describe("extractQuery", () => {
+ it("returns the chars after the trigger", () => {
+ expect(extractQuery("/cw", 3, "/")).toBe("cw");
+ });
+
+ it("returns empty string immediately after trigger", () => {
+ expect(extractQuery("/", 1, "/")).toBe("");
+ });
+
+ it("returns null when caret moved before any trigger", () => {
+ expect(extractQuery("hello", 5, "/")).toBeNull();
+ });
+
+ it("at-mention reads until the trigger, no whitespace allowed inside", () => {
+ expect(extractQuery("hi @foo", 7, "@")).toBe("foo");
+ expect(extractQuery("hi @foo bar", 11, "@")).toBeNull();
+ });
+
+ it("slash inside an at-mention does not register as slash mode", () => {
+ // "@/tmp" — the slash sits inside a word, not at line-start
+ expect(extractQuery("@/tmp", 5, "/")).toBeNull();
+ });
+});
diff --git a/harness/web/src/useCommandMenu.ts b/harness/web/src/useCommandMenu.ts
new file mode 100644
index 000000000..95360b427
--- /dev/null
+++ b/harness/web/src/useCommandMenu.ts
@@ -0,0 +1,189 @@
+// Headless state machine for the Composer popover.
+//
+// Three modes share one popover:
+// - "slash" — built-ins + skills, triggered by `/` at line-start
+// - "at" — file browser under cwd, triggered by `@` at word-boundary
+// - "history" — walk back through prior user messages of the active session
+//
+// Selection lives in this reducer. Item *fetching* lives in the Composer
+// (it owns IO). The reducer is pure; tests don't need IO.
+
+import { useReducer } from "react";
+
+export type MenuMode = "idle" | "slash" | "at" | "history";
+
+export interface MenuItem {
+ /** Coarse provenance — drives icons and how Enter is dispatched. */
+ kind: "builtin" | "skill" | "file";
+ /** Stable id within the mode (e.g. `/cwd`, `iii://skills/xyz`, absolute path). */
+ id: string;
+ /** Display text shown as the row's primary label. */
+ label: string;
+ /** Optional secondary text under the label. */
+ description?: string;
+ /** Mode-specific payload (skill uri, fs entry kind, etc.). */
+ meta?: unknown;
+}
+
+export interface CommandMenuState {
+ mode: MenuMode;
+ /** Text after the trigger char. Empty for history mode. */
+ query: string;
+ /** Filtered + ranked items the popover should render. */
+ items: MenuItem[];
+ /** Index into `items`. Always 0..max(items.length-1, 0). */
+ selectedIndex: number;
+ /** History walk pointer. null = drafting (not in history mode), 0+ walks back. */
+ historyIndex: number | null;
+ /** Original draft text preserved when history walk starts. Restored on Esc. */
+ historyDraft: string;
+}
+
+export const INITIAL_MENU_STATE: CommandMenuState = {
+ mode: "idle",
+ query: "",
+ items: [],
+ selectedIndex: 0,
+ historyIndex: null,
+ historyDraft: "",
+};
+
+export type Action =
+ | { kind: "open-slash"; items: MenuItem[] }
+ | { kind: "open-at"; items: MenuItem[] }
+ | { kind: "open-history"; draft: string }
+ | { kind: "set-items"; items: MenuItem[] }
+ | { kind: "filter"; query: string; items: MenuItem[] }
+ | { kind: "move"; delta: 1 | -1 }
+ | { kind: "history-step"; delta: 1 | -1; historyLen: number }
+ | { kind: "close" };
+
+function clampIndex(items: MenuItem[], idx: number): number {
+ if (items.length === 0) return 0;
+ if (idx < 0) return 0;
+ if (idx >= items.length) return items.length - 1;
+ return idx;
+}
+
+export function reduce(state: CommandMenuState, action: Action): CommandMenuState {
+ switch (action.kind) {
+ case "open-slash":
+ return {
+ ...INITIAL_MENU_STATE,
+ mode: "slash",
+ items: action.items,
+ selectedIndex: 0,
+ };
+ case "open-at":
+ return {
+ ...INITIAL_MENU_STATE,
+ mode: "at",
+ items: action.items,
+ selectedIndex: 0,
+ };
+ case "open-history":
+ return {
+ ...INITIAL_MENU_STATE,
+ mode: "history",
+ historyDraft: action.draft,
+ historyIndex: 0,
+ };
+ case "set-items":
+ // Caller refreshed items without changing the query. Preserve selection
+ // when possible; clamp if items shrunk.
+ return {
+ ...state,
+ items: action.items,
+ selectedIndex: clampIndex(action.items, state.selectedIndex),
+ };
+ case "filter":
+ // Query changed; reset selection to the top because ranking shifted.
+ return {
+ ...state,
+ query: action.query,
+ items: action.items,
+ selectedIndex: 0,
+ };
+ case "move": {
+ const next = clampIndex(state.items, state.selectedIndex + action.delta);
+ return next === state.selectedIndex ? state : { ...state, selectedIndex: next };
+ }
+ case "history-step": {
+ // delta=+1 walks further back, delta=-1 walks toward the draft.
+ // Caps at [0, historyLen-1]. The Composer turns historyIndex back into
+ // the draft text when stepping past 0 with delta=-1 (handled there).
+ const cur = state.historyIndex ?? 0;
+ const proposed = cur + action.delta;
+ if (action.historyLen === 0) return state;
+ const clamped = Math.max(0, Math.min(action.historyLen - 1, proposed));
+ if (clamped === cur) return state;
+ return { ...state, historyIndex: clamped };
+ }
+ case "close":
+ return INITIAL_MENU_STATE;
+ default:
+ return state;
+ }
+}
+
+/** React hook wrapper around the reducer. */
+export function useCommandMenu(): [CommandMenuState, React.Dispatch] {
+ return useReducer(reduce, INITIAL_MENU_STATE);
+}
+
+// ─── Trigger helpers ────────────────────────────────────────────────────────
+// Pure functions the Composer uses to decide whether a keystroke should open
+// a mode. Kept here so they're easy to unit-test alongside the reducer.
+
+/**
+ * Slash triggers when `/` lands at the start of a line — i.e. the textarea is
+ * empty OR the char immediately before the caret is `\n`. The trigger char
+ * itself is at `caret-1` (caller has already inserted it).
+ */
+export function shouldOpenSlash(text: string, caret: number): boolean {
+ if (caret <= 0) return false;
+ if (text[caret - 1] !== "/") return false;
+ if (caret === 1) return true;
+ return text[caret - 2] === "\n";
+}
+
+/**
+ * At-mention triggers when `@` follows whitespace, line-start, or empty input.
+ * The trigger char sits at `caret-1`.
+ */
+export function shouldOpenAt(text: string, caret: number): boolean {
+ if (caret <= 0) return false;
+ if (text[caret - 1] !== "@") return false;
+ if (caret === 1) return true;
+ const prev = text[caret - 2];
+ return prev === " " || prev === "\t" || prev === "\n";
+}
+
+/**
+ * Extract the query the user has typed after the active trigger char. Returns
+ * null if the caret has moved before the trigger (mode should close).
+ */
+export function extractQuery(
+ text: string,
+ caret: number,
+ trigger: "/" | "@",
+): string | null {
+ // Walk back from the caret to find the most recent trigger char that still
+ // qualifies (line-start for slash, word-boundary for at). We only care about
+ // the contiguous run from that trigger to the caret with no whitespace.
+ for (let i = caret - 1; i >= 0; i--) {
+ const c = text[i];
+ if (c === "\n") return null;
+ if (trigger === "@" && (c === " " || c === "\t")) return null;
+ if (c === trigger) {
+ // Confirm boundary
+ if (trigger === "/" && i !== 0 && text[i - 1] !== "\n") return null;
+ if (trigger === "@" && i !== 0) {
+ const prev = text[i - 1];
+ if (prev !== " " && prev !== "\t" && prev !== "\n") return null;
+ }
+ return text.slice(i + 1, caret);
+ }
+ }
+ return null;
+}
diff --git a/harness/web/src/useConnection.test.ts b/harness/web/src/useConnection.test.ts
new file mode 100644
index 000000000..0b6e8962d
--- /dev/null
+++ b/harness/web/src/useConnection.test.ts
@@ -0,0 +1,91 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+import type { IIIConnectionState } from "iii-browser-sdk";
+
+import {
+ __resetIiiClientForTests,
+ __setIiiClientDepsForTests,
+ disposeIiiClient,
+} from "./iii-client";
+import { useConnection } from "./useConnection";
+
+interface FakeSdk {
+ trigger: ReturnType;
+ registerFunction: ReturnType;
+ addConnectionStateListener: ReturnType;
+ shutdown: ReturnType;
+}
+
+describe("useConnection", () => {
+ let sdk: FakeSdk;
+ let connectionListeners: Array<(s: IIIConnectionState) => void>;
+ let unsubMock: ReturnType;
+
+ beforeEach(() => {
+ connectionListeners = [];
+ unsubMock = vi.fn();
+ sdk = {
+ trigger: vi.fn(),
+ registerFunction: vi.fn(),
+ addConnectionStateListener: vi.fn((handler) => {
+ connectionListeners.push(handler);
+ // Mimic the sdk's behaviour: fire immediately with current state.
+ handler("connecting");
+ return unsubMock;
+ }),
+ shutdown: vi.fn().mockResolvedValue(undefined),
+ };
+
+ __setIiiClientDepsForTests({
+ fetchBridgeInfo: vi.fn().mockResolvedValue({
+ ws_path: "/iii/ws",
+ protocol: "ws" as const,
+ engine_url: "ws://127.0.0.1:49134",
+ }),
+ makeBrowserId: () => "harness-test-id",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ registerWorker: () => sdk as any,
+ composeWsUrl: () => "ws://127.0.0.1:49134",
+ });
+ });
+
+ afterEach(async () => {
+ await disposeIiiClient();
+ __resetIiiClientForTests();
+ });
+
+ it("starts disconnected then transitions when the sdk reports connecting/connected", async () => {
+ const { result } = renderHook(() => useConnection());
+
+ // Pre-bootstrap, hook returns the initial "disconnected" sentinel.
+ expect(result.current.status).toBe("disconnected");
+
+ // Let the bootstrap microtasks resolve and the immediate-fire handler run.
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+
+ expect(result.current.status).toBe("connecting");
+ const t1 = result.current.since;
+ expect(t1).toBeGreaterThan(0);
+
+ await act(async () => {
+ connectionListeners[0]("connected");
+ });
+ expect(result.current.status).toBe("connected");
+ expect(result.current.since).toBeGreaterThanOrEqual(t1);
+ });
+
+ it("unsubscribes on unmount", async () => {
+ const { unmount } = renderHook(() => useConnection());
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ expect(unsubMock).not.toHaveBeenCalled();
+ unmount();
+ expect(unsubMock).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/harness/web/src/useConnection.ts b/harness/web/src/useConnection.ts
new file mode 100644
index 000000000..f77ea50bb
--- /dev/null
+++ b/harness/web/src/useConnection.ts
@@ -0,0 +1,45 @@
+// Hook that exposes the iii-browser-sdk connection state to the UI.
+// The status pill (and any other ambient indicators) read from this so the
+// transport layer is observable without each consumer threading the SDK.
+
+import { useEffect, useState } from "react";
+import type { IIIConnectionState } from "iii-browser-sdk";
+import { getIiiClient } from "./iii-client";
+
+export interface ConnectionInfo {
+ status: IIIConnectionState;
+ /** ms timestamp of the last transition. Useful for "reconnecting since…" UI. */
+ since: number;
+}
+
+const INITIAL: ConnectionInfo = {
+ status: "disconnected",
+ since: 0,
+};
+
+export function useConnection(): ConnectionInfo {
+ const [info, setInfo] = useState(INITIAL);
+
+ useEffect(() => {
+ let cancelled = false;
+ let unsub: (() => void) | undefined;
+ void (async () => {
+ try {
+ const client = await getIiiClient();
+ if (cancelled) return;
+ unsub = client.addConnectionStateListener((state) => {
+ setInfo({ status: state, since: Date.now() });
+ });
+ } catch {
+ // Bootstrap failure leaves status='disconnected'; the StatusPill
+ // surfaces the same condition. Don't crash the tree.
+ }
+ })();
+ return () => {
+ cancelled = true;
+ unsub?.();
+ };
+ }, []);
+
+ return info;
+}
diff --git a/harness/web/src/useGlobalShortcut.test.ts b/harness/web/src/useGlobalShortcut.test.ts
new file mode 100644
index 000000000..abe60420c
--- /dev/null
+++ b/harness/web/src/useGlobalShortcut.test.ts
@@ -0,0 +1,80 @@
+import { describe, it, expect } from "vitest";
+import { matchesShortcut } from "./useGlobalShortcut";
+
+const ev = (over: Partial<{ key: string; metaKey: boolean; ctrlKey: boolean; shiftKey: boolean }> = {}) => ({
+ key: "j",
+ metaKey: false,
+ ctrlKey: false,
+ shiftKey: false,
+ ...over,
+});
+
+describe("matchesShortcut", () => {
+ it("fires on Cmd-J when both meta and ctrl are requested", () => {
+ expect(
+ matchesShortcut({ key: "j", meta: true, ctrl: true }, ev({ metaKey: true })),
+ ).toBe(true);
+ });
+
+ it("fires on Ctrl-J when both meta and ctrl are requested", () => {
+ expect(
+ matchesShortcut({ key: "j", meta: true, ctrl: true }, ev({ ctrlKey: true })),
+ ).toBe(true);
+ });
+
+ it("does not fire on Cmd-K (different key)", () => {
+ expect(
+ matchesShortcut(
+ { key: "j", meta: true, ctrl: true },
+ ev({ key: "k", metaKey: true }),
+ ),
+ ).toBe(false);
+ });
+
+ it("does not fire when modifier is missing", () => {
+ expect(
+ matchesShortcut({ key: "j", meta: true, ctrl: true }, ev()),
+ ).toBe(false);
+ });
+
+ it("does not fire on bare J (no modifier requested) when meta is down", () => {
+ // Spec has no meta/ctrl: pressing the key with a modifier should NOT match.
+ expect(matchesShortcut({ key: "j" }, ev({ metaKey: true }))).toBe(false);
+ });
+
+ it("matches case-insensitively", () => {
+ expect(
+ matchesShortcut(
+ { key: "J", meta: true, ctrl: true },
+ ev({ key: "j", metaKey: true }),
+ ),
+ ).toBe(true);
+ });
+
+ it("respects shift: false requires shift up", () => {
+ expect(
+ matchesShortcut(
+ { key: "j", meta: true, ctrl: true },
+ ev({ metaKey: true, shiftKey: true }),
+ ),
+ ).toBe(false);
+ });
+
+ it("respects shift: true requires shift down", () => {
+ expect(
+ matchesShortcut(
+ { key: "j", meta: true, ctrl: true, shift: true },
+ ev({ metaKey: true, shiftKey: true }),
+ ),
+ ).toBe(true);
+ });
+
+ it("meta-only spec ignores ctrl", () => {
+ expect(
+ matchesShortcut({ key: "j", meta: true }, ev({ ctrlKey: true })),
+ ).toBe(false);
+ expect(
+ matchesShortcut({ key: "j", meta: true }, ev({ metaKey: true })),
+ ).toBe(true);
+ });
+});
diff --git a/harness/web/src/useGlobalShortcut.ts b/harness/web/src/useGlobalShortcut.ts
new file mode 100644
index 000000000..a5bcf74ec
--- /dev/null
+++ b/harness/web/src/useGlobalShortcut.ts
@@ -0,0 +1,78 @@
+// Generic global keybinder for window-level shortcuts (e.g. Cmd-J).
+//
+// Why not Cmd-K? Chrome's address bar focus already owns Cmd-K and many users
+// have muscle memory for it. The Phase B design review picked Cmd-J for the
+// function palette to avoid that collision. The handler here calls
+// preventDefault + stopPropagation on the matched key combo so other listeners
+// (and the browser default) don't also fire.
+//
+// `meta` and `ctrl` are both optional and combine OR-style when both are set:
+// passing `{ key: "j", meta: true, ctrl: true }` matches Cmd-J on macOS AND
+// Ctrl-J on Linux/Windows. That's the cross-platform default for app-wide
+// shortcuts. If you want strictly Cmd-only or Ctrl-only, set just one.
+
+import { useEffect } from "react";
+
+export interface ShortcutSpec {
+ key: string; // e.g. "j" — case-insensitive match against KeyboardEvent.key
+ meta?: boolean; // Cmd on macOS
+ ctrl?: boolean; // Ctrl on Linux/Win
+ shift?: boolean;
+}
+
+/**
+ * Pure predicate: does this keyboard event satisfy the shortcut spec?
+ *
+ * Extracted from the hook so it's exercisable in a node test environment
+ * without a DOM. The hook just wires it to `window.addEventListener`.
+ */
+export function matchesShortcut(
+ spec: ShortcutSpec,
+ e: {
+ key: string;
+ metaKey: boolean;
+ ctrlKey: boolean;
+ shiftKey: boolean;
+ },
+): boolean {
+ if (e.key.toLowerCase() !== spec.key.toLowerCase()) return false;
+
+ // Modifier handling:
+ // - When neither meta nor ctrl is requested, the event must have neither.
+ // - When meta XOR ctrl is requested, that one must be pressed (the other is
+ // not checked — Cmd-Shift-J on a Cmd-only spec should still match).
+ // - When both meta AND ctrl are requested, EITHER pressed counts (cross-
+ // platform mode).
+ const wantMeta = !!spec.meta;
+ const wantCtrl = !!spec.ctrl;
+ if (!wantMeta && !wantCtrl) {
+ if (e.metaKey || e.ctrlKey) return false;
+ } else if (wantMeta && wantCtrl) {
+ if (!e.metaKey && !e.ctrlKey) return false;
+ } else if (wantMeta) {
+ if (!e.metaKey) return false;
+ } else if (wantCtrl) {
+ if (!e.ctrlKey) return false;
+ }
+
+ // Shift is exact-match: spec.shift true requires shift down, spec.shift
+ // unset/false requires shift up.
+ if (!!spec.shift !== e.shiftKey) return false;
+ return true;
+}
+
+export function useGlobalShortcut(
+ spec: ShortcutSpec,
+ handler: () => void,
+): void {
+ useEffect(() => {
+ const onKey = (e: KeyboardEvent) => {
+ if (!matchesShortcut(spec, e)) return;
+ e.preventDefault();
+ e.stopPropagation();
+ handler();
+ };
+ window.addEventListener("keydown", onKey);
+ return () => window.removeEventListener("keydown", onKey);
+ }, [spec.key, spec.meta, spec.ctrl, spec.shift, handler]);
+}
diff --git a/harness/web/src/useStatus.test.ts b/harness/web/src/useStatus.test.ts
new file mode 100644
index 000000000..29c6ea0a8
--- /dev/null
+++ b/harness/web/src/useStatus.test.ts
@@ -0,0 +1,228 @@
+// @vitest-environment jsdom
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import { act, renderHook } from "@testing-library/react";
+
+import {
+ __resetIiiClientForTests,
+ __setIiiClientDepsForTests,
+ disposeIiiClient,
+} from "./iii-client";
+import { useStatus } from "./useStatus";
+
+interface FakeSdk {
+ trigger: ReturnType;
+ registerFunction: ReturnType;
+ addConnectionStateListener: ReturnType;
+ shutdown: ReturnType;
+}
+
+type Handlers = Record Promise | void>;
+
+function makeSdk(handlers: Handlers): FakeSdk {
+ return {
+ trigger: vi.fn().mockResolvedValue({ ok: true }),
+ registerFunction: vi.fn(
+ (id: string, fn: (payload: unknown) => Promise | void) => {
+ handlers[id] = fn;
+ return {
+ id,
+ unregister: vi.fn(() => {
+ delete handlers[id];
+ }),
+ };
+ },
+ ),
+ addConnectionStateListener: vi.fn().mockReturnValue(() => undefined),
+ shutdown: vi.fn().mockResolvedValue(undefined),
+ };
+}
+
+describe("useStatus", () => {
+ let handlers: Handlers;
+ let sdk: FakeSdk;
+
+ beforeEach(() => {
+ handlers = {};
+ sdk = makeSdk(handlers);
+ __setIiiClientDepsForTests({
+ fetchBridgeInfo: vi.fn().mockResolvedValue({
+ ws_path: "/iii/ws",
+ protocol: "ws" as const,
+ engine_url: "ws://127.0.0.1:49134",
+ }),
+ makeBrowserId: () => "harness-test-id",
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ registerWorker: () => sdk as any,
+ composeWsUrl: () => "ws://127.0.0.1:49134",
+ });
+ });
+
+ afterEach(async () => {
+ await disposeIiiClient();
+ __resetIiiClientForTests();
+ });
+
+ async function flush() {
+ await act(async () => {
+ await Promise.resolve();
+ await Promise.resolve();
+ await Promise.resolve();
+ });
+ }
+
+ async function fire(topic: string, payload: unknown) {
+ const fn = handlers[`${topic}::harness-test-id`];
+ expect(fn, `handler for ${topic} should be registered`).toBeDefined();
+ await act(async () => {
+ await fn(payload);
+ });
+ }
+
+ it("subscribes to ui::* topics and calls ui::subscribe with browser_id", async () => {
+ renderHook(() => useStatus());
+ await flush();
+
+ const ids = sdk.registerFunction.mock.calls.map((c) => c[0]);
+ expect(ids).toEqual(
+ expect.arrayContaining([
+ "ui::approval::requested::harness-test-id",
+ "ui::approval::resolved::harness-test-id",
+ "ui::cost::tick::harness-test-id",
+ "ui::workers::changed::harness-test-id",
+ ]),
+ );
+ expect(sdk.trigger).toHaveBeenCalledWith({
+ function_id: "ui::subscribe",
+ payload: { browser_id: "harness-test-id", session_id: null },
+ });
+ });
+
+ it("adds and removes pending approvals across requested/resolved", async () => {
+ const { result } = renderHook(() => useStatus());
+ await flush();
+
+ await fire("ui::approval::requested", {
+ tool_call_id: "tc-1",
+ tool_name: "shell::filesystem::write",
+ args: { path: "/tmp/x" },
+ expires_at: 9999,
+ session_id: "s1",
+ });
+ expect(result.current.pendingApprovals).toHaveLength(1);
+ expect(result.current.pendingApprovals[0].tool_call_id).toBe("tc-1");
+
+ await fire("ui::approval::resolved", {
+ tool_call_id: "tc-1",
+ decision: "allow",
+ });
+ expect(result.current.pendingApprovals).toHaveLength(0);
+ });
+
+ it("dedupes a duplicate approval requested push", async () => {
+ const { result } = renderHook(() => useStatus());
+ await flush();
+
+ await fire("ui::approval::requested", {
+ tool_call_id: "tc-1",
+ tool_name: "x",
+ });
+ await fire("ui::approval::requested", {
+ tool_call_id: "tc-1",
+ tool_name: "x",
+ });
+ expect(result.current.pendingApprovals).toHaveLength(1);
+ });
+
+ it("replaces cost snapshot on each tick", async () => {
+ const { result } = renderHook(() => useStatus());
+ await flush();
+
+ await fire("ui::cost::tick", {
+ usd_today: 1.5,
+ by_provider: { anthropic: 1.0, openai: 0.5 },
+ });
+ expect(result.current.cost.usd_today).toBe(1.5);
+
+ await fire("ui::cost::tick", {
+ usd_today: 2.0,
+ by_provider: {},
+ });
+ expect(result.current.cost.usd_today).toBe(2.0);
+ });
+
+ it("ignores malformed cost ticks", async () => {
+ const { result } = renderHook(() => useStatus());
+ await flush();
+
+ await fire("ui::cost::tick", { not: "a cost tick" });
+ expect(result.current.cost.usd_today).toBe(0);
+ });
+
+ it("replaces workers snapshot on each push", async () => {
+ const { result } = renderHook(() => useStatus());
+ await flush();
+
+ await fire("ui::workers::changed", {
+ up: 5,
+ down: 1,
+ total: 6,
+ workers: [{ name: "harness", status: "up" }],
+ });
+ expect(result.current.workers.up).toBe(5);
+ expect(result.current.workers.workers).toHaveLength(1);
+ });
+
+ it("buffers events and caps at 200 entries", async () => {
+ const { result } = renderHook(() => useStatus());
+ await flush();
+
+ for (let i = 0; i < 250; i++) {
+ await fire("ui::cost::tick", {
+ usd_today: i,
+ by_provider: {},
+ });
+ }
+ expect(result.current.events.length).toBe(200);
+ // Newest is preserved at the tail.
+ const last = result.current.events[result.current.events.length - 1];
+ expect((last.payload as { usd_today: number }).usd_today).toBe(249);
+ });
+
+ it("clearEvents empties the rolling buffer", async () => {
+ const { result } = renderHook(() => useStatus());
+ await flush();
+
+ await fire("ui::cost::tick", { usd_today: 1, by_provider: {} });
+ expect(result.current.events).toHaveLength(1);
+ await act(async () => {
+ result.current.clearEvents();
+ });
+ expect(result.current.events).toHaveLength(0);
+ });
+
+ it("flips hydrated=true after the first push of any kind", async () => {
+ const { result } = renderHook(() => useStatus());
+ await flush();
+ expect(result.current.hydrated).toBe(false);
+ await fire("ui::workers::changed", {
+ up: 1,
+ down: 0,
+ total: 1,
+ workers: [],
+ });
+ expect(result.current.hydrated).toBe(true);
+ });
+
+ it("unsubscribes on unmount", async () => {
+ const { unmount } = renderHook(() => useStatus());
+ await flush();
+ sdk.trigger.mockClear();
+ unmount();
+ // After unmount, the cleanup fires a follow-up ui::unsubscribe call.
+ await flush();
+ expect(sdk.trigger).toHaveBeenCalledWith({
+ function_id: "ui::unsubscribe",
+ payload: { browser_id: "harness-test-id", session_id: null },
+ });
+ });
+});
diff --git a/harness/web/src/useStatus.ts b/harness/web/src/useStatus.ts
new file mode 100644
index 000000000..867c795b8
--- /dev/null
+++ b/harness/web/src/useStatus.ts
@@ -0,0 +1,237 @@
+// Aggregate live status for the harness header + status tab.
+//
+// Subscribes (once per page) to the four all-sessions push topics minted by
+// the harness fanout (`harness/src/fanout.rs`):
+//
+// ui::approval::requested → add to pendingApprovals + push event
+// ui::approval::resolved → remove from pendingApprovals + push event
+// ui::cost::tick → replace cost snapshot + push event
+// ui::workers::changed → replace workers snapshot + push event
+//
+// Owns a rolling 200-entry events buffer that StatusTab renders. Filters,
+// pause/resume live in the consumer — this hook is unfiltered + always-on.
+//
+// Failure mode is documented in App.tsx: if the WS is dropped, useConnection
+// surfaces the disconnected state and the UI gates re-hydration on reconnect.
+// This hook does NOT attempt to reconnect itself; it just stops receiving
+// pushes until the SDK re-establishes the connection.
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import { getIiiClient } from "./iii-client";
+
+/** Cost summary pushed by the fanout — `summarize_budgets` in fanout.rs. */
+export interface CostSnapshot {
+ usd_today: number;
+ by_provider: Record;
+ by_period?: Record;
+ budgets?: number;
+}
+
+/** One worker row from `engine::workers::list`, normalized by the fanout. */
+export interface WorkerSnapshot {
+ name: string;
+ status: string;
+}
+
+/** Workers payload pushed by the fanout — `diff_workers` in fanout.rs. */
+export interface WorkersSnapshot {
+ up: number;
+ down: number;
+ total: number;
+ workers: WorkerSnapshot[];
+}
+
+/** Pending approval as pushed by the fanout's approval poll. */
+export interface PendingApprovalSummary {
+ tool_call_id: string;
+ tool_name?: string;
+ args?: unknown;
+ expires_at?: number;
+ session_id?: string;
+}
+
+/** A single rolling-buffer entry. Compact on purpose — the StatusTab feed
+ * formats each line, so we keep the raw payload for filter chips to inspect. */
+export interface StatusEvent {
+ /** Local-monotonic id for stable React keys. */
+ id: number;
+ /** Origin topic — drives filter chips. */
+ kind: "approval" | "cost" | "workers";
+ /** ms timestamp of arrival. Used for relative formatting. */
+ at: number;
+ /** Raw payload from the WS push, for downstream display. */
+ payload: unknown;
+}
+
+const EVENT_BUFFER_CAP = 200;
+
+const INITIAL_COST: CostSnapshot = {
+ usd_today: 0,
+ by_provider: {},
+};
+
+const INITIAL_WORKERS: WorkersSnapshot = {
+ up: 0,
+ down: 0,
+ total: 0,
+ workers: [],
+};
+
+export interface UseStatusValue {
+ pendingApprovals: PendingApprovalSummary[];
+ cost: CostSnapshot;
+ workers: WorkersSnapshot;
+ events: StatusEvent[];
+ /** True once the hook has subscribed and seen at least one push of any kind. */
+ hydrated: boolean;
+ /** Drop the rolling buffer (StatusTab "clear" button). */
+ clearEvents: () => void;
+}
+
+interface ResolvedPayload {
+ tool_call_id: string;
+ decision?: "allow" | "deny";
+}
+
+export function useStatus(): UseStatusValue {
+ const [pendingApprovals, setPendingApprovals] = useState<
+ PendingApprovalSummary[]
+ >([]);
+ const [cost, setCost] = useState(INITIAL_COST);
+ const [workers, setWorkers] = useState(INITIAL_WORKERS);
+ const [events, setEvents] = useState([]);
+ const [hydrated, setHydrated] = useState(false);
+
+ // Monotonic event id. Refs survive re-renders; we never need to read it
+ // during render so using useRef is sound.
+ const idRef = useRef(0);
+ const nextId = useCallback(() => {
+ idRef.current += 1;
+ return idRef.current;
+ }, []);
+
+ const pushEvent = useCallback(
+ (kind: StatusEvent["kind"], payload: unknown) => {
+ setEvents((prev) => {
+ const next = prev.concat({
+ id: nextId(),
+ kind,
+ at: Date.now(),
+ payload,
+ });
+ if (next.length <= EVENT_BUFFER_CAP) return next;
+ return next.slice(next.length - EVENT_BUFFER_CAP);
+ });
+ },
+ [nextId],
+ );
+
+ const clearEvents = useCallback(() => setEvents([]), []);
+
+ useEffect(() => {
+ let cancelled = false;
+ const offs: Array<() => void> = [];
+ let subscribed = false;
+ let browserId: string | null = null;
+
+ void (async () => {
+ try {
+ const client = await getIiiClient();
+ if (cancelled) return;
+ browserId = client.browserId;
+
+ offs.push(
+ client.on(
+ "ui::approval::requested",
+ (payload) => {
+ if (!payload?.tool_call_id) return;
+ setPendingApprovals((prev) => {
+ if (prev.some((p) => p.tool_call_id === payload.tool_call_id)) {
+ return prev;
+ }
+ return prev.concat(payload);
+ });
+ pushEvent("approval", payload);
+ setHydrated(true);
+ },
+ ),
+ );
+
+ offs.push(
+ client.on("ui::approval::resolved", (payload) => {
+ if (!payload?.tool_call_id) return;
+ setPendingApprovals((prev) =>
+ prev.filter((p) => p.tool_call_id !== payload.tool_call_id),
+ );
+ pushEvent("approval", payload);
+ setHydrated(true);
+ }),
+ );
+
+ offs.push(
+ client.on("ui::cost::tick", (payload) => {
+ if (!payload || typeof payload.usd_today !== "number") return;
+ setCost({
+ usd_today: payload.usd_today,
+ by_provider: payload.by_provider ?? {},
+ by_period: payload.by_period,
+ budgets: payload.budgets,
+ });
+ pushEvent("cost", payload);
+ setHydrated(true);
+ }),
+ );
+
+ offs.push(
+ client.on("ui::workers::changed", (payload) => {
+ if (!payload || !Array.isArray(payload.workers)) return;
+ setWorkers(payload);
+ pushEvent("workers", payload);
+ setHydrated(true);
+ }),
+ );
+
+ await client.call("ui::subscribe", {
+ browser_id: browserId,
+ session_id: null,
+ });
+ subscribed = true;
+ } catch (err) {
+ // Bootstrap failed — the StatusPill already surfaces it. Don't
+ // throw from a hook; the tree must keep rendering.
+ // eslint-disable-next-line no-console
+ console.warn("[useStatus] subscribe failed", err);
+ }
+ })();
+
+ return () => {
+ cancelled = true;
+ for (const off of offs) {
+ try {
+ off();
+ } catch {
+ // SDK already disposed
+ }
+ }
+ if (subscribed && browserId) {
+ void getIiiClient().then((client) =>
+ client
+ .call("ui::unsubscribe", {
+ browser_id: browserId,
+ session_id: null,
+ })
+ .catch(() => {}),
+ );
+ }
+ };
+ }, [pushEvent]);
+
+ return {
+ pendingApprovals,
+ cost,
+ workers,
+ events,
+ hydrated,
+ clearEvents,
+ };
+}
diff --git a/harness/web/src/workspace.test.ts b/harness/web/src/workspace.test.ts
new file mode 100644
index 000000000..c6db717cf
--- /dev/null
+++ b/harness/web/src/workspace.test.ts
@@ -0,0 +1,60 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+
+vi.mock("./bridge", () => ({
+ bridge: vi.fn(),
+ BridgeError: class BridgeError extends Error {},
+}));
+
+import { bridge } from "./bridge";
+import { loadWorkspace, saveWorkspace, type Workspace } from "./workspace";
+
+const bridgeMock = bridge as unknown as ReturnType;
+
+describe("workspace", () => {
+ beforeEach(() => {
+ bridgeMock.mockReset();
+ });
+
+ it("loadWorkspace returns null when state has no value", async () => {
+ bridgeMock.mockResolvedValueOnce(null);
+ expect(await loadWorkspace("s1")).toBeNull();
+ expect(bridgeMock).toHaveBeenCalledWith("state::get", {
+ scope: "agent",
+ key: "session/s1/workspace",
+ });
+ });
+
+ it("loadWorkspace returns the stored Workspace shape when present", async () => {
+ const ws: Workspace = { cwd: "/tmp/foo", set_at: 1234 };
+ bridgeMock.mockResolvedValueOnce(ws);
+ expect(await loadWorkspace("s1")).toEqual(ws);
+ });
+
+ it("loadWorkspace returns null on bridge error (advisory, never throws)", async () => {
+ bridgeMock.mockRejectedValueOnce(new Error("bridge down"));
+ expect(await loadWorkspace("s1")).toBeNull();
+ });
+
+ it("loadWorkspace returns null when shape is malformed", async () => {
+ bridgeMock.mockResolvedValueOnce({ random: "junk" });
+ expect(await loadWorkspace("s1")).toBeNull();
+ });
+
+ it("saveWorkspace writes state::set with set_at timestamp", async () => {
+ bridgeMock.mockResolvedValueOnce(undefined);
+ const before = Date.now();
+ await saveWorkspace("s1", "/tmp/foo");
+ const after = Date.now();
+ expect(bridgeMock).toHaveBeenCalledWith("state::set", {
+ scope: "agent",
+ key: "session/s1/workspace",
+ value: expect.objectContaining({
+ cwd: "/tmp/foo",
+ set_at: expect.any(Number),
+ }),
+ });
+ const call = bridgeMock.mock.calls[0][1] as { value: Workspace };
+ expect(call.value.set_at).toBeGreaterThanOrEqual(before);
+ expect(call.value.set_at).toBeLessThanOrEqual(after);
+ });
+});
diff --git a/harness/web/src/workspace.ts b/harness/web/src/workspace.ts
new file mode 100644
index 000000000..bfef0fc84
--- /dev/null
+++ b/harness/web/src/workspace.ts
@@ -0,0 +1,35 @@
+import { bridge } from "./bridge";
+import type { Workspace } from "./types";
+
+export type { Workspace } from "./types";
+
+const stateKey = (sessionId: string) => `session/${sessionId}/workspace`;
+
+/**
+ * Load the workspace for a session. Returns null when nothing has been saved
+ * yet. Returns null on bridge errors too — the workspace is advisory and
+ * should never block UI rendering.
+ */
+export async function loadWorkspace(sessionId: string): Promise {
+ try {
+ const value = await bridge("state::get", {
+ scope: "agent",
+ key: stateKey(sessionId),
+ });
+ if (value && typeof value.cwd === "string" && typeof value.set_at === "number") {
+ return value;
+ }
+ return null;
+ } catch {
+ return null;
+ }
+}
+
+export async function saveWorkspace(sessionId: string, cwd: string): Promise {
+ const value: Workspace = { cwd, set_at: Date.now() };
+ await bridge("state::set", {
+ scope: "agent",
+ key: stateKey(sessionId),
+ value,
+ });
+}
diff --git a/harness/web/tsconfig.json b/harness/web/tsconfig.json
index 26f2d4701..d345c3eea 100644
--- a/harness/web/tsconfig.json
+++ b/harness/web/tsconfig.json
@@ -4,6 +4,7 @@
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
+ "noEmit": true,
"allowImportingTsExtensions": false,
"resolveJsonModule": true,
"isolatedModules": true,