= {
+ running: "Running",
+ completed: "Completed",
+ failed: "Failed",
+ interrupted: "Interrupted",
+};
+
+/**
+ * Source-adapted AI Elements `Tool`. The header/input/output anatomy is kept;
+ * the status enum comes from canonical item events instead of an AI SDK tool
+ * part, and payloads stay in `` so nothing is prettified away.
+ */
+export function ToolItem({
+ name,
+ status,
+ input,
+ output,
+ failure,
+}: {
+ name: string;
+ status: ToolStatus;
+ input: string | null;
+ output: string | null;
+ failure: string | null;
+}) {
+ return (
+
+
+
+ Tool {name}
+
+ {STATUS_LABEL[status]}
+
+ {input === null ? null : (
+ <>
+ Input
+ {input}
+ >
+ )}
+ {output === null ? null : (
+ <>
+ Output
+ {output}
+ >
+ )}
+ {failure === null ? null : (
+ <>
+ Diagnostic
+
+ {failure}
+
+ >
+ )}
+ {status === "interrupted" ? (
+
+ Interrupted — the last known status is shown and no result was fabricated.
+
+ ) : null}
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/components/ui/tooltip.tsx b/packages/paperclip-runner/devtools/browser/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000000..6f367d8ada
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/components/ui/tooltip.tsx
@@ -0,0 +1,25 @@
+import * as React from "react";
+
+/**
+ * Source-adapted shadcn Tooltip. The content is always mirrored into a
+ * described-by element, so a tooltip is never the only channel for a
+ * diagnostic (interaction map A5).
+ */
+export function Tooltip({
+ content,
+ id,
+ children,
+}: {
+ content: string;
+ id: string;
+ children: React.ReactElement<{ "aria-describedby"?: string }>;
+}) {
+ return (
+
+ {React.cloneElement(children, { "aria-describedby": id })}
+
+ {content}
+
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/live/Inspector.tsx b/packages/paperclip-runner/devtools/browser/src/live/Inspector.tsx
new file mode 100644
index 0000000000..57d0cb1104
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/live/Inspector.tsx
@@ -0,0 +1,229 @@
+import * as React from "react";
+
+import type { PrpEvent } from "../../../../src/protocol/replay-contract";
+import type { SessionSnapshot } from "../../../../src/reducer/session-reducer";
+import { Badge } from "../components/ui/badge";
+import { Tabs } from "../components/ui/tabs";
+import type { LiveConnectionStatus, LiveSessionState } from "./protocol";
+import { capabilityRows } from "./transcript-model";
+
+const TABS = [
+ { id: "events", label: "Events" },
+ { id: "requests", label: "Requests" },
+ { id: "capabilities", label: "Capabilities" },
+ { id: "session", label: "Session" },
+];
+
+function CopyButton({ label, value }: { label: string; value: string }) {
+ const [copied, setCopied] = React.useState(false);
+ return (
+
+ );
+}
+
+/**
+ * The debugging surface. Redaction is the server's job: the inspector renders
+ * redaction markers verbatim and never tries to reconstruct a redacted value.
+ */
+export function Inspector({
+ events,
+ snapshot,
+ state,
+ connection,
+ reconnectAttempt,
+ replayParity,
+ highlightItemId,
+ onHighlightItem,
+}: {
+ events: readonly PrpEvent[];
+ snapshot: SessionSnapshot | null;
+ state: LiveSessionState | null;
+ connection: LiveConnectionStatus;
+ reconnectAttempt: number;
+ replayParity: boolean | null;
+ highlightItemId: string | null;
+ onHighlightItem: (itemId: string | null) => void;
+}) {
+ const [tab, setTab] = React.useState("events");
+ const [filter, setFilter] = React.useState("");
+ const [facet, setFacet] = React.useState(null);
+
+ const facets = React.useMemo(
+ () => [...new Set(events.map((event) => event.eventType))].sort(),
+ [events],
+ );
+
+ const visible = React.useMemo(
+ () =>
+ events.filter((event) => {
+ if (facet !== null && event.eventType !== facet) return false;
+ if (filter.length === 0) return true;
+ return JSON.stringify(event).toLowerCase().includes(filter.toLowerCase());
+ }),
+ [events, facet, filter],
+ );
+
+ return (
+
+ {tab === "events" ? (
+
+
+
setFilter(event.target.value)}
+ />
+
+
+ {facets.map((candidate) => (
+
+ ))}
+
+
+ {visible.map((event) => (
+ -
+
+ onHighlightItem(
+ (toggle.currentTarget as HTMLDetailsElement).open
+ ? (event.itemId ?? null)
+ : null,
+ )
+ }
+ >
+
+ {event.sourceSeq}
+ {event.eventType}
+ {event.itemId ?? event.turnId ?? "—"}
+
+ {JSON.stringify(event, null, 2)}
+
+
+
+ ))}
+
+
+ ) : null}
+
+ {tab === "requests" ? (
+
+ {(snapshot?.requests ?? []).map((request) => (
+
+
-
+
{request.requestId}
+
+ -
+ {request.type} — {request.status}
+
+
+ ))}
+ {(snapshot?.requests.length ?? 0) === 0 ? No runtime requests yet.
: null}
+
+ ) : null}
+
+ {tab === "capabilities" ? (
+
+ {capabilityRows(state?.capabilities ?? { resume: false, typedEvents: false, steering: false, interruption: false, structuredResult: false }).map(
+ (row) => (
+ -
+ {row.name}
+
+ {row.supported ? "supported" : "unsupported"}
+
+ {row.diagnostic === null ? null : (
+
{row.diagnostic}
+ )}
+
+ ),
+ )}
+
+ ) : null}
+
+ {tab === "session" ? (
+
+
+
- Run
+ - {state?.runId ?? "—"}
+
+
+
- Normalized session
+ - {state?.normalizedSessionId ?? "—"}
+
+
+
- Driver session
+ - {state?.driverSession.driverSessionId ?? "—"}
+
+
+
- Source cursors
+ - {JSON.stringify(snapshot?.sourceCursors ?? {})}
+
+
+
- Integrity
+ - {snapshot?.integrity ?? "—"}
+
+
+
- Gaps
+ - {snapshot?.gaps.length ?? 0}
+
+
+
- Connection
+ -
+ {connection}
+ {connection === "reconnecting" ? ` (attempt ${reconnectAttempt})` : ""}
+
+
+
+
- Credentials in browser
+ - {state?.credentialsExposed === true ? "yes" : "no"}
+
+
+
- Live and replay reducer
+ -
+ {replayParity === null ? "not compared yet" : replayParity ? "match" : "mismatch"}
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/live/LiveConsole.tsx b/packages/paperclip-runner/devtools/browser/src/live/LiveConsole.tsx
new file mode 100644
index 0000000000..7eb0b5f1e7
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/live/LiveConsole.tsx
@@ -0,0 +1,487 @@
+import * as React from "react";
+
+import { Badge } from "../components/ui/badge";
+import { Banner, BannerActions, BannerText } from "../components/ui/banner";
+import { Button } from "../components/ui/button";
+import { Composer } from "../components/ui/composer";
+import { Conversation } from "../components/ui/conversation";
+import { Dialog } from "../components/ui/dialog";
+import { Message, MessageText } from "../components/ui/message";
+import { ReasoningItem } from "../components/ui/reasoning-item";
+import { Tabs } from "../components/ui/tabs";
+import { ToolItem, type ToolStatus } from "../components/ui/tool-item";
+import { Inspector } from "./Inspector";
+import { RequestCard } from "./RequestCard";
+import { ConnectionBlock, GoalBlock, LineageTree, ManifestList } from "./SessionPanel";
+import { useLiveConsole } from "./use-live-console";
+import type { TranscriptEntry } from "./transcript-model";
+
+const MOBILE_SEGMENTS = [
+ { id: "chat", label: "Chat" },
+ { id: "session", label: "Session" },
+ { id: "inspector", label: "Inspector" },
+];
+
+const TURN_TONE = {
+ completed: "success",
+ failed: "danger",
+ interrupted: "warning",
+ cancelled: "warning",
+} as const;
+
+const TURN_LABEL = {
+ completed: "Turn completed",
+ failed: "Turn failed",
+ interrupted: "Turn interrupted",
+ cancelled: "Cancelled before start",
+} as const;
+
+/**
+ * Renders exactly one layout. Hiding a second copy with CSS would duplicate
+ * the transcript log landmark and every control in the DOM.
+ */
+function useCompactLayout(): boolean {
+ const [compact, setCompact] = React.useState(
+ () => window.matchMedia?.("(max-width: 64rem)").matches === true,
+ );
+ React.useEffect(() => {
+ const query = window.matchMedia?.("(max-width: 64rem)");
+ if (query === undefined) return undefined;
+ const onChange = () => setCompact(query.matches);
+ onChange();
+ query.addEventListener("change", onChange);
+ return () => query.removeEventListener("change", onChange);
+ }, []);
+ return compact;
+}
+
+function toolStatus(entry: Extract): ToolStatus {
+ if (entry.interrupted) return "interrupted";
+ if (entry.item.status === "failed") return "failed";
+ if (entry.item.status === "running") return "running";
+ return "completed";
+}
+
+export function LiveConsole() {
+ const console_ = useLiveConsole();
+ const [draft, setDraft] = React.useState("");
+ const [inspectorOpen, setInspectorOpen] = React.useState(false);
+ const [segment, setSegment] = React.useState("chat");
+ const [resetOpen, setResetOpen] = React.useState(false);
+ const [highlightItemId, setHighlightItemId] = React.useState(null);
+ const [outcomes, setOutcomes] = React.useState>({});
+ const oldestPendingRef = React.useRef(null);
+ const compact = useCompactLayout();
+
+ React.useEffect(() => {
+ const stored = window.localStorage.getItem("paperclip-runner.live-console.inspector");
+ if (stored === "open") setInspectorOpen(true);
+ }, []);
+
+ React.useEffect(() => {
+ window.localStorage.setItem(
+ "paperclip-runner.live-console.inspector",
+ inspectorOpen ? "open" : "closed",
+ );
+ }, [inspectorOpen]);
+
+ const manifestId = console_.state?.manifest ?? console_.selectedManifestId;
+ const activeManifest = console_.manifests.find((entry) => entry.id === manifestId);
+ const pendingRequests = console_.transcript.filter(
+ (entry) => entry.kind === "request" && entry.status === "pending",
+ );
+ const viewingChild = console_.selectedThreadId !== null;
+ const childSteeringSupported = false;
+
+ React.useEffect(() => {
+ const snapshot = console_.snapshot;
+ if (snapshot === null || console_.state === null) return;
+ if (!["completed", "failed", "interrupted", "cancelled"].includes(snapshot.turnState)) return;
+ setOutcomes((current) => ({
+ ...current,
+ [console_.state!.manifest ?? "unknown"]:
+ snapshot.turnState === "completed" ? "passed observations" : snapshot.turnState,
+ }));
+ }, [console_.snapshot, console_.state]);
+
+ function submit() {
+ const text = draft.trim();
+ if (text.length === 0) return;
+ setDraft("");
+ if (console_.composer === "active-turn") void console_.steer(text);
+ else void console_.send(text);
+ }
+
+ function runManifest(id: string) {
+ const manifest = console_.manifests.find((entry) => entry.id === id);
+ void console_.start({ manifestId: id, message: manifest?.prompt ?? "Run the demo." });
+ }
+
+ const composerReason = viewingChild && !childSteeringSupported
+ ? "Direct steering of child threads is not supported by this app-server."
+ : null;
+
+ const transcriptBody = (
+
+ {console_.transcript.length === 0 ? (
+
+
No session yet
+
Pick a demo chat or start a blank session
+
+ The browser never holds a provider credential. Only the demo server starts a driver
+ and owns the working directory.
+
+
+ ) : null}
+
+ {console_.transcript.map((entry) => {
+ if (entry.kind === "user") {
+ return (
+ -
+
+
+
+
+ );
+ }
+ if (entry.kind === "turn") {
+ return (
+ -
+ {TURN_LABEL[entry.state]}
+ {entry.detail}
+
+ );
+ }
+ if (entry.kind === "diagnostic") {
+ return (
+ -
+
+
+
+
+ );
+ }
+ if (entry.kind === "request") {
+ const isOldestPending =
+ entry.status === "pending" && pendingRequests[0]?.key === entry.key;
+ return (
+
+ void console_.resolve(entry.requestId, entry.turnId ?? "", resolution)
+ }
+ />
+ );
+ }
+ if (entry.role === "reasoning") {
+ return (
+ -
+
+
+ );
+ }
+ if (entry.role === "tool") {
+ return (
+ -
+
+
+ );
+ }
+ return (
+ -
+
+
+ {entry.failure === null ? null : (
+
{entry.failure}
+ )}
+
+
+ );
+ })}
+ {console_.steeringChips.map((chip) => (
+ -
+
+ {chip.status === "rejected"
+ ? `rejected — ${chip.detail ?? "turn already ended"}`
+ : chip.status}
+
+ {chip.text}
+ {chip.status === "rejected" || chip.status === "failed" ? (
+
+ ) : null}
+
+ ))}
+
+
+ );
+
+ const composerBlock = (
+
+ {console_.connection === "disconnected" || console_.connection === "reconnecting" ? (
+
+
+ {console_.connection === "reconnecting"
+ ? `Connection lost — reconnecting (attempt ${console_.reconnectAttempt})…`
+ : "Connection lost — the session is still on the server."}
+
+
+
+
+
+ ) : null}
+ {pendingRequests.length > 0 ? (
+
+ {pendingRequests.length} request waiting — Review
+
+
+
+
+ ) : null}
+ void console_.interrupt()}
+ disabledReason={composerReason}
+ />
+
+ );
+
+ const sessionRail = (
+ <>
+
+ oldestPendingRef.current?.focus()}
+ />
+ void console_.goal(operation, body)} />
+
+
+ >
+ );
+
+ const inspectorPanel = (
+
+ );
+
+ return (
+
+
+
+ {console_.replay.active ? (
+
+ REPLAY
+
+ ) : null}
+
{activeManifest?.name ?? "Live console"}
+ {viewingChild ? (
+
+ root ▸ {console_.selectedThreadId}
+
+ ) : null}
+
+
+
+
+
+
+
+
+ {console_.announcement}
+
+ {console_.error === null ? null : (
+
+ {console_.error}
+
+ )}
+
+ {console_.replay.active ? (
+
+
+
+
+
+ console_.replay.setPosition(Number(event.target.value))}
+ />
+
+ {console_.replay.position} / {console_.replay.length}
+
+
+ ) : null}
+
+ {compact ? (
+
+
+ {segment === "chat" ? (
+ <>
+ {transcriptBody}
+ {composerBlock}
+ >
+ ) : null}
+ {segment === "session" ? sessionRail : null}
+ {segment === "inspector" ? inspectorPanel : null}
+
+
+ ) : (
+
+
+
+ {transcriptBody}
+ {composerBlock}
+
+ {inspectorOpen ? (
+
+ ) : null}
+
+ )}
+
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/live/RequestCard.tsx b/packages/paperclip-runner/devtools/browser/src/live/RequestCard.tsx
new file mode 100644
index 0000000000..abe7969ba7
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/live/RequestCard.tsx
@@ -0,0 +1,168 @@
+import * as React from "react";
+
+import { Badge } from "../components/ui/badge";
+import { Button } from "../components/ui/button";
+import { runtimeRequestSubmission, type TranscriptRequestEntry } from "./transcript-model";
+
+const KIND_LABELS: Record = {
+ command_approval: "Command",
+ file_approval: "File change",
+ permission_approval: "Permission",
+ user_input: "Input",
+ elicitation: "Input",
+};
+
+const ACTION_LABELS: Record = {
+ accept: "Approve",
+ accept_for_session: "Approve for session",
+ decline: "Reject",
+ cancel: "Cancel",
+ submit: "Submit",
+};
+
+const STATUS_TONE = {
+ pending: "accent",
+ resolved: "success",
+ expired: "warning",
+ cancelled: "warning",
+} as const;
+
+function detailBlock(details: Record): string | null {
+ const command = details.command;
+ if (typeof command === "string") return command;
+ const diff = details.diff;
+ if (typeof diff === "string") {
+ const path = typeof details.path === "string" ? `${details.path}\n` : "";
+ return `${path}${diff}`;
+ }
+ return null;
+}
+
+/**
+ * One card renders all five request kinds. Actions come from the upstream
+ * request contract, so the card can never invent an affordance the runner
+ * cannot honour, and the first click locks the row until the canonical
+ * resolved event arrives.
+ */
+export function RequestCard({
+ entry,
+ onResolve,
+ focusRef,
+}: {
+ entry: TranscriptRequestEntry;
+ onResolve: (resolution: Record) => void;
+ focusRef?: React.Ref;
+}) {
+ const [answer, setAnswer] = React.useState("");
+ const [resolving, setResolving] = React.useState(false);
+ const answerId = React.useId();
+ const kindLabel = KIND_LABELS[entry.requestKind] ?? "Request";
+ const pending = entry.status === "pending";
+ const isInput = entry.requestKind === "user_input" || entry.requestKind === "elicitation";
+ const payload = detailBlock(entry.details);
+
+ React.useEffect(() => {
+ if (!pending) setResolving(false);
+ }, [pending]);
+
+ function submit(resolution: Record) {
+ setResolving(true);
+ onResolve(resolution);
+ }
+
+ if (!pending) {
+ return (
+
+
+
+
+ {kindLabel} · {entry.status === "resolved"
+ ? `resolved — ${ACTION_LABELS[entry.resolvedAction ?? ""] ?? entry.resolvedAction ?? "unknown"}`
+ : entry.status === "expired"
+ ? "expired before response"
+ : "cancelled"}
+
+ {entry.status}
+
+ {entry.prompt}
+
+ {entry.requestId}
+ {entry.resolvedAt === null ? null : }
+
+ {payload === null ? null : {payload}}
+
+
+ );
+ }
+
+ return (
+
+
+ {kindLabel}
+ pending
+
+ {entry.prompt}
+
+ {entry.requestId}
+
+ {payload === null ? null : {payload}}
+ {isInput ? (
+
+
+ setAnswer(event.target.value)}
+ />
+
+ ) : null}
+
+ {entry.actions.map((action) => (
+
+ ))}
+
+ {resolving ? (
+
+ Resolving — waiting for the canonical resolved event.
+
+ ) : null}
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/live/SessionPanel.tsx b/packages/paperclip-runner/devtools/browser/src/live/SessionPanel.tsx
new file mode 100644
index 0000000000..f5939551b9
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/live/SessionPanel.tsx
@@ -0,0 +1,346 @@
+import * as React from "react";
+
+import { Badge } from "../components/ui/badge";
+import { Button } from "../components/ui/button";
+import { Dialog } from "../components/ui/dialog";
+import { Menu } from "../components/ui/menu";
+import type {
+ GoalOperation,
+ LiveConnectionStatus,
+ LiveLineageEntry,
+ LiveManifestSummary,
+ LiveSessionState,
+} from "./protocol";
+import { GOAL_OPERATION_LABELS } from "./protocol";
+import { goalAvailability } from "./transcript-model";
+
+const CONNECTION_TONE = {
+ idle: "neutral",
+ connecting: "accent",
+ connected: "success",
+ reconnecting: "warning",
+ disconnected: "danger",
+ terminal: "neutral",
+} as const;
+
+const THREAD_STATE_LABEL: Record = {
+ starting: "Starting",
+ running: "Running",
+ completed: "Completed",
+ failed: "Failed",
+};
+
+export function ManifestList({
+ manifests,
+ selectedId,
+ onSelect,
+ onRun,
+ lastOutcome,
+ disabled,
+}: {
+ manifests: readonly LiveManifestSummary[];
+ selectedId: string;
+ onSelect: (id: string) => void;
+ onRun: (id: string) => void;
+ lastOutcome: Record;
+ disabled: boolean;
+}) {
+ return (
+
+
+ Demo chats
+
+
+ {manifests.map((manifest) => (
+ -
+
+ {manifest.id === selectedId ? (
+ <>
+
+ Expected observations
+
+ {manifest.expectedObservations.map((observation) => (
+ - {observation}
+ ))}
+
+
+
+ >
+ ) : null}
+
+ ))}
+
+
+ );
+}
+
+export function ConnectionBlock({
+ connection,
+ reconnectAttempt,
+ state,
+ pendingRequestCount,
+ onDrop,
+ onRetry,
+ onScrollToOldestRequest,
+}: {
+ connection: LiveConnectionStatus;
+ reconnectAttempt: number;
+ state: LiveSessionState | null;
+ pendingRequestCount: number;
+ onDrop: () => void;
+ onRetry: () => void;
+ onScrollToOldestRequest: () => void;
+}) {
+ return (
+
+
+ Session
+
+
+ Connection
+
+ {connection === "reconnecting" ? `reconnecting (attempt ${reconnectAttempt})` : connection}
+
+
+
+ Pending requests
+
+
+
+ Credentials in browser
+
+ {state?.credentialsExposed === true ? "exposed" : "none"}
+
+
+
+
+
- Run
+ - {state?.runId ?? "—"}
+
+
+
- Session
+ - {state?.normalizedSessionId ?? "—"}
+
+
+
+
+
+
+
+ );
+}
+
+export function GoalBlock({
+ state,
+ onGoal,
+}: {
+ state: LiveSessionState | null;
+ onGoal: (operation: GoalOperation, body?: Record) => void;
+}) {
+ const [dialogOpen, setDialogOpen] = React.useState(false);
+ const [objective, setObjective] = React.useState("");
+ const objectiveId = React.useId();
+ const capabilities = state?.capabilities ?? {
+ resume: false,
+ typedEvents: false,
+ steering: false,
+ interruption: false,
+ structuredResult: false,
+ };
+ const availability = goalAvailability(capabilities, state?.goal?.status ?? null);
+ const unsupported = capabilities.goals !== true;
+ const unsupportedReason = availability[0]?.reason ?? null;
+
+ return (
+
+
+
+ Goal
+
+
+ {state === null ? null : state.goal === null ? (
+
+ {unsupported ? "Goal operations are unsupported here." : "No goal set"}
+
+ ) : (
+
+
{state.goal.objective}
+
+
+ {state.goal.status}
+
+
+
+
+ )}
+
+
+ );
+}
+
+export function LineageTree({
+ lineage,
+ selectedThreadId,
+ onSelect,
+}: {
+ lineage: readonly LiveLineageEntry[];
+ selectedThreadId: string | null;
+ onSelect: (threadId: string | null) => void;
+}) {
+ const rootId = lineage.find((entry) => entry.depth === 0)?.threadId ?? null;
+ const current = selectedThreadId ?? rootId;
+ const treeRef = React.useRef(null);
+
+ function onKeyDown(event: React.KeyboardEvent) {
+ const rows = [
+ ...(treeRef.current?.querySelectorAll('[role="treeitem"]') ?? []),
+ ];
+ const index = rows.indexOf(document.activeElement as HTMLButtonElement);
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ rows[Math.min(index + 1, rows.length - 1)]?.focus();
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ rows[Math.max(index - 1, 0)]?.focus();
+ }
+ }
+
+ return (
+
+
+ Threads
+
+
+ {lineage.length === 0 ? - No threads yet.
: null}
+ {lineage.map((entry) => (
+ -
+
+
+ ))}
+
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/live/client.ts b/packages/paperclip-runner/devtools/browser/src/live/client.ts
new file mode 100644
index 0000000000..b183aaaa57
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/live/client.ts
@@ -0,0 +1,165 @@
+import type { PrpEvent } from "../../../../src/protocol/replay-contract";
+import type {
+ GoalOperation,
+ LiveManifestSummary,
+ LiveSessionState,
+} from "./protocol";
+
+const BASE = "/api/liveConsole";
+
+export class LiveConsoleError extends Error {
+ readonly status: number;
+ readonly code: string;
+
+ constructor(status: number, code: string, message: string) {
+ super(message);
+ this.name = "LiveConsoleError";
+ this.status = status;
+ this.code = code;
+ }
+}
+
+async function send(path: string, init?: RequestInit): Promise {
+ const response = await fetch(`${BASE}${path}`, {
+ ...init,
+ headers: init?.body === undefined ? undefined : { "content-type": "application/json" },
+ });
+ const body = (await response.json()) as Record;
+ if (!response.ok) {
+ throw new LiveConsoleError(
+ response.status,
+ typeof body.error === "string" ? body.error : "request_failed",
+ typeof body.message === "string" ? body.message : `Request to ${path} failed.`,
+ );
+ }
+ return body as T;
+}
+
+export async function fetchManifests(): Promise {
+ const body = await send<{ manifests: LiveManifestSummary[] }>("/manifests");
+ return body.manifests;
+}
+
+export async function createSession(input: {
+ manifest: string;
+ objective: string;
+ message?: string;
+ startTurn?: boolean;
+}): Promise {
+ return send("/sessions", {
+ method: "POST",
+ body: JSON.stringify(input),
+ });
+}
+
+export async function readSession(sessionId: string): Promise {
+ return send(`/sessions/${sessionId}`);
+}
+
+export async function readEvents(
+ sessionId: string,
+ after = 0,
+): Promise<{ events: PrpEvent[]; cursor: number; replay: boolean }> {
+ return send(`/sessions/${sessionId}/events?after=${after}`);
+}
+
+export async function startTurn(
+ sessionId: string,
+ text: string,
+): Promise {
+ return send(`/sessions/${sessionId}/turns`, {
+ method: "POST",
+ body: JSON.stringify({ text }),
+ });
+}
+
+export async function steerTurn(
+ sessionId: string,
+ turnId: string,
+ text: string,
+): Promise {
+ return send(`/sessions/${sessionId}/steer`, {
+ method: "POST",
+ body: JSON.stringify({ turnId, text }),
+ });
+}
+
+export async function interruptTurn(
+ sessionId: string,
+ turnId: string | null,
+): Promise {
+ return send(`/sessions/${sessionId}/interrupt`, {
+ method: "POST",
+ body: JSON.stringify({
+ reason: "browser_operator",
+ ...(turnId === null ? {} : { turnId }),
+ }),
+ });
+}
+
+export async function resolveRequest(
+ sessionId: string,
+ requestId: string,
+ turnId: string,
+ resolution: Record,
+): Promise {
+ return send(
+ `/sessions/${sessionId}/requests/${encodeURIComponent(requestId)}/resolve`,
+ { method: "POST", body: JSON.stringify({ turnId, resolution }) },
+ );
+}
+
+export async function goalOperation(
+ sessionId: string,
+ operation: GoalOperation,
+ body: Record = {},
+): Promise {
+ return send(`/sessions/${sessionId}/goal/${operation}`, {
+ method: "POST",
+ body: JSON.stringify(body),
+ });
+}
+
+export async function reconnectSession(sessionId: string): Promise {
+ return send(`/sessions/${sessionId}/reconnect`, {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+}
+
+export async function closeSession(sessionId: string): Promise {
+ await send(`/sessions/${sessionId}/close`, {
+ method: "POST",
+ body: JSON.stringify({}),
+ });
+}
+
+export interface EventStreamHandle {
+ close(): void;
+}
+
+/**
+ * Subscribes to the canonical event stream from a durable cursor. The stream
+ * never carries provider credentials; the server redacts every frame.
+ */
+export function openEventStream(
+ sessionId: string,
+ after: number,
+ handlers: {
+ onEvent: (event: PrpEvent) => void;
+ onOpen?: () => void;
+ onError?: () => void;
+ },
+): EventStreamHandle {
+ const source = new EventSource(`${BASE}/sessions/${sessionId}/stream?after=${after}`);
+ source.onopen = () => handlers.onOpen?.();
+ source.onmessage = (message) => {
+ try {
+ handlers.onEvent(JSON.parse(message.data) as PrpEvent);
+ } catch {
+ handlers.onError?.();
+ }
+ };
+ source.onerror = () => handlers.onError?.();
+ return { close: () => source.close() };
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/live/protocol.ts b/packages/paperclip-runner/devtools/browser/src/live/protocol.ts
new file mode 100644
index 0000000000..d793d59db4
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/live/protocol.ts
@@ -0,0 +1,115 @@
+import type { PrpEvent } from "../../../../src/protocol/replay-contract";
+import type { SessionSnapshot } from "../../../../src/reducer/session-reducer";
+
+/**
+ * The browser only ever sees the demo server's public state and the canonical
+ * PRP event stream. There is no second event model and no client-side session
+ * cache: every surface reads these shapes or the reducer snapshot they carry.
+ */
+export interface LiveCapabilities {
+ resume: boolean;
+ typedEvents: boolean;
+ steering: boolean;
+ interruption: boolean;
+ structuredResult: boolean;
+ read?: boolean;
+ reconciliation?: boolean;
+ usage?: boolean;
+ runtimeRequestResolution?: boolean;
+ runtimeRequestHandoff?: boolean;
+ goals?: boolean;
+ threadLineage?: boolean;
+ unsupported?: string[];
+}
+
+export interface LivePendingRequest {
+ requestId: string;
+ requestKind: string;
+ method: string;
+ turnId: string;
+ itemId: string;
+ status: string;
+ prompt: string;
+ details: Record;
+}
+
+export interface LiveGoal {
+ threadId: string;
+ objective: string;
+ status: string;
+ tokenBudget: number | null;
+ tokensUsed: number;
+ timeUsedSeconds: number;
+ createdAt: number;
+ updatedAt: number;
+}
+
+export interface LiveLineageEntry {
+ threadId: string;
+ providerSessionId: string | null;
+ parentThreadId: string | null;
+ depth: number;
+ nickname: string | null;
+ role: string | null;
+ status: string;
+}
+
+export interface LiveSessionState {
+ sessionId: string;
+ runId: string;
+ normalizedSessionId: string;
+ manifest: string | null;
+ providerAuthentication: string;
+ credentialsExposed: boolean;
+ capabilities: LiveCapabilities;
+ driverSession: {
+ driverSessionId: string;
+ providerSessionId?: string | null;
+ displayId?: string | null;
+ };
+ activeTurnId: string | null;
+ pendingRequests: LivePendingRequest[];
+ goal: LiveGoal | null;
+ lineage: LiveLineageEntry[];
+ cursor: number;
+ snapshot: SessionSnapshot;
+}
+
+export interface LiveManifestSummary {
+ id: string;
+ name: string;
+ purpose: string;
+ scenarios: string[];
+ objective: string;
+ prompt: string;
+ expectedObservations: string[];
+}
+
+export type LiveConnectionStatus =
+ | "idle"
+ | "connecting"
+ | "connected"
+ | "reconnecting"
+ | "disconnected"
+ | "terminal";
+
+export type GoalOperation = "get" | "set" | "pause" | "resume" | "clear";
+
+export type LiveEventList = readonly PrpEvent[];
+
+export const GOAL_OPERATIONS: readonly GoalOperation[] = [
+ "set",
+ "get",
+ "pause",
+ "resume",
+ "clear",
+];
+
+/** Human labels for the five fixed goal verbs (interaction map §5). */
+export const GOAL_OPERATION_LABELS: Record = {
+ set: "Set goal…",
+ get: "View",
+ pause: "Pause",
+ resume: "Resume",
+ clear: "Clear",
+};
diff --git a/packages/paperclip-runner/devtools/browser/src/live/transcript-model.test.ts b/packages/paperclip-runner/devtools/browser/src/live/transcript-model.test.ts
new file mode 100644
index 0000000000..119382d37b
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/live/transcript-model.test.ts
@@ -0,0 +1,293 @@
+import { describe, expect, it } from "vitest";
+
+import { LiveConsoleScriptedDriver } from "../../../../src/mock-core/live-console-scripted-driver";
+import {
+ parseHarnessRuntimeRequestResolution,
+ type HarnessSession,
+} from "../../../../src/contracts/harness-driver";
+import type { PrpEvent } from "../../../../src/protocol/replay-contract";
+import {
+ applyPrpEvent,
+ createSessionSnapshotFromMetadata,
+ type SessionSnapshot,
+} from "../../../../src/reducer/session-reducer";
+import type { LiveCapabilities } from "./protocol";
+import {
+ buildTranscript,
+ capabilityRows,
+ composerState,
+ goalAvailability,
+ runtimeRequestSubmission,
+ transcriptRole,
+ type TranscriptEntry,
+} from "./transcript-model";
+
+const FULL: LiveCapabilities = {
+ resume: true,
+ typedEvents: true,
+ steering: true,
+ interruption: true,
+ structuredResult: true,
+ runtimeRequestResolution: true,
+ goals: true,
+ threadLineage: true,
+};
+
+function seed(runId: string, sessionId: string): SessionSnapshot {
+ return createSessionSnapshotFromMetadata({
+ fixtureName: "live-console-demo",
+ identity: {
+ schema: "paperclip.prp.identity.v1",
+ companyId: "package-local-demo",
+ issueId: "live-console-demo",
+ runId,
+ environmentLeaseId: "package-local",
+ runnerInstanceId: "live-console-demo-server",
+ normalizedSessionId: sessionId,
+ driverSessionId: `thread-${sessionId}`,
+ },
+ capabilities: {
+ schema: "paperclip.prp.capabilities.v1",
+ sessionReusePolicy: "reuse_per_issue",
+ driver: { kind: "live-console-demo", version: "1" },
+ steer: true,
+ interrupt: true,
+ resume: true,
+ runtimeRequests: true,
+ structuredResult: true,
+ typedEvents: true,
+ },
+ });
+}
+
+async function drive(
+ manifestId: string,
+ interact?: (session: HarnessSession, turnId: string) => Promise,
+): Promise<{ events: PrpEvent[]; snapshot: SessionSnapshot }> {
+ const driver = new LiveConsoleScriptedDriver({ manifestId, chunkDelayMs: 0 });
+ const session = await driver.openSession({
+ runId: `run-${manifestId}`,
+ normalizedSessionId: `session-${manifestId}`,
+ workingDirectory: "/demo",
+ });
+ const events: PrpEvent[] = [];
+ const pump = (async () => {
+ for await (const event of session.events()) events.push(event);
+ })();
+ const { turnId } = await session.startTurn({ message: { role: "user", text: "Go." } });
+ if (interact !== undefined) await interact(session, turnId);
+ await new Promise((resolve) => setTimeout(resolve, 60));
+ await session.close({ reason: "test" });
+ await pump;
+ return {
+ events,
+ snapshot: events.reduce(applyPrpEvent, seed(`run-${manifestId}`, `session-${manifestId}`)),
+ };
+}
+
+function kinds(entries: readonly TranscriptEntry[]): string[] {
+ return entries.map((entry) => (entry.kind === "item" ? `item:${entry.role}` : entry.kind));
+}
+
+describe("transcriptRole", () => {
+ it("maps canonical item kinds onto transcript roles", () => {
+ expect(transcriptRole("reasoning")).toBe("reasoning");
+ expect(transcriptRole("agentMessage")).toBe("assistant");
+ expect(transcriptRole("commandExecution")).toBe("tool");
+ expect(transcriptRole("steering_acknowledgement")).toBe("steering");
+ expect(transcriptRole("thread_lineage")).toBe("lineage");
+ expect(transcriptRole("something_new")).toBe("system");
+ });
+});
+
+describe("buildTranscript", () => {
+ it("orders entries by the reducer timeline and shows the submitted message", async () => {
+ const { events, snapshot } = await drive("completion");
+ const entries = buildTranscript(snapshot, events);
+ expect(kinds(entries)).toEqual([
+ "user",
+ "item:reasoning",
+ "item:tool",
+ "item:assistant",
+ "turn",
+ ]);
+ const user = entries[0];
+ expect(user.kind === "user" && user.text).toBe("Go.");
+ const tool = entries[2];
+ expect(tool.kind === "item" && tool.input).toBe("docs/live-console-protocol-server.md");
+ expect(tool.kind === "item" && tool.output).toContain("server-sent events");
+ });
+
+ it("marks an interrupted item and never claims it completed cleanly", async () => {
+ const { events, snapshot } = await drive("interrupt-during-tool", async (session, turnId) => {
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ await session.interrupt?.({ turnId });
+ });
+ const entries = buildTranscript(snapshot, events);
+ const tool = entries.find((entry) => entry.kind === "item" && entry.role === "tool");
+ expect(tool?.kind === "item" && tool.interrupted).toBe(true);
+ expect(tool?.kind === "item" && tool.output).toBeNull();
+ const turn = entries.find((entry) => entry.kind === "turn");
+ expect(turn?.kind === "turn" && turn.state).toBe("interrupted");
+ });
+
+ it("keeps a resolved request in the record with the chosen action", async () => {
+ const { events, snapshot } = await drive("approvals", async (session, turnId) => {
+ await new Promise((resolve) => setTimeout(resolve, 20));
+ await session.resolveRuntimeRequest?.({
+ requestId: "request-command",
+ turnId,
+ resolution: { action: "accept_for_session" },
+ });
+ });
+ const request = buildTranscript(snapshot, events).find(
+ (entry) => entry.kind === "request" && entry.requestId === "request-command",
+ );
+ expect(request?.kind === "request" && request.status).toBe("resolved");
+ expect(request?.kind === "request" && request.resolvedAction).toBe("accept_for_session");
+ expect(request?.kind === "request" && request.actions).toEqual([
+ "accept",
+ "accept_for_session",
+ "decline",
+ "cancel",
+ ]);
+ });
+
+ it("renders a failed item with its exact diagnostic", async () => {
+ const { events, snapshot } = await drive("failure");
+ const failed = buildTranscript(snapshot, events).find(
+ (entry) => entry.kind === "item" && entry.failure !== null,
+ );
+ expect(failed?.kind === "item" && failed.failure).toContain("cannot find module");
+ });
+
+ it("keeps a replayed prefix a real prefix of the finished transcript", async () => {
+ // Replay mode reduces events[0..n]. Stepping forward must only ever add
+ // entries in the same positions the live session produced.
+ const { events, snapshot } = await drive("completion");
+ const full = buildTranscript(snapshot, events);
+
+ for (let cut = 1; cut <= events.length; cut += 1) {
+ const prefix = events.slice(0, cut);
+ const stepped = buildTranscript(
+ prefix.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
+ prefix,
+ );
+ expect(stepped.length).toBeLessThanOrEqual(full.length);
+ stepped.forEach((entry, index) => {
+ expect(entry.key).toBe(full[index]?.key);
+ expect(entry.position).toBe(full[index]?.position);
+ });
+ }
+ expect(
+ buildTranscript(
+ events.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
+ events,
+ ),
+ ).toEqual(full);
+ });
+});
+
+describe("composerState", () => {
+ const base = {
+ connection: "connected" as const,
+ submitting: false,
+ interrupting: false,
+ activeTurnId: null,
+ sessionState: "running" as const,
+ };
+
+ it("shows exactly one state per protocol situation", () => {
+ expect(composerState(base)).toBe("idle");
+ expect(composerState({ ...base, submitting: true })).toBe("submitting");
+ expect(composerState({ ...base, activeTurnId: "turn-1" })).toBe("active-turn");
+ expect(composerState({ ...base, interrupting: true, activeTurnId: "turn-1" })).toBe(
+ "interrupting",
+ );
+ expect(composerState({ ...base, connection: "disconnected" })).toBe("disconnected");
+ expect(composerState({ ...base, sessionState: "closed" })).toBe("terminal");
+ });
+
+ it("puts a terminal session ahead of every other state", () => {
+ expect(
+ composerState({
+ ...base,
+ sessionState: "failed",
+ activeTurnId: "turn-1",
+ connection: "disconnected",
+ }),
+ ).toBe("terminal");
+ });
+});
+
+describe("goalAvailability", () => {
+ it("disables every verb with the exact diagnostic when goals are unsupported", () => {
+ const rows = goalAvailability(
+ {
+ ...FULL,
+ goals: false,
+ unsupported: ["Goal operations unsupported: app-server 0.132.0 does not advertise goals"],
+ },
+ null,
+ );
+ expect(rows).toHaveLength(5);
+ expect(rows.every((row) => !row.enabled)).toBe(true);
+ expect(rows[0]?.reason).toContain("does not advertise goals");
+ });
+
+ it("disables only the verbs that are invalid for the current goal state", () => {
+ const active = goalAvailability(FULL, "active");
+ expect(active.find((row) => row.operation === "pause")?.enabled).toBe(true);
+ expect(active.find((row) => row.operation === "resume")?.enabled).toBe(false);
+
+ const paused = goalAvailability(FULL, "paused");
+ expect(paused.find((row) => row.operation === "resume")?.enabled).toBe(true);
+ expect(paused.find((row) => row.operation === "pause")?.enabled).toBe(false);
+
+ const none = goalAvailability(FULL, null);
+ expect(none.find((row) => row.operation === "set")?.enabled).toBe(true);
+ expect(none.find((row) => row.operation === "clear")?.enabled).toBe(false);
+ });
+});
+
+describe("runtimeRequestSubmission", () => {
+ it("shapes each submit body the way its request kind is validated", async () => {
+ expect(runtimeRequestSubmission("user_input", "staging")).toEqual({
+ action: "submit",
+ answers: { answer: { answers: ["staging"] } },
+ });
+ expect(runtimeRequestSubmission("elicitation", "stable")).toEqual({
+ action: "submit",
+ content: { answer: "stable" },
+ });
+
+ // The driver is the authority on shape, so both bodies are checked against
+ // the real validator rather than against this test's expectations alone.
+ for (const [requestKind, answer] of [
+ ["user_input", "staging"],
+ ["elicitation", "stable"],
+ ] as const) {
+ expect(() =>
+ parseHarnessRuntimeRequestResolution(
+ requestKind,
+ runtimeRequestSubmission(requestKind, answer),
+ )).not.toThrow();
+ // ...and the other kind's body is rejected, which is what the browser
+ // used to send for elicitation.
+ const wrongKind = requestKind === "user_input" ? "elicitation" : "user_input";
+ expect(() =>
+ parseHarnessRuntimeRequestResolution(
+ requestKind,
+ runtimeRequestSubmission(wrongKind, answer),
+ )).toThrow(/rejected its resolution/);
+ }
+ });
+});
+
+describe("capabilityRows", () => {
+ it("gives every unsupported capability a diagnostic string", () => {
+ const rows = capabilityRows({ ...FULL, goals: false, usage: false });
+ expect(rows.filter((row) => !row.supported).every((row) => row.diagnostic !== null)).toBe(true);
+ expect(rows.find((row) => row.name === "steer")?.supported).toBe(true);
+ });
+});
diff --git a/packages/paperclip-runner/devtools/browser/src/live/transcript-model.ts b/packages/paperclip-runner/devtools/browser/src/live/transcript-model.ts
new file mode 100644
index 0000000000..9f0d853bcf
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/live/transcript-model.ts
@@ -0,0 +1,402 @@
+import type { PrpEvent } from "../../../../src/protocol/replay-contract";
+import type {
+ SessionItemSnapshot,
+ SessionSnapshot,
+} from "../../../../src/reducer/session-reducer";
+import type { GoalOperation, LiveCapabilities } from "./protocol";
+
+/**
+ * A projection of reducer state plus the canonical events that produced it.
+ * It never stores anything the protocol did not send, so a replayed session
+ * and a live session with the same events project identically.
+ */
+export type TranscriptRole =
+ | "user"
+ | "assistant"
+ | "reasoning"
+ | "tool"
+ | "steering"
+ | "interrupt"
+ | "goal"
+ | "lineage"
+ | "system";
+
+export interface TranscriptItemEntry {
+ kind: "item";
+ key: string;
+ position: number;
+ role: TranscriptRole;
+ item: SessionItemSnapshot;
+ interrupted: boolean;
+ streaming: boolean;
+ toolName: string | null;
+ input: string | null;
+ output: string | null;
+ failure: string | null;
+}
+
+export interface TranscriptUserEntry {
+ kind: "user";
+ key: string;
+ position: number;
+ turnId: string | null;
+ text: string;
+}
+
+export interface TranscriptRequestEntry {
+ kind: "request";
+ key: string;
+ position: number;
+ requestId: string;
+ requestKind: string;
+ type: string;
+ prompt: string;
+ status: "pending" | "resolved" | "expired" | "cancelled";
+ actions: string[];
+ details: Record;
+ resolvedAction: string | null;
+ resolvedAt: string | null;
+ turnId: string | null;
+}
+
+export interface TranscriptTurnEntry {
+ kind: "turn";
+ key: string;
+ position: number;
+ turnId: string | null;
+ state: "completed" | "failed" | "interrupted" | "cancelled";
+ detail: string;
+}
+
+export interface TranscriptDiagnosticEntry {
+ kind: "diagnostic";
+ key: string;
+ position: number;
+ message: string;
+ code: string | null;
+}
+
+export type TranscriptEntry =
+ | TranscriptItemEntry
+ | TranscriptUserEntry
+ | TranscriptRequestEntry
+ | TranscriptTurnEntry
+ | TranscriptDiagnosticEntry;
+
+const TOOL_KINDS = new Set([
+ "commandExecution",
+ "command_execution",
+ "fileChange",
+ "file_change",
+ "mcpToolCall",
+ "tool",
+ "webSearch",
+ "patchApply",
+]);
+
+const ASSISTANT_KINDS = new Set(["agentMessage", "assistant", "assistantMessage", "message"]);
+
+export function transcriptRole(kind: string): TranscriptRole {
+ if (kind === "reasoning") return "reasoning";
+ if (ASSISTANT_KINDS.has(kind)) return "assistant";
+ if (TOOL_KINDS.has(kind)) return "tool";
+ if (kind === "steering_acknowledgement") return "steering";
+ if (kind === "interrupt_acknowledgement") return "interrupt";
+ if (kind === "goal" || kind === "goal_cleared") return "goal";
+ if (kind === "thread_lineage") return "lineage";
+ return "system";
+}
+
+function payloadRecord(value: unknown): Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value)
+ ? (value as Record)
+ : {};
+}
+
+function payloadText(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+interface ItemFacts {
+ interrupted: boolean;
+ toolName: string | null;
+ input: string | null;
+ output: string | null;
+ failure: string | null;
+ terminal: boolean;
+}
+
+function collectItemFacts(events: readonly PrpEvent[]): Map {
+ const facts = new Map();
+ for (const event of events) {
+ if (event.itemId === undefined || !event.eventType.startsWith("item.")) continue;
+ const payload = payloadRecord(event.payload);
+ const current = facts.get(event.itemId) ?? {
+ interrupted: false,
+ toolName: null,
+ input: null,
+ output: null,
+ failure: null,
+ terminal: false,
+ };
+ if (payload.interrupted === true || payload.status === "interrupted") {
+ current.interrupted = true;
+ }
+ current.toolName = payloadText(payload.toolName) ?? current.toolName;
+ current.input = payloadText(payload.input) ?? current.input;
+ current.output = payloadText(payload.output) ?? current.output;
+ if (event.eventType === "item.failed") {
+ current.failure = payloadText(payload.message) ?? "The item failed.";
+ }
+ if (event.eventType === "item.completed" || event.eventType === "item.failed") {
+ current.terminal = true;
+ }
+ facts.set(event.itemId, current);
+ }
+ return facts;
+}
+
+/**
+ * Builds the submit body a request kind actually accepts. Elicitation answers
+ * travel as `content` and user-input answers as `answers`; the runner validates
+ * both against the request kind, so sending the wrong one fails closed rather
+ * than resolving the request with an empty response.
+ */
+export function runtimeRequestSubmission(
+ requestKind: string,
+ answer: string,
+): Record {
+ if (requestKind === "elicitation") {
+ return { action: "submit", content: { answer } };
+ }
+ return { action: "submit", answers: { answer: { answers: [answer] } } };
+}
+
+function requestActions(details: unknown): string[] {
+ const record = payloadRecord(details);
+ return Array.isArray(record.actions)
+ ? record.actions.filter((action): action is string => typeof action === "string")
+ : [];
+}
+
+/**
+ * Builds the ordered transcript. Order comes from the reducer timeline, which
+ * is authoritative; nothing is re-sorted client-side.
+ */
+export function buildTranscript(
+ snapshot: SessionSnapshot,
+ events: readonly PrpEvent[],
+): TranscriptEntry[] {
+ const facts = collectItemFacts(events);
+ const items = new Map(snapshot.items.map((item) => [item.itemId, item]));
+ const seenItems = new Set();
+ const entries: TranscriptEntry[] = [];
+ const eventsById = new Map(events.map((event) => [event.sourceEventId, event]));
+
+ for (const timelineEntry of snapshot.timeline) {
+ const event = eventsById.get(timelineEntry.sourceEventId);
+ const payload = payloadRecord(event?.payload);
+ const position = timelineEntry.position;
+
+ if (timelineEntry.eventType === "turn.submitted") {
+ entries.push({
+ kind: "user",
+ key: `user:${timelineEntry.sourceEventId}`,
+ position,
+ turnId: typeof payload.turnId === "string" ? payload.turnId : null,
+ text: payloadText(payload.text) ?? "(no message text in the canonical event)",
+ });
+ continue;
+ }
+
+ if (
+ ["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(
+ timelineEntry.eventType,
+ )
+ ) {
+ const state = timelineEntry.eventType.split(".").at(-1) as TranscriptTurnEntry["state"];
+ entries.push({
+ kind: "turn",
+ key: `turn:${timelineEntry.sourceEventId}`,
+ position,
+ turnId: typeof payload.turnId === "string" ? payload.turnId : null,
+ state,
+ detail:
+ payloadText(payload.message) ??
+ payloadText(payload.reason) ??
+ timelineEntry.summary,
+ });
+ continue;
+ }
+
+ if (timelineEntry.eventType === "runtime_request.created") {
+ const request = payloadRecord(payload.request ?? payload);
+ const requestId = typeof request.requestId === "string" ? request.requestId : "";
+ const projected = snapshot.requests.find(
+ (candidate) => candidate.requestId === requestId,
+ );
+ const resolution = events.find(
+ (candidate) =>
+ candidate.eventType.startsWith("runtime_request.") &&
+ candidate.eventType !== "runtime_request.created" &&
+ payloadRecord(candidate.payload).requestId === requestId,
+ );
+ entries.push({
+ kind: "request",
+ key: `request:${requestId}`,
+ position,
+ requestId,
+ requestKind: typeof request.requestKind === "string" ? request.requestKind : "runtime",
+ type: typeof request.type === "string" ? request.type : "runtime",
+ prompt: payloadText(request.prompt) ?? "Runtime request",
+ status: (projected?.status ?? "pending") as TranscriptRequestEntry["status"],
+ actions: requestActions(request),
+ details: payloadRecord(request.details),
+ resolvedAction: payloadText(payloadRecord(resolution?.payload).action),
+ resolvedAt: resolution?.emittedAt ?? null,
+ turnId: typeof request.turnId === "string" ? request.turnId : null,
+ });
+ continue;
+ }
+
+ if (
+ timelineEntry.eventType === "harness.diagnostic" ||
+ timelineEntry.eventType === "runner.diagnostic"
+ ) {
+ entries.push({
+ kind: "diagnostic",
+ key: `diagnostic:${timelineEntry.sourceEventId}`,
+ position,
+ message: payloadText(payload.message) ?? timelineEntry.summary,
+ code: payloadText(payload.code),
+ });
+ continue;
+ }
+
+ const itemId = timelineEntry.itemId;
+ if (itemId === undefined || seenItems.has(itemId)) continue;
+ const item = items.get(itemId);
+ if (item === undefined) continue;
+ seenItems.add(itemId);
+ const itemFacts = facts.get(itemId);
+ entries.push({
+ kind: "item",
+ key: `item:${itemId}`,
+ position,
+ role: transcriptRole(item.kind),
+ item,
+ interrupted: itemFacts?.interrupted ?? false,
+ streaming: item.status === "running" && itemFacts?.terminal !== true,
+ toolName: itemFacts?.toolName ?? null,
+ input: itemFacts?.input ?? null,
+ output: itemFacts?.output ?? null,
+ failure: itemFacts?.failure ?? null,
+ });
+ }
+
+ return entries;
+}
+
+export type ComposerState =
+ | "idle"
+ | "submitting"
+ | "active-turn"
+ | "interrupting"
+ | "disconnected"
+ | "terminal";
+
+export interface ComposerStateInput {
+ connection: "idle" | "connecting" | "connected" | "reconnecting" | "disconnected" | "terminal";
+ submitting: boolean;
+ interrupting: boolean;
+ activeTurnId: string | null;
+ sessionState: SessionSnapshot["sessionState"];
+}
+
+/** One state machine with exactly one visible primary action. */
+export function composerState(input: ComposerStateInput): ComposerState {
+ if (input.sessionState === "closed" || input.sessionState === "failed") return "terminal";
+ if (input.connection === "disconnected" || input.connection === "reconnecting") {
+ return "disconnected";
+ }
+ if (input.interrupting) return "interrupting";
+ if (input.activeTurnId !== null) return "active-turn";
+ if (input.submitting) return "submitting";
+ return "idle";
+}
+
+export interface GoalOperationAvailability {
+ operation: GoalOperation;
+ enabled: boolean;
+ reason: string | null;
+}
+
+const GOAL_UNSUPPORTED_FALLBACK =
+ "Goal operations unsupported: this app-server does not advertise the goals capability";
+
+/**
+ * Capability gating for the goal menu. An unsupported verb is disabled with
+ * the exact upstream diagnostic — never hidden and never emulated.
+ */
+export function goalAvailability(
+ capabilities: LiveCapabilities,
+ goalStatus: string | null,
+): GoalOperationAvailability[] {
+ const unsupported = capabilities.unsupported ?? [];
+ if (capabilities.goals !== true) {
+ const reason = unsupported[0] ?? GOAL_UNSUPPORTED_FALLBACK;
+ return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => ({
+ operation,
+ enabled: false,
+ reason,
+ }));
+ }
+ return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => {
+ if (operation === "set") return { operation, enabled: true, reason: null };
+ if (goalStatus === null) {
+ return {
+ operation,
+ enabled: false,
+ reason: "No goal is set on this thread yet.",
+ };
+ }
+ if (operation === "pause" && goalStatus !== "active") {
+ return { operation, enabled: false, reason: `The goal is already ${goalStatus}.` };
+ }
+ if (operation === "resume" && goalStatus === "active") {
+ return { operation, enabled: false, reason: "The goal is already active." };
+ }
+ return { operation, enabled: true, reason: null };
+ });
+}
+
+export interface CapabilityRow {
+ name: string;
+ supported: boolean;
+ diagnostic: string | null;
+}
+
+export function capabilityRows(capabilities: LiveCapabilities): CapabilityRow[] {
+ const unsupported = capabilities.unsupported ?? [];
+ const rows: Array<[string, boolean | undefined]> = [
+ ["steer", capabilities.steering],
+ ["interrupt", capabilities.interruption],
+ ["resume", capabilities.resume],
+ ["runtime requests", capabilities.runtimeRequestResolution],
+ ["runtime request handoff", capabilities.runtimeRequestHandoff],
+ ["goals", capabilities.goals],
+ ["child threads", capabilities.threadLineage],
+ ["structured result", capabilities.structuredResult],
+ ["typed events", capabilities.typedEvents],
+ ["usage", capabilities.usage],
+ ];
+ return rows.map(([name, supported]) => ({
+ name,
+ supported: supported === true,
+ diagnostic:
+ supported === true
+ ? null
+ : (unsupported.find((entry) => entry.toLowerCase().includes(name.split(" ")[0]!)) ??
+ `${name} is not advertised by this app-server`),
+ }));
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/live/use-live-console.test.ts b/packages/paperclip-runner/devtools/browser/src/live/use-live-console.test.ts
new file mode 100644
index 0000000000..43d7b46fac
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/live/use-live-console.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from "vitest";
+
+import type { PrpEvent } from "../../../../src/protocol/replay-contract";
+import {
+ acceptLiveEvent,
+ createLiveEventCursor,
+} from "./use-live-console";
+
+function event(sourceEventId: string): PrpEvent {
+ return { sourceEventId } as PrpEvent;
+}
+
+describe("Live console reconnect cursor", () => {
+ it("does not advance past an unseen event when replay delivers a duplicate", () => {
+ const first = event("source:event-1");
+ const second = event("source:event-2");
+ const cursor = createLiveEventCursor([first], 1);
+
+ expect(acceptLiveEvent(cursor, first)).toBe(false);
+ expect(cursor.cursor).toBe(1);
+
+ expect(acceptLiveEvent(cursor, second)).toBe(true);
+ expect(cursor.cursor).toBe(2);
+ });
+});
diff --git a/packages/paperclip-runner/devtools/browser/src/live/use-live-console.ts b/packages/paperclip-runner/devtools/browser/src/live/use-live-console.ts
new file mode 100644
index 0000000000..a2c6a903a9
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/live/use-live-console.ts
@@ -0,0 +1,579 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+
+import type { PrpEvent } from "../../../../src/protocol/replay-contract";
+import {
+ applyPrpEvent,
+ createSessionSnapshotFromMetadata,
+ type SessionSnapshot,
+} from "../../../../src/reducer/session-reducer";
+import * as client from "./client";
+import type {
+ GoalOperation,
+ LiveConnectionStatus,
+ LiveManifestSummary,
+ LiveSessionState,
+} from "./protocol";
+import {
+ buildTranscript,
+ composerState,
+ type ComposerState,
+ type TranscriptEntry,
+} from "./transcript-model";
+
+const SESSION_STORAGE_KEY = "paperclip-runner.live-console.session";
+const RECONNECT_DELAY_MS = 900;
+const MAX_RECONNECT_ATTEMPTS = 5;
+
+export interface LiveEventCursor {
+ cursor: number;
+ seenSourceEventIds: Set;
+}
+
+export function createLiveEventCursor(
+ events: readonly PrpEvent[] = [],
+ cursor = 0,
+): LiveEventCursor {
+ return {
+ cursor,
+ seenSourceEventIds: new Set(events.map((event) => event.sourceEventId)),
+ };
+}
+
+export function acceptLiveEvent(cursor: LiveEventCursor, event: PrpEvent): boolean {
+ if (cursor.seenSourceEventIds.has(event.sourceEventId)) return false;
+ cursor.seenSourceEventIds.add(event.sourceEventId);
+ cursor.cursor += 1;
+ return true;
+}
+
+export type SteeringChipStatus = "pending" | "acknowledged" | "rejected" | "failed";
+
+export interface SteeringChip {
+ id: string;
+ text: string;
+ expectedTurnId: string;
+ status: SteeringChipStatus;
+ detail: string | null;
+}
+
+function reduceEvents(
+ state: LiveSessionState | null,
+ events: readonly PrpEvent[],
+): SessionSnapshot | null {
+ if (state === null) return null;
+ const seed = createSessionSnapshotFromMetadata({
+ fixtureName: state.snapshot.fixtureName,
+ identity: state.snapshot.identity,
+ capabilities: state.snapshot.capabilities,
+ });
+ return events.reduce(applyPrpEvent, seed);
+}
+
+function errorMessage(cause: unknown): string {
+ if (cause instanceof client.LiveConsoleError) return cause.message;
+ return cause instanceof Error ? cause.message : String(cause);
+}
+
+export interface LiveConsole {
+ manifests: LiveManifestSummary[];
+ selectedManifestId: string;
+ selectManifest: (id: string) => void;
+ state: LiveSessionState | null;
+ events: PrpEvent[];
+ snapshot: SessionSnapshot | null;
+ transcript: TranscriptEntry[];
+ connection: LiveConnectionStatus;
+ reconnectAttempt: number;
+ replayParity: boolean | null;
+ composer: ComposerState;
+ steeringChips: SteeringChip[];
+ announcement: string;
+ error: string | null;
+ busy: boolean;
+ selectedThreadId: string | null;
+ selectThread: (threadId: string | null) => void;
+ replay: {
+ active: boolean;
+ position: number;
+ length: number;
+ playing: boolean;
+ enter: () => void;
+ exit: () => void;
+ setPosition: (position: number) => void;
+ togglePlay: () => void;
+ step: (delta: number) => void;
+ };
+ start: (input: { manifestId: string; message: string }) => Promise;
+ send: (text: string) => Promise;
+ steer: (text: string) => Promise;
+ interrupt: () => Promise;
+ resolve: (
+ requestId: string,
+ turnId: string,
+ resolution: Record,
+ ) => Promise;
+ goal: (operation: GoalOperation, body?: Record) => Promise;
+ dropConnection: () => void;
+ retryNow: () => void;
+ reset: () => Promise;
+ dismissSteeringChip: (id: string) => void;
+}
+
+export function useLiveConsole(): LiveConsole {
+ const [manifests, setManifests] = useState([]);
+ const [selectedManifestId, setSelectedManifestId] = useState("completion");
+ const [state, setState] = useState(null);
+ const [events, setEvents] = useState([]);
+ const [connection, setConnection] = useState("idle");
+ const [reconnectAttempt, setReconnectAttempt] = useState(0);
+ const [steeringChips, setSteeringChips] = useState([]);
+ const [announcement, setAnnouncement] = useState("");
+ const [error, setError] = useState(null);
+ const [busy, setBusy] = useState(false);
+ const [interrupting, setInterrupting] = useState(false);
+ const [submitting, setSubmitting] = useState(false);
+ const [selectedThreadId, setSelectedThreadId] = useState(null);
+ const [replayActive, setReplayActive] = useState(false);
+ const [replayPosition, setReplayPosition] = useState(0);
+ const [replayPlaying, setReplayPlaying] = useState(false);
+
+ const streamRef = useRef(null);
+ const eventCursorRef = useRef(createLiveEventCursor());
+ const sessionIdRef = useRef(null);
+ const retryTimerRef = useRef | null>(null);
+
+ const closeStream = useCallback(() => {
+ streamRef.current?.close();
+ streamRef.current = null;
+ if (retryTimerRef.current !== null) {
+ clearTimeout(retryTimerRef.current);
+ retryTimerRef.current = null;
+ }
+ }, []);
+
+ const subscribe = useCallback(
+ (sessionId: string, attempt = 0) => {
+ closeStream();
+ setConnection(attempt === 0 ? "connecting" : "reconnecting");
+ setReconnectAttempt(attempt);
+ streamRef.current = client.openEventStream(sessionId, eventCursorRef.current.cursor, {
+ onOpen: () => {
+ setConnection("connected");
+ setReconnectAttempt(0);
+ setAnnouncement("Connected");
+ },
+ onEvent: (event) => {
+ if (!acceptLiveEvent(eventCursorRef.current, event)) return;
+ setEvents((current) => [...current, event]);
+ },
+ onError: () => {
+ closeStream();
+ if (attempt >= MAX_RECONNECT_ATTEMPTS) {
+ setConnection("disconnected");
+ setAnnouncement("Disconnected");
+ return;
+ }
+ setConnection("reconnecting");
+ setReconnectAttempt(attempt + 1);
+ setAnnouncement(`Reconnecting, attempt ${attempt + 1}`);
+ retryTimerRef.current = setTimeout(
+ () => subscribe(sessionId, attempt + 1),
+ RECONNECT_DELAY_MS,
+ );
+ },
+ });
+ },
+ [closeStream],
+ );
+
+ const adopt = useCallback(
+ (next: LiveSessionState) => {
+ setState(next);
+ setError(null);
+ },
+ [],
+ );
+
+ const attach = useCallback(
+ async (sessionId: string, resume: boolean) => {
+ const [next, history] = await Promise.all([
+ client.readSession(sessionId),
+ client.readEvents(sessionId, 0),
+ ]);
+ sessionIdRef.current = sessionId;
+ window.sessionStorage.setItem(SESSION_STORAGE_KEY, sessionId);
+ adopt(next);
+ setEvents(history.events);
+ eventCursorRef.current = createLiveEventCursor(history.events, history.cursor);
+ if (resume) setAnnouncement("Replayed the durable transcript");
+ subscribe(sessionId, 0);
+ },
+ [adopt, subscribe],
+ );
+
+ useEffect(() => {
+ let cancelled = false;
+ void client
+ .fetchManifests()
+ .then((list) => {
+ if (cancelled) return;
+ setManifests(list);
+ if (list[0] !== undefined) setSelectedManifestId((current) => current || list[0]!.id);
+ })
+ .catch((cause) => {
+ if (!cancelled) setError(errorMessage(cause));
+ });
+ const stored = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
+ if (stored !== null) {
+ void attach(stored, true).catch(() => {
+ window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
+ });
+ }
+ return () => {
+ cancelled = true;
+ };
+ // Runs once: session restoration is a mount concern.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => () => closeStream(), [closeStream]);
+
+ // Lineage, goal, pending requests, and the server cursor live in the public
+ // state, so a stream that only carries events would leave them stale.
+ useEffect(() => {
+ const sessionId = sessionIdRef.current;
+ if (sessionId === null || events.length === 0) return undefined;
+ const timer = setTimeout(() => {
+ void client
+ .readSession(sessionId)
+ .then((next) => setState(next))
+ .catch(() => undefined);
+ }, 150);
+ return () => clearTimeout(timer);
+ }, [events.length]);
+
+ const liveSnapshot = useMemo(() => reduceEvents(state, events), [state, events]);
+ const visibleEvents = useMemo(
+ () => (replayActive ? events.slice(0, replayPosition) : events),
+ [events, replayActive, replayPosition],
+ );
+ const snapshot = useMemo(
+ () => (replayActive ? reduceEvents(state, visibleEvents) : liveSnapshot),
+ [replayActive, state, visibleEvents, liveSnapshot],
+ );
+
+ const replayParity = useMemo(() => {
+ if (state === null || liveSnapshot === null) return null;
+ if (state.cursor !== events.length) return null;
+ return (
+ JSON.stringify(liveSnapshot.timeline) === JSON.stringify(state.snapshot.timeline) &&
+ liveSnapshot.integrity === state.snapshot.integrity
+ );
+ }, [state, liveSnapshot, events.length]);
+
+ const transcript = useMemo(
+ () => (snapshot === null ? [] : buildTranscript(snapshot, visibleEvents)),
+ [snapshot, visibleEvents],
+ );
+
+ useEffect(() => {
+ if (!replayPlaying) return undefined;
+ if (replayPosition >= events.length) {
+ setReplayPlaying(false);
+ return undefined;
+ }
+ const timer = setTimeout(() => setReplayPosition((current) => current + 1), 140);
+ return () => clearTimeout(timer);
+ }, [replayPlaying, replayPosition, events.length]);
+
+ // Steering chips resolve against canonical acknowledgements only.
+ useEffect(() => {
+ setSteeringChips((chips) => {
+ if (chips.every((chip) => chip.status !== "pending")) return chips;
+ const acknowledged = events.filter(
+ (event) =>
+ event.eventType === "item.completed" &&
+ (event.payload as { kind?: string }).kind === "steering_acknowledgement",
+ );
+ let index = 0;
+ return chips.map((chip) => {
+ if (chip.status !== "pending") return chip;
+ const match = acknowledged[index];
+ index += 1;
+ return match === undefined
+ ? chip
+ : { ...chip, status: "acknowledged" as const, detail: null };
+ });
+ });
+ }, [events]);
+
+ // The reducer is authoritative for the active turn. Falling back to the
+ // last public-state read would keep a finished turn alive in the composer.
+ const activeTurnId = snapshot === null ? (state?.activeTurnId ?? null) : snapshot.activeTurnId;
+
+ // A submitted turn that has not been accepted yet is a protocol fact, not a
+ // local flag: it is what makes interrupt-before-start reachable.
+ const awaitingTurnStart = useMemo(() => {
+ let pending = false;
+ for (const entry of snapshot?.timeline ?? []) {
+ if (entry.eventType === "turn.submitted") pending = true;
+ else if (entry.eventType.startsWith("turn.")) pending = false;
+ }
+ return pending;
+ }, [snapshot]);
+
+ const composer = composerState({
+ connection,
+ submitting: submitting || awaitingTurnStart,
+ interrupting,
+ activeTurnId,
+ sessionState: snapshot?.sessionState ?? "not_started",
+ });
+
+ const guard = useCallback(async (task: () => Promise) => {
+ setBusy(true);
+ try {
+ await task();
+ } catch (cause) {
+ setError(errorMessage(cause));
+ } finally {
+ setBusy(false);
+ }
+ }, []);
+
+ const start = useCallback(
+ async (input: { manifestId: string; message: string }) => {
+ await guard(async () => {
+ closeStream();
+ setEvents([]);
+ eventCursorRef.current = createLiveEventCursor();
+ setSteeringChips([]);
+ setReplayActive(false);
+ setReplayPosition(0);
+ setSelectedThreadId(null);
+ setSubmitting(true);
+ const manifest = manifests.find((entry) => entry.id === input.manifestId);
+ try {
+ const created = await client.createSession({
+ manifest: input.manifestId,
+ objective: manifest?.objective ?? "Run the Live console demo.",
+ message: input.message,
+ });
+ sessionIdRef.current = created.sessionId;
+ window.sessionStorage.setItem(SESSION_STORAGE_KEY, created.sessionId);
+ adopt(created);
+ setAnnouncement("Turn running");
+ subscribe(created.sessionId, 0);
+ } finally {
+ setSubmitting(false);
+ }
+ });
+ },
+ [adopt, closeStream, guard, manifests, subscribe],
+ );
+
+ const refreshState = useCallback(async () => {
+ const sessionId = sessionIdRef.current;
+ if (sessionId === null) return;
+ adopt(await client.readSession(sessionId));
+ }, [adopt]);
+
+ const send = useCallback(
+ async (text: string) => {
+ const sessionId = sessionIdRef.current;
+ if (sessionId === null) {
+ await start({ manifestId: selectedManifestId, message: text });
+ return;
+ }
+ await guard(async () => {
+ setSubmitting(true);
+ try {
+ adopt(await client.startTurn(sessionId, text));
+ setAnnouncement("Turn running");
+ } finally {
+ setSubmitting(false);
+ }
+ });
+ },
+ [adopt, guard, selectedManifestId, start],
+ );
+
+ const steer = useCallback(
+ async (text: string) => {
+ const sessionId = sessionIdRef.current;
+ const expectedTurnId = activeTurnId;
+ if (sessionId === null || expectedTurnId === null) return;
+ const chipId = `steer-${Date.now()}-${steeringChips.length}`;
+ setSteeringChips((chips) => [
+ ...chips,
+ { id: chipId, text, expectedTurnId, status: "pending", detail: null },
+ ]);
+ try {
+ adopt(await client.steerTurn(sessionId, expectedTurnId, text));
+ setAnnouncement("Steering sent");
+ } catch (cause) {
+ const stale =
+ cause instanceof client.LiveConsoleError &&
+ (cause.code === "stale_turn" || cause.code === "already_terminal");
+ setSteeringChips((chips) =>
+ chips.map((chip) =>
+ chip.id === chipId
+ ? {
+ ...chip,
+ status: stale ? "rejected" : "failed",
+ detail: stale ? "turn already ended" : errorMessage(cause),
+ }
+ : chip,
+ ),
+ );
+ setAnnouncement(stale ? "Steering rejected: turn already ended" : "Steering failed");
+ await refreshState().catch(() => undefined);
+ }
+ },
+ [activeTurnId, adopt, refreshState, steeringChips.length],
+ );
+
+ const interrupt = useCallback(async () => {
+ const sessionId = sessionIdRef.current;
+ if (sessionId === null) return;
+ setInterrupting(true);
+ setAnnouncement("Stopping");
+ try {
+ adopt(await client.interruptTurn(sessionId, activeTurnId));
+ setAnnouncement("Turn stopped");
+ } catch (cause) {
+ setError(errorMessage(cause));
+ } finally {
+ setInterrupting(false);
+ }
+ }, [activeTurnId, adopt]);
+
+ const resolve = useCallback(
+ async (requestId: string, turnId: string, resolution: Record) => {
+ const sessionId = sessionIdRef.current;
+ if (sessionId === null) return;
+ await guard(async () => {
+ adopt(await client.resolveRequest(sessionId, requestId, turnId, resolution));
+ setAnnouncement("Request resolved");
+ });
+ },
+ [adopt, guard],
+ );
+
+ const goal = useCallback(
+ async (operation: GoalOperation, body: Record = {}) => {
+ const sessionId = sessionIdRef.current;
+ if (sessionId === null) return;
+ await guard(async () => {
+ adopt(await client.goalOperation(sessionId, operation, body));
+ setAnnouncement(`Goal ${operation}`);
+ });
+ },
+ [adopt, guard],
+ );
+
+ const dropConnection = useCallback(() => {
+ closeStream();
+ setConnection("disconnected");
+ setAnnouncement("Connection lost");
+ }, [closeStream]);
+
+ const retryNow = useCallback(() => {
+ const sessionId = sessionIdRef.current;
+ if (sessionId === null) return;
+ void guard(async () => {
+ const resumed = await client.reconnectSession(sessionId);
+ adopt(resumed);
+ const history = await client.readEvents(sessionId, 0);
+ setEvents(history.events);
+ eventCursorRef.current = createLiveEventCursor(history.events, history.cursor);
+ subscribe(sessionId, 0);
+ setAnnouncement("Resumed the same session");
+ });
+ }, [adopt, guard, subscribe]);
+
+ const reset = useCallback(async () => {
+ const sessionId = sessionIdRef.current;
+ closeStream();
+ if (sessionId !== null) {
+ try {
+ await client.closeSession(sessionId);
+ } catch (cause) {
+ setConnection("disconnected");
+ setError(errorMessage(cause));
+ setAnnouncement("Demo reset failed");
+ return;
+ }
+ }
+ window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
+ sessionIdRef.current = null;
+ eventCursorRef.current = createLiveEventCursor();
+ setState(null);
+ setEvents([]);
+ setSteeringChips([]);
+ setConnection("idle");
+ setReplayActive(false);
+ setReplayPosition(0);
+ setSelectedThreadId(null);
+ setError(null);
+ setAnnouncement("Demo state reset");
+ }, [closeStream]);
+
+ const replay = useMemo(
+ () => ({
+ active: replayActive,
+ position: replayPosition,
+ length: events.length,
+ playing: replayPlaying,
+ enter: () => {
+ setReplayActive(true);
+ setReplayPosition(0);
+ setReplayPlaying(false);
+ },
+ exit: () => {
+ setReplayActive(false);
+ setReplayPlaying(false);
+ },
+ setPosition: (position: number) =>
+ setReplayPosition(Math.min(Math.max(position, 0), events.length)),
+ togglePlay: () => setReplayPlaying((current) => !current),
+ step: (delta: number) =>
+ setReplayPosition((current) =>
+ Math.min(Math.max(current + delta, 0), events.length),
+ ),
+ }),
+ [events.length, replayActive, replayPlaying, replayPosition],
+ );
+
+ return {
+ manifests,
+ selectedManifestId,
+ selectManifest: setSelectedManifestId,
+ state,
+ events,
+ snapshot,
+ transcript,
+ connection,
+ reconnectAttempt,
+ replayParity,
+ composer,
+ steeringChips,
+ announcement,
+ error,
+ busy,
+ selectedThreadId,
+ selectThread: setSelectedThreadId,
+ replay,
+ start,
+ send,
+ steer,
+ interrupt,
+ resolve,
+ goal,
+ dropConnection,
+ retryNow,
+ reset,
+ dismissSteeringChip: (id: string) =>
+ setSteeringChips((chips) => chips.filter((chip) => chip.id !== id)),
+ };
+}
diff --git a/packages/paperclip-runner/devtools/browser/src/main.tsx b/packages/paperclip-runner/devtools/browser/src/main.tsx
new file mode 100644
index 0000000000..8bcbe49a01
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/main.tsx
@@ -0,0 +1,16 @@
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+
+import { App } from "./App";
+import "./styles.css";
+
+const root = document.getElementById("root");
+if (root === null) {
+ throw new Error("Browser replay root element is missing");
+}
+
+createRoot(root).render(
+
+
+ ,
+);
diff --git a/packages/paperclip-runner/devtools/browser/src/styles.css b/packages/paperclip-runner/devtools/browser/src/styles.css
new file mode 100644
index 0000000000..a06ecfa662
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/src/styles.css
@@ -0,0 +1,1430 @@
+:root {
+ color-scheme: light;
+ --background: #f7f7f5;
+ --foreground: #191917;
+ --card: #ffffff;
+ --card-foreground: #191917;
+ --muted: #efefeb;
+ --muted-foreground: #66665f;
+ --border: #deded7;
+ --primary: #242420;
+ --primary-foreground: #ffffff;
+ --success: #17633a;
+ --success-surface: #e9f5ed;
+ --warning: #8a5a10;
+ --warning-surface: #fff4dc;
+ --danger: #a02f2f;
+ --danger-surface: #fceaea;
+ /* Pending and streaming are not warnings; Live console needs their own pair. */
+ --accent: #2f5fa8;
+ --accent-surface: #e9f0fa;
+ --surface-raised: #fcfcfa;
+ --ring: #66665f;
+ --font-sans: Inter, ui-sans-serif, system-ui, sans-serif;
+ --font-mono: "SFMono-Regular", Consolas, "Liberation Mono", monospace;
+ --font-size-xs: 0.75rem;
+ --font-size-sm: 0.875rem;
+ --font-size-md: 1rem;
+ --font-size-lg: 1.25rem;
+ --font-size-xl: 1.75rem;
+ --line-tight: 1.2;
+ --line-normal: 1.5;
+ --weight-medium: 500;
+ --weight-semibold: 600;
+ --space-1: 0.25rem;
+ --space-2: 0.5rem;
+ --space-3: 0.75rem;
+ --space-4: 1rem;
+ --space-5: 1.25rem;
+ --space-6: 1.5rem;
+ --space-8: 2rem;
+ --space-10: 2.5rem;
+ --radius-sm: 0.375rem;
+ --radius-md: 0.625rem;
+ --shadow-sm: 0 0.0625rem 0.125rem rgb(0 0 0 / 0.05);
+ --editor-height: 32rem;
+ --page-width: 90rem;
+ --control-height: 2.5rem;
+ --timeline-column: 2rem;
+ --motion-fast: 120ms;
+ --motion-medium: 200ms;
+ --z-banner: 10;
+ --z-dialog: 20;
+ --rail-width: 17rem;
+ --center-width: 48rem;
+ --inspector-width: 24rem;
+ --transcript-height: 34rem;
+ --touch-target: 2.75rem;
+ --facet-height: 6rem;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ min-width: 20rem;
+ background: var(--background);
+ color: var(--foreground);
+ font-family: var(--font-sans);
+ font-size: var(--font-size-sm);
+ line-height: var(--line-normal);
+}
+
+button,
+input,
+select,
+textarea {
+ font: inherit;
+}
+
+h1,
+h2,
+h3,
+p {
+ margin: 0;
+}
+
+.app-shell {
+ width: min(100%, var(--page-width));
+ margin-inline: auto;
+ padding: var(--space-8);
+}
+
+.page-header,
+.snapshot-heading,
+.timeline-heading,
+.editor-actions,
+.timeline-title {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-4);
+}
+
+.page-header {
+ align-items: flex-start;
+ margin-bottom: var(--space-8);
+}
+
+.mode-switch,
+.scenario-actions,
+.live-status,
+.parity-line {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.mode-switch {
+ flex-wrap: wrap;
+ margin-bottom: var(--space-6);
+}
+
+.mode-switch .ui-button[aria-pressed="false"],
+.scenario-actions .ui-button + .ui-button {
+ background: var(--muted);
+ color: var(--muted-foreground);
+}
+
+.page-header > div {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.page-header h1 {
+ font-size: var(--font-size-xl);
+ line-height: var(--line-tight);
+ letter-spacing: -0.02em;
+}
+
+.page-header p:not(.eyebrow),
+.ui-card-description,
+.timeline-heading span,
+.editor-actions span,
+dt {
+ color: var(--muted-foreground);
+}
+
+.eyebrow {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.workspace-grid {
+ display: grid;
+ grid-template-columns: minmax(22rem, 0.8fr) minmax(28rem, 1.2fr);
+ gap: var(--space-8);
+ align-items: start;
+}
+
+.ui-card {
+ display: grid;
+ gap: var(--space-5);
+ padding: var(--space-6);
+ background: var(--card);
+ color: var(--card-foreground);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-md);
+ box-shadow: var(--shadow-sm);
+}
+
+.ui-card-header,
+.ui-card-content,
+.editor-content {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.ui-card-title,
+.snapshot-heading h2 {
+ font-size: var(--font-size-lg);
+ line-height: var(--line-tight);
+ font-weight: var(--weight-semibold);
+}
+
+.ui-card-description {
+ font-size: var(--font-size-sm);
+}
+
+.ui-button {
+ min-height: var(--control-height);
+ padding: var(--space-2) var(--space-4);
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: var(--primary);
+ color: var(--primary-foreground);
+ font-weight: var(--weight-medium);
+ cursor: pointer;
+ transition: opacity var(--motion-fast) ease;
+}
+
+.ui-button:hover {
+ opacity: 0.88;
+}
+
+.ui-button:disabled {
+ cursor: not-allowed;
+ opacity: 0.5;
+}
+
+.ui-button:focus-visible,
+select:focus-visible,
+.request-card input:focus-visible,
+.ui-textarea:focus-visible {
+ outline: 0.125rem solid var(--ring);
+ outline-offset: 0.125rem;
+}
+
+.ui-badge {
+ display: inline-flex;
+ align-items: center;
+ width: fit-content;
+ min-height: 1.625rem;
+ padding-inline: var(--space-2);
+ border-radius: 999rem;
+ background: var(--muted);
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+ text-transform: capitalize;
+ white-space: nowrap;
+}
+
+.ui-badge[data-tone="success"] {
+ background: var(--success-surface);
+ color: var(--success);
+}
+
+.ui-badge[data-tone="warning"] {
+ background: var(--warning-surface);
+ color: var(--warning);
+}
+
+.ui-badge[data-tone="danger"] {
+ background: var(--danger-surface);
+ color: var(--danger);
+}
+
+label,
+dt {
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+}
+
+select,
+.request-card input,
+.ui-textarea {
+ width: 100%;
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--card);
+ color: var(--card-foreground);
+}
+
+select {
+ min-height: var(--control-height);
+ padding: var(--space-2) var(--space-3);
+}
+
+.request-card input {
+ min-height: var(--control-height);
+ padding: var(--space-2) var(--space-3);
+}
+
+.ui-textarea {
+ min-height: var(--editor-height);
+ resize: vertical;
+ padding: var(--space-3);
+ font-family: var(--font-mono);
+ font-size: var(--font-size-xs);
+ line-height: var(--line-normal);
+ tab-size: 2;
+}
+
+.replay-panel {
+ display: grid;
+ gap: var(--space-6);
+}
+
+.live-status,
+.parity-line {
+ justify-content: space-between;
+ padding-block: var(--space-2);
+}
+
+.live-status > span,
+.parity-line > span {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+}
+
+.request-card {
+ display: grid;
+ gap: var(--space-3);
+ padding-block: var(--space-4);
+ border-block: 0.0625rem solid var(--border);
+}
+
+.request-card > div {
+ display: grid;
+ gap: var(--space-1);
+}
+
+.process-facts {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: var(--space-4);
+ margin: 0;
+ padding-block: var(--space-3);
+}
+
+.empty-live-state {
+ display: grid;
+ gap: var(--space-3);
+ padding-block: var(--space-6);
+}
+
+.empty-live-state h2 {
+ font-size: var(--font-size-lg);
+}
+
+.empty-live-state p:last-child {
+ color: var(--muted-foreground);
+}
+
+.snapshot-heading {
+ align-items: flex-start;
+}
+
+.snapshot-heading > div {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.snapshot-grid {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: var(--space-4);
+ margin: 0;
+ padding-block: var(--space-5);
+ border-block: 0.0625rem solid var(--border);
+}
+
+.snapshot-grid div {
+ min-width: 0;
+}
+
+dd {
+ margin: var(--space-1) 0 0;
+ overflow: hidden;
+ font-family: var(--font-mono);
+ font-size: var(--font-size-xs);
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.result-summary {
+ display: grid;
+ gap: var(--space-1);
+}
+
+.result-summary span {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+ text-transform: uppercase;
+}
+
+.result-summary p {
+ font-size: var(--font-size-md);
+}
+
+.diagnostic,
+.validation-errors {
+ padding: var(--space-4);
+ border-radius: var(--radius-sm);
+ background: var(--warning-surface);
+ color: var(--warning);
+}
+
+.validation-errors {
+ display: grid;
+ gap: var(--space-4);
+ background: var(--danger-surface);
+ color: var(--danger);
+}
+
+.validation-errors ul {
+ margin: 0;
+ padding-left: var(--space-5);
+}
+
+.timeline-heading {
+ margin-top: var(--space-2);
+}
+
+.timeline-heading h3 {
+ font-size: var(--font-size-md);
+}
+
+.timeline-heading span {
+ font-size: var(--font-size-xs);
+}
+
+.timeline {
+ display: grid;
+ gap: 0;
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.timeline li {
+ display: grid;
+ grid-template-columns: var(--timeline-column) minmax(0, 1fr);
+ gap: var(--space-3);
+ padding-block: var(--space-3);
+}
+
+.timeline li + li {
+ border-top: 0.0625rem solid var(--border);
+}
+
+.sequence {
+ display: inline-grid;
+ place-items: center;
+ align-self: start;
+ width: var(--timeline-column);
+ height: var(--timeline-column);
+ border-radius: 999rem;
+ background: var(--muted);
+ color: var(--muted-foreground);
+ font-family: var(--font-mono);
+ font-size: var(--font-size-xs);
+}
+
+.timeline-title code,
+.snapshot-json pre,
+.validation-errors code {
+ font-family: var(--font-mono);
+}
+
+.timeline-title code {
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+}
+
+.timeline-title time,
+.timeline li p {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+}
+
+.timeline li p {
+ margin-top: var(--space-1);
+}
+
+.snapshot-json {
+ border-top: 0.0625rem solid var(--border);
+ padding-top: var(--space-4);
+}
+
+.snapshot-json summary {
+ cursor: pointer;
+ font-weight: var(--weight-medium);
+}
+
+.snapshot-json pre {
+ max-height: var(--editor-height);
+ overflow: auto;
+ padding: var(--space-4);
+ border-radius: var(--radius-sm);
+ background: var(--muted);
+ font-size: var(--font-size-xs);
+}
+
+@media (max-width: 60rem) {
+ .workspace-grid {
+ grid-template-columns: 1fr;
+ }
+
+ .ui-textarea {
+ min-height: 20rem;
+ }
+}
+
+@media (max-width: 36rem) {
+ .app-shell {
+ padding: var(--space-5);
+ }
+
+ .page-header,
+ .snapshot-heading,
+ .timeline-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+
+ .snapshot-grid {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ :root {
+ --motion-fast: 0ms;
+ }
+}
+
+/* ---------------------------------------------------------------------------
+ Live console
+ Every value below comes from the token layer above. Adapted shadcn/ui and
+ AI Elements components carry no visual values of their own.
+ --------------------------------------------------------------------------- */
+
+.ui-badge[data-tone="accent"] {
+ background: var(--accent-surface);
+ color: var(--accent);
+}
+
+.ui-button--danger {
+ background: var(--danger);
+ color: var(--primary-foreground);
+}
+
+.ui-button--secondary {
+ background: var(--muted);
+ color: var(--foreground);
+}
+
+.ui-button--quiet {
+ background: transparent;
+ color: var(--foreground);
+ border: 0.0625rem solid var(--border);
+}
+
+.ui-visually-hidden {
+ position: absolute;
+ width: 0.0625rem;
+ height: 0.0625rem;
+ margin: -0.0625rem;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
+.live-console {
+ display: grid;
+ gap: var(--space-4);
+}
+
+.live-console-header,
+.live-console-title,
+.live-console-header-actions {
+ display: flex;
+ align-items: center;
+ gap: var(--space-3);
+}
+
+.live-console-header {
+ justify-content: space-between;
+ flex-wrap: wrap;
+}
+
+.live-console-title h2 {
+ font-size: var(--font-size-lg);
+ font-weight: var(--weight-semibold);
+}
+
+.ui-breadcrumb {
+ color: var(--muted-foreground);
+ font-family: var(--font-mono);
+ font-size: var(--font-size-xs);
+}
+
+.live-console-desktop {
+ display: grid;
+ grid-template-columns: var(--rail-width) minmax(0, 1fr);
+ gap: var(--space-5);
+ align-items: start;
+}
+
+.live-console[data-inspector="true"] .live-console-desktop {
+ grid-template-columns: var(--rail-width) minmax(0, 1fr) var(--inspector-width);
+}
+
+.live-console-rail,
+.live-console-inspector {
+ display: grid;
+ /* An auto track would size to the widest identifier and overflow the rail. */
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--space-4);
+ min-width: 0;
+}
+
+.live-console-center {
+ display: grid;
+ gap: var(--space-3);
+ min-width: 0;
+ max-width: var(--center-width);
+}
+
+.ui-panel {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ min-width: 0;
+ gap: var(--space-3);
+ padding: var(--space-4);
+ background: var(--card);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-md);
+}
+
+.ui-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+}
+
+.ui-panel-title {
+ font-size: var(--font-size-sm);
+ font-weight: var(--weight-semibold);
+}
+
+.ui-panel-actions {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.ui-status-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+}
+
+.ui-status-row > span:first-child {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+}
+
+.ui-identity-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--space-2);
+ margin: 0;
+ min-width: 0;
+}
+
+.ui-identity-grid > div,
+.ui-inspector-grid > div,
+.ui-status-row,
+.ui-tree-row,
+.ui-capability-list li {
+ min-width: 0;
+}
+
+.ui-identity-grid dd,
+.ui-inspector-grid dd {
+ overflow-wrap: anywhere;
+ white-space: normal;
+}
+
+/* Manifest list ---------------------------------------------------------- */
+
+.ui-manifest-list,
+.ui-tree,
+.ui-transcript,
+.ui-inspector-list,
+.ui-observation-list {
+ display: grid;
+ gap: var(--space-2);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.ui-manifest-row {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--space-1);
+ width: 100%;
+ min-width: 0;
+ min-height: var(--touch-target);
+ padding: var(--space-2);
+ border: 0.0625rem solid transparent;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.ui-manifest-row[aria-pressed="true"] {
+ border-color: var(--accent);
+ background: var(--accent-surface);
+}
+
+.ui-manifest-name {
+ font-weight: var(--weight-semibold);
+}
+
+.ui-manifest-purpose {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ overflow-wrap: anywhere;
+}
+
+.ui-manifest-badges {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1);
+}
+
+.ui-observation-list {
+ gap: var(--space-1);
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+}
+
+/* Lineage tree ----------------------------------------------------------- */
+
+.ui-tree-row {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+ width: 100%;
+ min-height: var(--touch-target);
+ padding: var(--space-2);
+ border: 0.0625rem solid transparent;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+}
+
+.ui-tree li[data-depth="1"] .ui-tree-row {
+ padding-left: var(--space-5);
+}
+
+.ui-tree-row[aria-current="true"] {
+ border-color: var(--border);
+ background: var(--muted);
+}
+
+.ui-tree-state,
+.ui-tree-empty {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+}
+
+.ui-state-dot {
+ display: inline-block;
+ width: var(--space-2);
+ height: var(--space-2);
+ border-radius: 999rem;
+ background: var(--muted-foreground);
+}
+
+.ui-state-dot[data-state="running"] {
+ background: var(--accent);
+}
+
+.ui-state-dot[data-state="completed"] {
+ background: var(--success);
+}
+
+.ui-state-dot[data-state="failed"] {
+ background: var(--danger);
+}
+
+/* Conversation ----------------------------------------------------------- */
+
+.ui-conversation {
+ position: relative;
+}
+
+.ui-conversation-viewport {
+ max-height: var(--transcript-height);
+ overflow-y: auto;
+ padding: var(--space-4);
+ background: var(--card);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-md);
+}
+
+.ui-jump-to-latest {
+ position: absolute;
+ bottom: var(--space-3);
+ left: 50%;
+ translate: -50% 0;
+ z-index: var(--z-banner);
+ min-height: var(--touch-target);
+ padding-inline: var(--space-4);
+ border: 0.0625rem solid var(--border);
+ border-radius: 999rem;
+ background: var(--card);
+ color: var(--foreground);
+ cursor: pointer;
+}
+
+.ui-empty-transcript {
+ display: grid;
+ gap: var(--space-2);
+ padding-block: var(--space-6);
+}
+
+.ui-empty-transcript h3 {
+ font-size: var(--font-size-md);
+}
+
+.ui-empty-transcript p:last-child {
+ color: var(--muted-foreground);
+}
+
+.ui-transcript {
+ gap: var(--space-3);
+}
+
+.ui-message {
+ display: grid;
+ gap: var(--space-1);
+ padding: var(--space-3);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-raised);
+}
+
+.ui-message[data-role="user"] {
+ margin-left: var(--space-10);
+ background: var(--muted);
+}
+
+.ui-message[data-state="failed"] {
+ border-color: var(--danger);
+ background: var(--danger-surface);
+ color: var(--danger);
+}
+
+.ui-message[data-state="interrupted"] {
+ border-color: var(--warning);
+}
+
+.ui-message-role {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.ui-message-text {
+ white-space: pre-wrap;
+}
+
+.ui-message-divider {
+ padding-top: var(--space-2);
+ border-top: 0.0625rem solid var(--warning);
+ color: var(--warning);
+ font-size: var(--font-size-xs);
+}
+
+.ui-stream-cursor {
+ display: inline-block;
+ width: var(--space-2);
+ height: var(--font-size-md);
+ margin-left: var(--space-1);
+ background: var(--accent);
+ vertical-align: text-bottom;
+ animation: ui-pulse 1s steps(2, end) infinite;
+}
+
+@keyframes ui-pulse {
+ 50% {
+ opacity: 0.2;
+ }
+}
+
+.ui-turn-marker {
+ display: flex;
+ align-items: center;
+ gap: var(--space-2);
+ padding-block: var(--space-2);
+ border-top: 0.0625rem solid var(--border);
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+}
+
+.ui-steering-chip {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+ padding: var(--space-2);
+ border: 0.0625rem dashed var(--border);
+ border-radius: var(--radius-sm);
+ font-size: var(--font-size-xs);
+}
+
+/* Disclosures ------------------------------------------------------------ */
+
+.ui-disclosure {
+ padding: var(--space-2) var(--space-3);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--surface-raised);
+}
+
+.ui-disclosure-summary {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-2);
+ min-height: var(--touch-target);
+ cursor: pointer;
+ font-weight: var(--weight-medium);
+}
+
+.ui-disclosure-preview,
+.ui-disclosure-label {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ font-weight: var(--weight-semibold);
+}
+
+.ui-disclosure-preview {
+ overflow: hidden;
+ min-width: 0;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ui-payload {
+ max-height: var(--transcript-height);
+ margin: var(--space-2) 0 0;
+ overflow: auto;
+ padding: var(--space-3);
+ border-radius: var(--radius-sm);
+ background: var(--muted);
+ font-family: var(--font-mono);
+ font-size: var(--font-size-xs);
+ white-space: pre-wrap;
+ word-break: break-word;
+}
+
+.ui-payload--danger {
+ background: var(--danger-surface);
+ color: var(--danger);
+}
+
+/* Request cards ---------------------------------------------------------- */
+
+.ui-request {
+ display: grid;
+ gap: var(--space-2);
+ padding: var(--space-3);
+ border: 0.0625rem solid var(--border);
+ border-left: var(--space-1) solid var(--accent);
+ border-radius: var(--radius-md);
+ background: var(--card);
+}
+
+.ui-request--settled {
+ border-left-color: var(--border);
+ background: var(--surface-raised);
+}
+
+.ui-request[data-status="resolved"] {
+ border-left-color: var(--success);
+}
+
+.ui-request-header,
+.ui-request-summary,
+.ui-request-actions,
+.ui-request-meta {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-2);
+}
+
+.ui-request-summary {
+ justify-content: space-between;
+ min-height: var(--touch-target);
+ cursor: pointer;
+}
+
+.ui-request-kind {
+ font-weight: var(--weight-semibold);
+}
+
+.ui-request-meta,
+.ui-request-resolving {
+ color: var(--muted-foreground);
+ font-family: var(--font-mono);
+ font-size: var(--font-size-xs);
+}
+
+.ui-request-input {
+ display: grid;
+ gap: var(--space-1);
+}
+
+.ui-request-input input {
+ min-height: var(--control-height);
+ padding: var(--space-2) var(--space-3);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--card);
+ color: var(--card-foreground);
+}
+
+.ui-request-actions .ui-button {
+ min-height: var(--touch-target);
+}
+
+/* Composer --------------------------------------------------------------- */
+
+.ui-composer-region {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.ui-composer {
+ display: grid;
+ gap: var(--space-2);
+ padding: var(--space-3);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--card);
+}
+
+.ui-composer[data-state="active-turn"] {
+ border-color: var(--accent);
+}
+
+.ui-composer-input {
+ width: 100%;
+ padding: var(--space-3);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--card);
+ color: var(--card-foreground);
+ resize: none;
+}
+
+.ui-composer-actions {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.ui-composer-hint,
+.ui-composer-reason,
+.ui-menu-reason,
+.ui-capability-diagnostic {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+}
+
+.ui-composer-reason,
+.ui-capability-diagnostic {
+ color: var(--warning);
+}
+
+.ui-composer-buttons {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-3);
+}
+
+.ui-composer-buttons .ui-button {
+ min-width: var(--touch-target);
+ min-height: var(--touch-target);
+}
+
+/* Banners, menus, dialogs, tooltips -------------------------------------- */
+
+.ui-banner {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ justify-content: space-between;
+ gap: var(--space-3);
+ z-index: var(--z-banner);
+ padding: var(--space-3);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--muted);
+ transition: opacity var(--motion-medium) ease;
+}
+
+.ui-banner[data-tone="accent"] {
+ border-color: var(--accent);
+ background: var(--accent-surface);
+ color: var(--accent);
+}
+
+.ui-banner[data-tone="warning"] {
+ border-color: var(--warning);
+ background: var(--warning-surface);
+ color: var(--warning);
+}
+
+.ui-banner[data-tone="danger"] {
+ border-color: var(--danger);
+ background: var(--danger-surface);
+ color: var(--danger);
+}
+
+.ui-banner-actions {
+ display: flex;
+ gap: var(--space-2);
+}
+
+.ui-menu {
+ position: relative;
+}
+
+.ui-menu-list {
+ position: absolute;
+ right: 0;
+ z-index: var(--z-banner);
+ display: grid;
+ gap: var(--space-1);
+ min-width: var(--rail-width);
+ padding: var(--space-2);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--card);
+ box-shadow: var(--shadow-sm);
+}
+
+.ui-menu-item {
+ min-height: var(--touch-target);
+ padding: var(--space-2);
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: inherit;
+ text-align: left;
+ cursor: pointer;
+}
+
+.ui-menu-item[aria-disabled="true"] {
+ color: var(--muted-foreground);
+ cursor: not-allowed;
+}
+
+.ui-menu-item:hover:not([aria-disabled="true"]) {
+ background: var(--muted);
+}
+
+.ui-dialog {
+ z-index: var(--z-dialog);
+ max-width: var(--center-width);
+ padding: 0;
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-md);
+ background: var(--card);
+ color: var(--card-foreground);
+}
+
+.ui-dialog-body {
+ display: grid;
+ gap: var(--space-3);
+ padding: var(--space-5);
+}
+
+.ui-dialog-title {
+ font-size: var(--font-size-lg);
+ font-weight: var(--weight-semibold);
+}
+
+.ui-dialog-description {
+ color: var(--muted-foreground);
+}
+
+.ui-dialog-input {
+ width: 100%;
+ padding: var(--space-3);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--card);
+ color: var(--card-foreground);
+}
+
+.ui-dialog-footer {
+ display: flex;
+ justify-content: flex-end;
+ gap: var(--space-3);
+}
+
+.ui-tooltip {
+ position: relative;
+}
+
+.ui-tooltip-content {
+ position: absolute;
+ left: 0;
+ top: 100%;
+ z-index: var(--z-banner);
+ display: none;
+ min-width: var(--rail-width);
+ padding: var(--space-2);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--card);
+ font-size: var(--font-size-xs);
+}
+
+.ui-tooltip:hover .ui-tooltip-content,
+.ui-tooltip:focus-within .ui-tooltip-content {
+ display: block;
+}
+
+/* Tabs and inspector ----------------------------------------------------- */
+
+.ui-tabs {
+ display: grid;
+ gap: var(--space-3);
+}
+
+.ui-tablist {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1);
+ padding: var(--space-1);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--muted);
+}
+
+.ui-tab {
+ min-height: var(--touch-target);
+ padding-inline: var(--space-3);
+ border: 0;
+ border-radius: var(--radius-sm);
+ background: transparent;
+ color: var(--muted-foreground);
+ cursor: pointer;
+}
+
+.ui-tab[aria-selected="true"] {
+ background: var(--card);
+ color: var(--foreground);
+ font-weight: var(--weight-semibold);
+}
+
+.ui-inspector-events {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.ui-inspector-filter {
+ width: 100%;
+ min-height: var(--control-height);
+ padding: var(--space-2) var(--space-3);
+ border: 0.0625rem solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--card);
+ color: var(--card-foreground);
+}
+
+.ui-facets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: var(--space-1);
+ max-height: var(--facet-height);
+ overflow-y: auto;
+ padding-bottom: var(--space-1);
+}
+
+.ui-facet,
+.ui-chip,
+.ui-copy {
+ min-height: var(--space-6);
+ padding-inline: var(--space-2);
+ border: 0.0625rem solid var(--border);
+ border-radius: 999rem;
+ background: var(--card);
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+ cursor: pointer;
+}
+
+.ui-facet[aria-pressed="true"] {
+ border-color: var(--accent);
+ background: var(--accent-surface);
+ color: var(--accent);
+}
+
+.ui-inspector-list {
+ max-height: var(--transcript-height);
+ overflow-y: auto;
+}
+
+.ui-inspector-list li[data-highlighted="true"] {
+ background: var(--accent-surface);
+}
+
+.ui-inspector-row {
+ display: grid;
+ grid-template-columns: var(--timeline-column) minmax(0, 1fr) minmax(0, 1fr);
+ gap: var(--space-2);
+ align-items: center;
+ min-height: var(--touch-target);
+ cursor: pointer;
+ font-family: var(--font-mono);
+ font-size: var(--font-size-xs);
+}
+
+.ui-inspector-id {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.ui-seq {
+ color: var(--muted-foreground);
+}
+
+.ui-inspector-grid {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ gap: var(--space-3);
+ margin: 0;
+ min-width: 0;
+}
+
+.ui-capability-list {
+ display: grid;
+ gap: var(--space-2);
+ margin: 0;
+ padding: 0;
+ list-style: none;
+}
+
+.ui-capability-list li {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: var(--space-2);
+ align-items: center;
+ padding-block: var(--space-2);
+ border-bottom: 0.0625rem solid var(--border);
+}
+
+.ui-capability-list p {
+ grid-column: 1 / -1;
+}
+
+.ui-replay-stepper {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: var(--space-3);
+ padding: var(--space-3);
+ border: 0.0625rem solid var(--warning);
+ border-radius: var(--radius-md);
+ background: var(--warning-surface);
+}
+
+.ui-goal-banner {
+ display: grid;
+ gap: var(--space-2);
+}
+
+.ui-goal-empty {
+ color: var(--muted-foreground);
+ font-size: var(--font-size-xs);
+}
+
+.ui-transcript li[data-highlighted="true"] {
+ outline: 0.125rem solid var(--accent);
+ outline-offset: 0.125rem;
+}
+
+.ui-tab:focus-visible,
+.ui-menu-item:focus-visible,
+.ui-tree-row:focus-visible,
+.ui-manifest-row:focus-visible,
+.ui-facet:focus-visible,
+.ui-chip:focus-visible,
+.ui-copy:focus-visible,
+.ui-jump-to-latest:focus-visible,
+.ui-request:focus-visible,
+.ui-conversation-viewport:focus-visible,
+.ui-composer-input:focus-visible,
+.ui-inspector-filter:focus-visible,
+.ui-dialog-input:focus-visible,
+.ui-request-input input:focus-visible,
+.ui-tabpanel:focus-visible,
+summary:focus-visible {
+ outline: 0.125rem solid var(--ring);
+ outline-offset: 0.125rem;
+}
+
+@media (max-width: 64rem) {
+ .ui-message[data-role="user"] {
+ margin-left: var(--space-5);
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ :root {
+ --motion-medium: 0ms;
+ }
+
+ .ui-stream-cursor {
+ animation: none;
+ }
+}
diff --git a/packages/paperclip-runner/devtools/browser/static-replay.spec.ts b/packages/paperclip-runner/devtools/browser/static-replay.spec.ts
new file mode 100644
index 0000000000..98dfaf7288
--- /dev/null
+++ b/packages/paperclip-runner/devtools/browser/static-replay.spec.ts
@@ -0,0 +1,112 @@
+import { expect, test } from "@playwright/test";
+
+test("validates and renders the shared Replay fixture", async ({ page }, testInfo) => {
+ await page.goto("/");
+ await expect(page.getByRole("heading", { name: "Live runner diagnostics" })).toBeVisible();
+ await page.getByRole("button", { name: "Static replay" }).click();
+ await expect(page.getByRole("heading", { name: "Static protocol replay" })).toBeVisible();
+ await expect(page.getByTestId("terminal-badge")).toHaveText("Succeeded");
+ await expect(page.getByTestId("timeline").getByRole("listitem")).toHaveCount(9);
+ await expect(page.getByTestId("timeline")).not.toContainText("workspace_preparing");
+ await expect(page.getByTestId("result-summary")).toContainText(
+ "The scripted run completed successfully.",
+ );
+ await page.screenshot({
+ path: testInfo.outputPath("replay-static-replay.png"),
+ fullPage: true,
+ });
+});
+
+test("shows duplicate and unsupported-version replay states", async ({ page }) => {
+ await page.goto("/");
+ await page.getByRole("button", { name: "Static replay" }).click();
+ await page.getByLabel("Fixture", { exact: true }).selectOption("duplicate-event");
+ await expect(page.getByText("1 duplicate events ignored")).toBeVisible();
+ await page
+ .getByLabel("Fixture", { exact: true })
+ .selectOption("unsupported-required-version");
+ await expect(page.getByRole("heading", { name: "Fixture cannot be replayed" })).toBeVisible();
+ await expect(page.getByText(/protocolVersion 2 is unsupported/)).toBeVisible();
+});
+
+test("streams a live run and proves replay parity", async ({ page }, testInfo) => {
+ await page.goto("/");
+ await page.getByRole("button", { name: "Start local run" }).click();
+ await expect(page.getByTestId("live-status")).toHaveText("Terminal");
+ await expect(page.getByTestId("terminal-badge")).toHaveText("Succeeded");
+ await expect(page.getByTestId("process-facts")).toContainText("Harness process exit");
+ await expect(page.getByTestId("process-facts")).toContainText("done");
+ await expect(page.getByTestId("parity-result")).toContainText("Match");
+ await page.screenshot({
+ path: testInfo.outputPath("local-runner-live-complete.png"),
+ fullPage: true,
+ });
+});
+
+test("resolves live permission and input requests", async ({ page }, testInfo) => {
+ await page.goto("/");
+ await page.getByLabel("Scenario").selectOption("permission-input");
+ await page.getByRole("button", { name: "Start local run" }).click();
+ await expect(page.getByTestId("runtime-request")).toContainText("permission request");
+ await expect(page.getByTestId("terminal-badge")).toHaveText("Executing");
+ await expect(
+ page.getByTestId("timeline").getByRole("listitem").filter({
+ hasText: "runtime_request.created",
+ }).first(),
+ ).toContainText("permission: Allow the fake driver to write its local fixture?");
+ await page.screenshot({
+ path: testInfo.outputPath("local-runner-live-permission.png"),
+ fullPage: true,
+ });
+ await page.getByRole("button", { name: "Allow" }).click();
+ await expect(page.getByTestId("runtime-request")).toContainText("input request");
+ await expect(
+ page.getByTestId("timeline").getByRole("listitem").filter({
+ hasText: "runtime_request.resolved",
+ }).first(),
+ ).toContainText("Resolved permission: Allow the fake driver to write its local fixture?");
+ await page.waitForTimeout(10_500);
+ await page.getByLabel("Response").fill("local-runner-browser-trace");
+ await page.getByRole("button", { name: "Send input" }).click();
+ await expect(page.getByTestId("live-status")).toHaveText("Terminal");
+ await expect(page.getByTestId("parity-result")).toContainText("Match");
+});
+
+test("interrupts a live turn without duplicating terminal state", async ({ page }, testInfo) => {
+ await page.goto("/");
+ await page.getByLabel("Scenario").selectOption("interrupted");
+ await page.getByRole("button", { name: "Start local run" }).click();
+ const interrupt = page.getByRole("button", { name: "Interrupt turn" });
+ await expect(interrupt).toBeEnabled();
+ await interrupt.click();
+ await expect(page.getByTestId("live-status")).toHaveText("Terminal");
+ await expect(page.getByTestId("terminal-badge")).toHaveText("Cancelled");
+ await expect(page.getByTestId("live-status")).toHaveAttribute("data-tone", "neutral");
+ await expect(page.getByTestId("terminal-badge")).toHaveAttribute("data-tone", "neutral");
+ await expect(page.getByTestId("timeline").getByText("run.terminal")).toHaveCount(1);
+ await page.screenshot({
+ path: testInfo.outputPath("local-runner-live-interrupted.png"),
+ fullPage: true,
+ });
+});
+
+test("shows failed outcomes as danger and names the duplicate-terminal guard", async ({ page }) => {
+ await page.goto("/");
+ await page.getByLabel("Scenario").selectOption("error");
+ await page.getByRole("button", { name: "Start local run" }).click();
+ await expect(page.getByTestId("live-status")).toHaveText("Terminal");
+ await expect(page.getByTestId("live-status")).toHaveAttribute("data-tone", "danger");
+ await expect(page.getByTestId("terminal-badge")).toHaveText("Failed");
+ await expect(page.getByTestId("terminal-badge")).toHaveAttribute("data-tone", "danger");
+
+ await page.getByLabel("Scenario").selectOption("duplicate-terminal");
+ await page.getByRole("button", { name: "Start local run" }).click();
+ await expect(page.getByTestId("live-status")).toHaveText("Terminal");
+ await expect(
+ page.getByTestId("timeline").getByRole("listitem").filter({
+ hasText: "harness.diagnostic",
+ }),
+ ).toContainText(
+ "Duplicate terminal event ignored; the first terminal event remains authoritative.",
+ );
+});
diff --git a/packages/paperclip-runner/devtools/issue-thread/index.html b/packages/paperclip-runner/devtools/issue-thread/index.html
new file mode 100644
index 0000000000..ff5100b74d
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ 🧯 Mock Paperclip · Issue thread
+
+
+
+
+
+
diff --git a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts
new file mode 100644
index 0000000000..fa9c0f2551
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts
@@ -0,0 +1,1234 @@
+import { createRequire } from "node:module";
+
+import { expect, test, type Page } from "@playwright/test";
+
+import { CAPABILITY_UI_SHOT_SLUGS } from "../../src/issue-thread/fixtures";
+import type {
+ CapabilityIssueThreadSnapshot,
+ CapabilityThreadItem,
+} from "../../src/issue-thread/types";
+
+const require = createRequire(import.meta.url);
+const AXE_PATH = require.resolve("axe-core/axe.min.js");
+
+const DESKTOP = { width: 1440, height: 900 };
+const MOBILE = { width: 390, height: 844 };
+
+function route(slug: string, params: Record = {}): string {
+ const query = new URLSearchParams({ shot: slug, capture: "1", ...params });
+ return `/#/issue/hb-baseline?${query.toString()}`;
+}
+
+/** Every route settles on a data attribute, never on a timeout (§10.1). */
+async function open(page: Page, slug: string, params: Record = {}) {
+ await page.goto(route(slug, params));
+ await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
+}
+
+interface AxeViolation {
+ id: string;
+ impact: string | null;
+ nodes: unknown[];
+}
+
+async function seriousAxeViolations(page: Page): Promise {
+ await page.addScriptTag({ path: AXE_PATH });
+ const violations = await page.evaluate(async () => {
+ const axe = (window as unknown as { axe: { run: (context: unknown, options: unknown) => Promise<{ violations: AxeViolation[] }> } }).axe;
+ const results = await axe.run(document, {
+ resultTypes: ["violations"],
+ runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] },
+ });
+ return results.violations.map((violation) => ({
+ id: violation.id,
+ impact: violation.impact ?? null,
+ nodes: violation.nodes.length,
+ }));
+ });
+ return (violations as unknown as AxeViolation[]).filter(
+ (violation) => violation.impact === "serious" || violation.impact === "critical",
+ );
+}
+
+test.describe("Capability issue thread", () => {
+ test("loads the bundled sans and mono faces before the thread settles", async ({ page }) => {
+ await open(page, "thread-baseline");
+
+ const probe = await page.evaluate(async () => {
+ const normalizeFamily = (family: string) => family.trim().replace(/^['"]|['"]$/g, "");
+ const styles = getComputedStyle(document.documentElement);
+ const sansFamily = normalizeFamily(styles.getPropertyValue("--pit-font-sans").split(",")[0]);
+ const monoFamily = normalizeFamily(styles.getPropertyValue("--pit-font-mono").split(",")[0]);
+ const symbolFamily = normalizeFamily(styles.getPropertyValue("--pit-font-symbols"));
+ await document.fonts.load(`400 16px "${symbolFamily}"`, "◐⏳\uFE0E");
+ const statuses = (family: string) =>
+ [...document.fonts]
+ .filter((face) => normalizeFamily(face.family) === family)
+ .map((face) => face.status);
+
+ return {
+ sansFamily,
+ monoFamily,
+ symbolFamily,
+ sansStatuses: statuses(sansFamily),
+ monoStatuses: statuses(monoFamily),
+ symbolStatuses: statuses(symbolFamily),
+ sansWeightsLoaded: [400, 500, 600, 700].every((weight) =>
+ document.fonts.check(`${weight} 16px "${sansFamily}"`, "Paperclip"),
+ ),
+ monoWeightsLoaded: [400, 700].every((weight) =>
+ document.fonts.check(`${weight} 16px "${monoFamily}"`, "TASK-17003"),
+ ),
+ symbolsLoaded: document.fonts.check(`400 16px "${symbolFamily}"`, "◐⏳\uFE0E"),
+ };
+ });
+
+ expect(probe).toEqual({
+ sansFamily: "Paperclip Issue Thread Inter",
+ monoFamily: "Paperclip Issue Thread DejaVu Sans Mono",
+ symbolFamily: "Paperclip Issue Thread Symbols",
+ sansStatuses: ["loaded"],
+ monoStatuses: ["loaded", "loaded"],
+ symbolStatuses: ["loaded", "loaded"],
+ sansWeightsLoaded: true,
+ monoWeightsLoaded: true,
+ symbolsLoaded: true,
+ });
+ });
+
+ test("baseline thread renders identity, turns, and the §3 item types", async ({ page }) => {
+ await open(page, "thread-baseline");
+
+ await expect(page.locator('[data-session-mode="fake"]').first()).toBeVisible();
+ const chips = page.getByTestId("identity-chips");
+ await expect(chips.getByTestId("agent-chip")).toHaveText("Fake agent");
+ await expect(chips.getByTestId("runner-chip")).toContainText("In-process runner");
+ await expect(chips.getByTestId("control-plane-chip")).toHaveText("Mock Paperclip");
+
+ await expect(page.locator("h1")).toHaveCount(1);
+ await expect(page.locator('[data-turn-id]')).toHaveCount(3);
+ for (const kind of ["user_message", "agent_message", "durable_comment", "tool_activity"]) {
+ await expect(page.locator(`[data-thread-item="${kind}"]`).first()).toBeVisible();
+ }
+ await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1);
+ });
+
+ test("a durable progress comment is visually distinct from model prose", async ({ page }) => {
+ await open(page, "thread-baseline");
+ await expect(
+ page.locator('[data-thread-item="durable_comment"]').getByText("Recorded to mock thread"),
+ ).toBeVisible();
+ await expect(
+ page.locator('[data-thread-item="agent_message"]').first().getByText("Recorded to mock thread"),
+ ).toHaveCount(0);
+ });
+
+ test("tool strips are collapsed disclosures that deep-link into Evidence", async ({ page }) => {
+ await open(page, "thread-baseline");
+ const strip = page.locator('[data-tool-strip="get_task_context"]').first();
+ await expect(strip).not.toHaveAttribute("open", "");
+ await strip.locator("summary").click();
+ await expect(strip).toHaveAttribute("open", "");
+
+ await page.getByRole("button", { name: "View in Evidence" }).first().click();
+ const panel = page.getByTestId("evidence-panel");
+ await expect(panel).toBeVisible();
+ await expect(panel.locator('[data-record-id="call-1"][data-highlighted="true"]')).toBeVisible();
+ });
+
+ test("a pending question card resolves inline and releases the composer", async ({ page }) => {
+ await open(page, "interaction-question-pending");
+
+ await expect(page.locator('[data-composer-state="waiting"]')).toBeVisible();
+ const card = page.locator('[data-interaction-id="ix-questions-01"]');
+ await expect(card).toHaveAttribute("data-interaction-state", "pending");
+ await expect(card.getByTestId("interaction-state-chip")).toContainText("Waiting for you");
+
+ // The submit is rejected until every required question is answered.
+ await card.getByRole("button", { name: "Submit answers" }).click();
+ await expect(card.getByRole("alert")).toContainText("required");
+
+ await card.getByRole("radio", { name: "Yes — runner owns it" }).check();
+ await card.getByRole("combobox").selectOption("hb-baseline");
+ await card.getByRole("button", { name: "Submit answers" }).click();
+
+ await expect(card).toHaveAttribute("data-interaction-state", "answered");
+ await expect(card.getByTestId("interaction-state-chip")).toContainText("Answered");
+ await expect(page.locator('[data-composer-state="ready"]').first()).toBeVisible();
+ });
+
+ test("a revision-bound confirmation names its target and requires a reject reason", async ({
+ page,
+ }) => {
+ await open(page, "interaction-confirmation-pending");
+ const card = page.locator('[data-interaction-id="ix-confirmation-plan-01"]');
+ await expect(card.getByTestId("interaction-target")).toHaveText("plan · r4");
+
+ await card.getByRole("button", { name: "Request changes" }).click();
+ await expect(card.getByRole("alert")).toContainText("reason is required");
+ await expect(card).toHaveAttribute("data-interaction-state", "pending");
+
+ await card.getByRole("textbox").fill("Split the acceptance section first.");
+ await card.getByRole("button", { name: "Request changes" }).click();
+ await expect(card).toHaveAttribute("data-interaction-state", "rejected");
+ await expect(card).toContainText("Split the acceptance section first.");
+ });
+
+ test("resolved, rejected, stale, and superseded cards stay distinct in history", async ({
+ page,
+ }) => {
+ await open(page, "interaction-resolved-mixed");
+ for (const state of ["answered", "accepted", "rejected", "stale_target", "superseded_by_comment"]) {
+ await expect(
+ page.locator(`[data-interaction-state="${state}"]`),
+ `missing ${state}`,
+ ).toHaveCount(1);
+ }
+ // Expired treatments keep their controls removed but stay reachable.
+ await expect(
+ page.locator('[data-interaction-state="stale_target"] button', { hasText: "Approve" }),
+ ).toHaveCount(0);
+ await expect(
+ page.locator('[data-interaction-state="stale_target"]').getByRole("button", {
+ name: "View request evidence",
+ }),
+ ).toBeVisible();
+ });
+
+ test("the deliverable strip and card report one size in one unit", async ({ page }) => {
+ await open(page, "deliverable-registered");
+ const card = page.locator('[data-thread-item="deliverable"]');
+ await expect(card).toContainText("18.0 kB");
+ await expect(
+ page.locator('[data-tool-strip="register_deliverable"]'),
+ ).toContainText("18.0 kB");
+ });
+
+ test("a denial quotes the authorization record verbatim and badges Evidence", async ({
+ page,
+ }) => {
+ await open(page, "denial-optional-tool");
+ await expect(page.getByTestId("denial-reason")).toHaveText(
+ "denied: missing grant su:read_or_write",
+ );
+ await expect(page.getByTestId("evidence-toggle")).toContainText("(1)");
+
+ await page.locator('[data-thread-item="denial"]').getByRole("button", { name: "View in Evidence" }).click();
+ const record = page.getByTestId("evidence-panel").locator('[data-record-id="authz-deny-1"]');
+ await expect(record).toHaveAttribute("data-allowed", "false");
+ await expect(record).toContainText("denied: missing grant su:read_or_write");
+ });
+
+ test("Evidence exposes eight sections, a turn selector, and the withheld control-plane list", async ({
+ page,
+ }) => {
+ await open(page, "debug-panel-open", { panel: "authorization" });
+ const panel = page.getByTestId("evidence-panel");
+ await expect(panel).toBeVisible();
+ await expect(panel.locator("[data-evidence-section]")).toHaveCount(8);
+ await expect(panel.locator("#evidence-turn-selector")).toBeVisible();
+
+ // The Tools section is open by default; assert its groups directly.
+ await expect(
+ panel.locator('[data-evidence-section="tools"] button').first(),
+ ).toHaveAttribute("aria-expanded", "true");
+ await expect(panel.getByText("Control plane (not exposed to the agent)")).toBeVisible();
+ await expect(
+ panel.locator('[data-disposition="control_plane_owned"]', { hasText: "create_task" }),
+ ).toBeVisible();
+ await expect(panel.getByText("Agent tool — always")).toBeVisible();
+ await expect(panel.getByText("Agent tool — granted")).toBeVisible();
+
+ });
+
+ test("tool exposure groups are deduplicated, foldable, and open catalog details", async ({ page }) => {
+ await open(page, "debug-panel-open", { panel: "tools" });
+ const panel = page.getByTestId("evidence-panel");
+ const always = panel.getByRole("button", { name: /Agent tool — always/ });
+ await expect(always).toHaveCount(1);
+ await always.click();
+ await expect(panel.getByRole("button", { name: /get_task_context/ })).toHaveCount(0);
+ await always.click();
+ await panel.getByRole("button", { name: /get_task_context/ }).click();
+ const dialog = page.getByRole("dialog", { name: "Get active task context" });
+ await expect(dialog).toContainText("Read the active mock task, actor, wake, ancestors, budget, and interaction results.");
+ await expect(dialog).toContainText("Input schema");
+ await dialog.getByRole("button", { name: "Close tool details" }).click();
+ await expect(dialog).toHaveCount(0);
+ });
+
+ test("Evidence records link back to their thread anchor", async ({ page }) => {
+ await open(page, "debug-panel-open", { panel: "calls" });
+ const panel = page.getByTestId("evidence-panel");
+ await panel.locator('[data-record-id="call-1"]').getByRole("button", { name: "Show in thread" }).click();
+ await expect(page.locator('[data-tool-strip="get_task_context"]')).toBeInViewport();
+ });
+
+ test("the splitter resizes the panel from the keyboard", async ({ page }) => {
+ await open(page, "debug-panel-open", { panel: "tools" });
+ const splitter = page.getByRole("separator", { name: "Resize the evidence panel" });
+ const before = await splitter.getAttribute("aria-valuenow");
+ await splitter.focus();
+ await page.keyboard.press("ArrowLeft");
+ await expect(splitter).not.toHaveAttribute("aria-valuenow", before ?? "");
+ });
+
+ test("the splitter drag makes the DevTools panel wider", async ({ page }) => {
+ await open(page, "debug-panel-open", { panel: "tools" });
+ const splitter = page.getByRole("separator", { name: "Resize the evidence panel" });
+ const panel = page.getByTestId("evidence-panel");
+ const handle = await splitter.boundingBox();
+ const before = await panel.boundingBox();
+ expect(handle).not.toBeNull();
+ expect(before).not.toBeNull();
+ await page.mouse.move(handle!.x + handle!.width / 2, handle!.y + handle!.height / 2);
+ await page.mouse.down();
+ await page.mouse.move(handle!.x - 140, handle!.y + handle!.height / 2, { steps: 5 });
+ await page.mouse.up();
+ const after = await panel.boundingBox();
+ expect(after!.width).toBeGreaterThan(before!.width + 100);
+ });
+
+ test("reset is confirmed, escapable, and clears the thread", async ({ page }) => {
+ await open(page, "disposition-terminal");
+ await page.getByTestId("reset-button").click();
+ const dialog = page.getByTestId("reset-dialog");
+ await expect(dialog).toBeVisible();
+ await expect(dialog).toContainText("The transcript will be lost.");
+ await expect(page.getByRole("button", { name: "Cancel" })).toBeFocused();
+
+ await page.keyboard.press("Escape");
+ await expect(dialog).toHaveCount(0);
+
+ await page.getByTestId("reset-button").click();
+ await page
+ .getByTestId("reset-dialog")
+ .getByRole("button", { name: "Reset scenario", exact: true })
+ .click();
+ await expect(page.locator('[data-thread-item="disposition"]')).toHaveCount(0);
+ await expect(page.locator('[data-composer-state="ready"]').first()).toBeVisible();
+ });
+
+ test("stop keeps the partial turn and marks it", async ({ page }) => {
+ await open(page, "turn-streaming");
+ await expect(page.locator('[data-composer-state="streaming"]')).toBeVisible();
+ await expect(page.getByTestId("composer-stop")).toBeVisible();
+ await expect(page.locator('[data-composer-state="streaming"] textarea')).toBeEnabled();
+
+ await page.getByTestId("composer-stop").click();
+ await expect(page.getByTestId("stopped-marker")).toBeVisible();
+ await expect(page.locator('[data-thread-item="agent_message"]').last()).toContainText(
+ "Two dependent tasks are still open",
+ );
+ });
+
+ test("reconnect pins an amber banner and disables the composer", async ({ page }) => {
+ await open(page, "reconnect-banner");
+ await expect(page.getByTestId("reconnect-banner")).toContainText("attempt 2");
+ await expect(page.locator('[data-composer-state="reconnecting"]')).toBeVisible();
+ await expect(page.locator('[data-composer-state="reconnecting"] textarea')).toBeDisabled();
+
+ await page.getByRole("button", { name: "Retry now" }).click();
+ await expect(page.getByText("Reconnected")).toBeVisible();
+ });
+
+ test("replay is read-only and labelled as fake-derived", async ({ page }) => {
+ await open(page, "replay-mode");
+ await expect(page.getByTestId("agent-chip")).toContainText("Replay · fake source");
+ await expect(page.getByTestId("composer-reason")).toHaveText("Replay is read-only");
+ await expect(page.getByTestId("replay-strip")).toContainText("Replay 12/18");
+ await expect(page.locator('[data-composer-state="disabled"] textarea')).toBeDisabled();
+ });
+
+ test("the replay strip steps, advances, and plays through to the end", async ({ page }) => {
+ await open(page, "replay-mode");
+ const strip = page.getByTestId("replay-strip");
+ const progress = page.getByRole("progressbar", { name: "Replay progress" });
+
+ await page.getByTestId("replay-step-back").click();
+ await expect(strip).toContainText("Replay 11/18");
+ await expect(progress).toHaveAttribute("aria-valuenow", "11");
+
+ await page.getByTestId("replay-next-turn").click();
+ await expect(strip).toContainText("Replay 12/18");
+
+ // Play-all runs the recording out and parks itself at the last ordinal
+ // instead of leaving the operator to click Next turn eighteen times (§6).
+ const playAll = page.getByTestId("replay-play-all");
+ await playAll.click();
+ await expect(playAll).toHaveAttribute("aria-pressed", "true");
+ await expect(strip).toContainText("Replay 18/18", { timeout: 15_000 });
+ await expect(playAll).toHaveAttribute("aria-pressed", "false");
+ await expect(page.getByTestId("replay-next-turn")).toBeDisabled();
+ });
+
+ test("a terminal disposition keeps the composer available", async ({ page }) => {
+ await open(page, "disposition-terminal");
+ await expect(page.locator('[data-thread-item="disposition"]')).toContainText("Done");
+ await expect(page.locator("#composer-input")).toBeEnabled();
+ await expect(page.getByText("This task is done. You can still continue the conversation.")).toBeVisible();
+ });
+
+ test("composer drafts survive a refresh", async ({ page }) => {
+ await open(page, "thread-baseline");
+ await page.locator("#composer-input").fill("Draft that must survive F5.");
+ await page.reload();
+ await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
+ await expect(page.locator("#composer-input")).toHaveValue("Draft that must survive F5.");
+ });
+
+ test("mobile keeps the thread usable with no horizontal page scroll", async ({ page }) => {
+ await page.setViewportSize(MOBILE);
+ await open(page, "turn-streaming");
+
+ const overflow = await page.evaluate(() => {
+ const element = document.scrollingElement as HTMLElement;
+ return element.scrollWidth - element.clientWidth;
+ });
+ expect(overflow).toBeLessThanOrEqual(0);
+
+ // Stop stays outside the overflow menu while a turn is active (§2.2).
+ await expect(page.getByTestId("stop-button")).toBeVisible();
+ await expect(page.getByTestId("stop-button")).toBeEnabled();
+ await expect(page.getByTestId("overflow-menu")).toHaveCount(0);
+
+ await page.getByTestId("segment-evidence").click();
+ await expect(page.getByTestId("evidence-panel")).toBeVisible();
+ await expect(page.getByTestId("evidence-panel")).toHaveAttribute("data-layout", "segment");
+ });
+
+ test("mobile surfaces the denial count on the Evidence segment", async ({ page }) => {
+ await page.setViewportSize(MOBILE);
+ await open(page, "denial-optional-tool");
+ await expect(page.getByTestId("segment-denial-badge")).toHaveText("1");
+ });
+
+ test("every actionable control meets the 44px touch target on mobile", async ({ page }) => {
+ await page.setViewportSize(MOBILE);
+ await open(page, "interaction-question-pending");
+ const undersized = await page.evaluate(() => {
+ const selectors = "button:not([hidden]), select, textarea, [role='radio']";
+ return [...document.querySelectorAll(selectors)]
+ .filter((node) => {
+ const element = node as HTMLElement;
+ if (element.offsetParent === null) return false;
+ const rect = element.getBoundingClientRect();
+ return rect.height < 44;
+ })
+ .map((node) => (node as HTMLElement).className || node.tagName);
+ });
+ expect(undersized).toEqual([]);
+ });
+
+ test("the mobile overflow menu closes with Escape", async ({ page }) => {
+ await page.setViewportSize(MOBILE);
+ await open(page, "thread-baseline");
+ await page.getByTestId("overflow-menu-button").click();
+ await expect(page.getByTestId("overflow-menu")).toBeVisible();
+ await page.keyboard.press("Escape");
+ await expect(page.getByTestId("overflow-menu")).toHaveCount(0);
+ });
+
+ test("opening Evidence moves focus to its heading", async ({ page }) => {
+ await open(page, "thread-baseline");
+ await page.getByTestId("evidence-toggle").click();
+ await expect(page.getByRole("heading", { name: "Developer tools" })).toBeFocused();
+ });
+
+ test("Escape closes the desktop overlay sheet and returns focus to its toggle", async ({
+ page,
+ }) => {
+ // Between the mobile segment and the docked side panel, Evidence renders as
+ // an overlay sheet; §9.1 makes Escape dismiss it.
+ await page.setViewportSize({ width: 1000, height: 800 });
+ await open(page, "thread-baseline");
+ await page.getByTestId("evidence-toggle").click();
+
+ const panel = page.getByTestId("evidence-panel");
+ await expect(panel).toHaveAttribute("data-layout", "overlay");
+ await expect(page.getByRole("heading", { name: "Developer tools" })).toBeFocused();
+
+ await page.keyboard.press("Escape");
+ await expect(panel).toHaveCount(0);
+ await expect(page.getByTestId("evidence-toggle")).toBeFocused();
+ });
+
+ test("resolving an interaction card moves focus to its state chip", async ({ page }) => {
+ await open(page, "interaction-question-pending");
+ const card = page.locator('[data-interaction-id="ix-questions-01"]');
+
+ await card.getByRole("radio", { name: "Yes — runner owns it" }).check();
+ await card.getByRole("combobox").selectOption("hb-baseline");
+ await card.getByRole("button", { name: "Submit answers" }).click();
+
+ await expect(card).toHaveAttribute("data-interaction-state", "answered");
+ await expect(card.getByTestId("interaction-state-chip")).toBeFocused();
+ });
+
+ test("the waiting composer anchor focuses the pending card", async ({ page }) => {
+ await open(page, "interaction-question-pending");
+ await page.getByTestId("pending-anchor").click();
+ await expect(page.locator('[data-interaction-id="ix-questions-01"]')).toBeInViewport();
+ });
+
+ for (const slug of CAPABILITY_UI_SHOT_SLUGS) {
+ test(`axe reports no serious or critical violation on ${slug} (desktop)`, async ({ page }) => {
+ await page.setViewportSize(DESKTOP);
+ await open(page, slug, slug === "debug-panel-open" ? { panel: "authorization" } : {});
+ expect(await seriousAxeViolations(page)).toEqual([]);
+ });
+
+ test(`axe reports no serious or critical violation on ${slug} (mobile)`, async ({ page }) => {
+ await page.setViewportSize(MOBILE);
+ await open(page, slug, slug === "debug-panel-open" ? { panel: "authorization", seg: "evidence" } : {});
+ expect(await seriousAxeViolations(page)).toEqual([]);
+ });
+ }
+});
+
+/* ------------------------------------------------------- Capability clean room */
+
+/**
+ * The clean room is live-only by construction, so these tests stub the package
+ * session route rather than starting a real Codex process: what is under test
+ * here is the surface — entry, blank state, evidence-on-demand, identity
+ * rotation, failure honesty, and the narrow layout — not the tool loop, which
+ * `smoke:capability:cleanroom` and the server suite cover against the real thing.
+ */
+
+const CLEAN_ROOM_API = "**/api/capability/ui/cleanroom/session*";
+const DEVTOOLS_API = "**/api/capability/ui/devtools?*";
+
+function devtoolsPayload() {
+ const state = {
+ company: { id: "company-1", name: "Mock Paperclip" },
+ actors: [], tasks: [], comments: [], interactions: [], approvals: [], artifacts: [],
+ workProducts: [], blockers: [], workspaceServices: [], budgets: [], runs: [], wakes: [],
+ audit: [], decisions: [], idempotency: [], faults: [],
+ documents: [{
+ id: "document-1",
+ key: "plan",
+ title: "Runner plan",
+ revisions: [{ id: "revision-1", revision: 1, body: "# Real document body\n\nInspectable from DevTools.", changeSummary: "Initial draft", createdAt: "2026-08-10T12:00:00.000Z" }],
+ }],
+ };
+ return {
+ schema: "paperclip.capability.devtools.v1",
+ currentRevision: 4,
+ revisions: [{ revision: 4, at: "2026-08-10T12:00:00.000Z", turnId: "turn-1", operationId: "write_document", state }],
+ protocol: [], runtime: {}, authority: {},
+ };
+}
+
+function cleanRoomView(identifier: string, withTurn: boolean) {
+ const guard = {
+ id: `network-guard-session-${identifier}`,
+ turnId: "turn-0",
+ category: "session",
+ outcome: "no_real_paperclip_request",
+ reason: "Real Paperclip API requests: 0. Child PAPERCLIP_* environment keys: none.",
+ stateRevision: 3,
+ threadAnchorId: null,
+ };
+ return {
+ schema: "paperclip.capability.issue-thread-view.v1",
+ sessionId: `session-${identifier}`,
+ mode: "live",
+ identity: {
+ agentLabel: "Real Codex",
+ runnerLabel: "Real runnerd",
+ runnerAttached: true,
+ controlPlaneLabel: "Mock Paperclip",
+ controlPlaneTooltip: "All issue records are mock. No real Paperclip API is reachable.",
+ replaySource: null,
+ },
+ issue: {
+ identifier,
+ title: "Clean-room chat",
+ status: "in_progress",
+ priority: "medium",
+ assignee: "Mock Agent",
+ runState: `run-${identifier} · idle`,
+ scenarioId: "capability-clean-room",
+ fixtureProfile: "clean-room",
+ },
+ turns: withTurn
+ ? [
+ {
+ id: "turn-1",
+ ordinal: 1,
+ mode: "live",
+ toolCallCount: 1,
+ at: "2026-08-10T12:00:00.000Z",
+ stoppedByUser: false,
+ items: [
+ {
+ kind: "user_message",
+ id: "transcript-1",
+ at: "2026-08-10T12:00:00.000Z",
+ author: "You (board user)",
+ body: "Read this issue and record a status.",
+ },
+ {
+ kind: "tool_activity",
+ id: "item-call-call-1",
+ at: "2026-08-10T12:00:01.000Z",
+ status: "ok",
+ operationId: "report_progress",
+ summary: "state revision 3",
+ input: { body: "first status" },
+ result: { ok: true, stateRevision: 3 },
+ evidenceRef: { section: "calls", recordId: "call-1" },
+ },
+ {
+ kind: "agent_message",
+ id: "transcript-2",
+ at: "2026-08-10T12:00:02.000Z",
+ author: "Real Codex",
+ body: "Recorded a first status on the mock issue.",
+ streaming: false,
+ },
+ ],
+ },
+ ]
+ : [],
+ composer: { state: "ready", helper: null, reason: null, pendingInteractionId: null },
+ evidence: {
+ tools: [],
+ calls: [],
+ authorization: [],
+ control_plane: [guard],
+ runner: [],
+ state: [],
+ traceability: [],
+ parity: [],
+ },
+ connection: { state: "connected", attempt: 0 },
+ replay: null,
+ renderedAt: "2026-08-10T12:00:02.000Z",
+ };
+}
+
+function cleanRoomPayload(identifier: string, withTurn = false) {
+ return {
+ sessionId: `session-${identifier}`,
+ surface: "cleanroom",
+ identity: {
+ token: identifier.toLowerCase().replace("mck-", "tok"),
+ sequence: Number(identifier.slice(4)),
+ companyId: `company-cleanroom-${identifier}`,
+ actorId: `actor-cleanroom-${identifier}`,
+ taskId: `task-cleanroom-${identifier}`,
+ identifier,
+ },
+ limits: { maxTurns: 24, maxMessageBytes: 8192 },
+ view: cleanRoomView(identifier, withTurn),
+ };
+}
+
+/**
+ * A turn now answers with the NDJSON stream, so a stubbed turn has to speak it.
+ * These stubs deliver one settled frame: they exercise what a turn *produced*,
+ * while incremental delivery is proved against the real server below.
+ */
+function turnStreamBody(payload: unknown, interim: unknown[] = []): string {
+ const frames = interim.map((view, index) => ({
+ schema: "paperclip.capability.turn-stream.v1",
+ type: "frame",
+ seq: index + 1,
+ reason: "delta",
+ turnId: "turn-1",
+ view,
+ }));
+ return [
+ ...frames,
+ {
+ schema: "paperclip.capability.turn-stream.v1",
+ type: "settled",
+ seq: frames.length + 1,
+ payload,
+ },
+ ]
+ .map((frame) => `${JSON.stringify(frame)}\n`)
+ .join("");
+}
+
+async function stubCleanRoom(page: Page, identifiers: string[]) {
+ let opened = 0;
+ await page.route(CLEAN_ROOM_API, async (route) => {
+ const requestedSessionId = new URL(route.request().url()).searchParams.get("sessionId");
+ const identifier = requestedSessionId?.replace(/^session-/, "")
+ ?? identifiers[Math.min(opened, identifiers.length - 1)]
+ ?? "MCK-1000";
+ if (requestedSessionId === null) opened += 1;
+ await route.fulfill({
+ status: route.request().method() === "POST" ? 201 : 200,
+ contentType: "application/json",
+ body: JSON.stringify(cleanRoomPayload(identifier)),
+ });
+ });
+}
+
+async function openCleanRoom(page: Page, identifiers: string[] = ["MCK-1000"]) {
+ await stubCleanRoom(page, identifiers);
+ await page.addInitScript(() => window.localStorage.clear());
+ await page.goto("/#/chat");
+ await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
+}
+
+test.describe("Capability clean-room chat", () => {
+ test("the landing surface offers a clean-room entry that needs no scenario", async ({ page }) => {
+ await open(page, "thread-baseline");
+ const entry = page.getByTestId("surface-chat-link");
+ await expect(entry).toBeVisible();
+ await expect(entry).toHaveRole("button");
+ await expect(entry).toContainText("New chat");
+ });
+
+ test("a new chat opens a blank live thread with no scenario controls", async ({ page }) => {
+ await openCleanRoom(page);
+
+ await expect(page).toHaveTitle("🫧 Mock Paperclip · Issue thread");
+ await expect(page.locator('[data-surface="chat"]')).toBeVisible();
+ await expect(page.getByTestId("clean-room-empty")).toBeVisible();
+ await expect(page.locator("[data-turn-id]")).toHaveCount(0);
+ await expect(page.getByTestId("scenario-picker")).toHaveCount(0);
+ await expect(page.getByTestId("replay-button")).toHaveCount(0);
+ await expect(page.getByTestId("replay-strip")).toHaveCount(0);
+
+ const chips = page.getByTestId("identity-chips");
+ await expect(chips.getByTestId("agent-chip")).toHaveText("Real Codex");
+ await expect(chips.getByTestId("runner-chip")).toContainText("Real runnerd");
+ await expect(chips.getByTestId("control-plane-chip")).toHaveText("Mock Paperclip");
+ await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1);
+ });
+
+ test("evidence stays collapsed until it is asked for", async ({ page }) => {
+ await openCleanRoom(page);
+
+ await expect(page.locator(".pit-panel")).toHaveCount(0);
+ await expect(page.getByTestId("evidence-toggle")).toHaveAttribute("aria-expanded", "false");
+
+ await page.getByTestId("evidence-toggle").click();
+ await expect(page.locator(".pit-panel")).toBeVisible();
+ await page.getByRole("button", { name: /Control plane/ }).click();
+ await expect(page.getByText("Real Paperclip API requests: 0")).toBeVisible();
+ });
+
+ test("documents written to company state are readable in DevTools", async ({ page }) => {
+ await page.route(DEVTOOLS_API, async (route) => {
+ await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(devtoolsPayload()) });
+ });
+ await openCleanRoom(page);
+ await page.getByTestId("evidence-toggle").click();
+ const panel = page.getByTestId("evidence-panel");
+ const tabs = panel.getByRole("tab");
+ await expect(tabs.first()).toHaveText(/Evidence/);
+ await expect(tabs.nth(1)).toHaveText(/Timeline/);
+ await expect(panel.locator(".pit-devtools-tabs .pit-icon")).toHaveCount(8);
+ const centerOffsets = await tabs.evaluateAll((elements) => elements.map((element) => {
+ const icon = element.querySelector(".pit-tab-glyph")?.getBoundingClientRect();
+ const label = element.querySelector(".pit-tab-glyph + span")?.getBoundingClientRect();
+ return icon === undefined || label === undefined
+ ? Number.POSITIVE_INFINITY
+ : Math.abs((icon.top + icon.height / 2) - (label.top + label.height / 2));
+ }));
+ expect(Math.max(...centerOffsets)).toBeLessThanOrEqual(1);
+ await expect(tabs.first()).toHaveAttribute("aria-selected", "true");
+ await expect(panel.locator("#evidence-turn-selector")).toBeVisible();
+ await panel.getByRole("tab", { name: /Timeline/ }).click();
+ await expect(panel.locator("#evidence-turn-selector")).toHaveCount(0);
+ await panel.getByRole("tab", { name: /Evidence/ }).click();
+ await expect(panel.locator("#evidence-turn-selector")).toBeVisible();
+ await page.getByRole("tab", { name: /Documents/ }).click();
+ await expect(page.getByRole("heading", { name: "Runner plan" })).toBeVisible();
+ await expect(page.getByText("Inspectable from DevTools.")).toBeVisible();
+ });
+
+ test("Runner events expand completely while stream deltas stay grouped", async ({ page }) => {
+ await page.addInitScript(() => window.localStorage.clear());
+ await page.route(CLEAN_ROOM_API, async (route) => {
+ const payload = cleanRoomPayload("MCK-1000");
+ const view = payload.view as CapabilityIssueThreadSnapshot;
+ view.evidence.runner = [
+ {
+ id: "session-1",
+ turnId: "turn-0",
+ kind: "session",
+ ordinal: 1,
+ detail: "session · started",
+ details: [
+ { label: "Action", value: "started" },
+ { label: "Runner", value: "paperclip-runnerd" },
+ ],
+ },
+ ...[2, 3].map((ordinal) => ({
+ id: `delta-${ordinal}`,
+ turnId: "turn-1",
+ kind: "provider_event",
+ ordinal,
+ detail: "provider event · assistant_delta",
+ details: [{ label: "Event", value: "assistant_delta" }],
+ })),
+ ];
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify(payload),
+ });
+ });
+ await page.goto("/#/chat");
+ await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
+
+ await page.getByTestId("evidence-toggle").click();
+ await page.getByRole("button", { name: /Runner & events/ }).click();
+
+ const session = page.locator('[data-record-id="session-1"]');
+ await session.locator("summary").click();
+ await expect(session).toContainText("paperclip-runnerd");
+
+ const group = page.locator('[data-runner-delta-group="assistant_delta"]');
+ await expect(group).toContainText("2 streamed updates");
+ await group.locator(":scope > summary").click();
+ await expect(group.locator(".pit-runner-event")).toHaveCount(2);
+ await group.locator(".pit-runner-event").first().locator("summary").click();
+ await expect(group.locator(".pit-runner-event").first()).toContainText("assistant_delta");
+ });
+
+ test("a sent message renders the live turn it produced", async ({ page }) => {
+ await openCleanRoom(page);
+ await page.route("**/api/capability/ui/message", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/x-ndjson",
+ body: turnStreamBody({
+ sessionId: "session-MCK-1000",
+ surface: "cleanroom",
+ identity: cleanRoomPayload("MCK-1000").identity,
+ limits: { maxTurns: 24, maxMessageBytes: 8192 },
+ view: cleanRoomView("MCK-1000", true),
+ }),
+ });
+ });
+
+ await page.locator("#composer-input").fill("Read this issue and record a status.");
+ await page.getByTestId("composer-send").click();
+
+ await expect(page.getByTestId("clean-room-empty")).toHaveCount(0);
+ await expect(page.locator('[data-thread-item="user_message"]')).toBeVisible();
+ await expect(page.locator('[data-thread-item="agent_message"]')).toBeVisible();
+ await expect(page.locator('[data-tool-strip="report_progress"]')).toBeVisible();
+ });
+
+ test("New chat visibly rotates the mock identity", async ({ page }) => {
+ await openCleanRoom(page, ["MCK-1000", "MCK-2000"]);
+ await expect(page.locator(".pit-identifier")).toHaveText("MCK-1000");
+
+ await page.getByTestId("surface-chat-link").click();
+ await expect(page.locator(".pit-identifier")).toHaveText("MCK-2000");
+ await expect(page.getByTestId("clean-room-empty")).toBeVisible();
+ const history = page.getByRole("region", { name: "Session history" });
+ await expect(history.getByRole("button")).toHaveCount(2);
+
+ await history.getByRole("button", { name: /MCK-1000/ }).click();
+ await expect(page.locator(".pit-identifier")).toHaveText("MCK-1000");
+ await expect(page.locator("#composer-input")).toBeEnabled();
+
+ await history.getByRole("button", { name: /MCK-2000/ }).click();
+ await expect(page.locator(".pit-identifier")).toHaveText("MCK-2000");
+ await expect(page.locator("#composer-input")).toBeEnabled();
+ });
+
+ test("a failed live start reports the failure instead of a fixture", async ({ page }) => {
+ await page.addInitScript(() => window.localStorage.clear());
+ await page.route(CLEAN_ROOM_API, async (route) => {
+ await route.fulfill({
+ status: 500,
+ contentType: "application/json",
+ body: JSON.stringify({
+ error: "capability_issue_thread_unavailable",
+ message: "paperclip-runnerd exited before the Codex app-server was ready",
+ }),
+ });
+ });
+ await page.goto("/#/chat");
+
+ const error = page.getByTestId("surface-error");
+ await expect(error).toBeVisible();
+ await expect(error).toContainText("could not start the selected provider");
+ await expect(page.getByTestId("surface-error-retry")).toBeVisible();
+ await expect(page.locator("[data-turn-id]")).toHaveCount(0);
+ });
+
+ test("the narrow layout keeps thread and composer usable without horizontal scroll", async ({
+ page,
+ }) => {
+ await page.setViewportSize(MOBILE);
+ await openCleanRoom(page);
+
+ await expect(page.getByTestId("clean-room-empty")).toBeVisible();
+ await expect(page.locator("#composer-input")).toBeVisible();
+ const overflow = await page.evaluate(
+ () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ );
+ expect(overflow).toBeLessThanOrEqual(0);
+
+ await page.getByTestId("segment-evidence").click();
+ await expect(page.locator(".pit-panel")).toBeVisible();
+ expect(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ ),
+ ).toBeLessThanOrEqual(0);
+ });
+
+ test("axe reports no serious or critical violation on the clean room", async ({ page }) => {
+ await page.setViewportSize(DESKTOP);
+ await openCleanRoom(page);
+ expect(await seriousAxeViolations(page)).toEqual([]);
+
+ await page.setViewportSize(MOBILE);
+ await expect(page.getByTestId("clean-room-empty")).toBeVisible();
+ expect(await seriousAxeViolations(page)).toEqual([]);
+ });
+});
+
+/* ------------------------------------ pending-request live region (TASK-16978) */
+
+/**
+ * Clean-room QA (TASK-16974) heard the visually hidden status region still
+ * announce "A request is waiting for your answer." after the request had been
+ * answered: the live path re-renders with the same request still pending while
+ * the submit is in flight, which used to re-arm the pending copy and leave it
+ * standing once the request settled. These tests pin the live region to the
+ * request's actual state across pending → answered → a later turn.
+ */
+
+const PENDING_ANNOUNCEMENT = "A request is waiting for your answer.";
+
+function questionsItem(state: "pending" | "answered" | "withdrawn"): CapabilityThreadItem {
+ return {
+ kind: "interaction",
+ id: "item-ix-1",
+ at: "2026-08-10T12:00:03.000Z",
+ interactionId: "ix-questions-01",
+ interactionKind: "questions",
+ title: "One scoping question",
+ prompt: "Answer this before I continue.",
+ payload: {
+ kind: "questions",
+ submitLabel: "Submit answers",
+ questions: [
+ {
+ id: "q1",
+ prompt: "Should the runner own process cleanup?",
+ control: "radio",
+ options: ["Yes — runner owns it", "No — harness owns it"],
+ required: true,
+ },
+ ],
+ },
+ state,
+ target: null,
+ stateLabel:
+ state === "pending" ? "Waiting for you" : state === "answered" ? "Answered" : "Withdrawn",
+ resolvedSummary:
+ state === "answered" ? ["Should the runner own process cleanup? Yes — runner owns it"] : [],
+ reason: null,
+ supersededBy: null,
+ evidenceRef: { section: "authorization", recordId: "authz-ix-1" },
+ };
+}
+
+/** A clean-room view carrying one request in the given state. */
+function interactionView(
+ identifier: string,
+ state: "pending" | "answered",
+ options: { withdrawn?: boolean; extraTurn?: boolean } = {},
+): CapabilityIssueThreadSnapshot {
+ const view = JSON.parse(
+ JSON.stringify(cleanRoomView(identifier, true)),
+ ) as CapabilityIssueThreadSnapshot;
+ view.turns[0].items.push(questionsItem(options.withdrawn === true ? "withdrawn" : state));
+ if (options.extraTurn === true) {
+ view.turns.push({
+ id: "turn-2",
+ ordinal: 2,
+ mode: "live",
+ toolCallCount: 0,
+ at: "2026-08-10T12:01:00.000Z",
+ stoppedByUser: false,
+ items: [
+ {
+ kind: "user_message",
+ id: "transcript-3",
+ at: "2026-08-10T12:01:00.000Z",
+ author: "You (board user)",
+ body: "Carry on with the plan.",
+ },
+ {
+ kind: "agent_message",
+ id: "transcript-4",
+ at: "2026-08-10T12:01:01.000Z",
+ author: "Real Codex",
+ body: "Folded the answer into the plan.",
+ streaming: false,
+ },
+ ],
+ });
+ }
+ view.composer =
+ state === "pending"
+ ? {
+ state: "waiting",
+ helper: "Answer the pending request above to continue.",
+ reason: null,
+ pendingInteractionId: "ix-questions-01",
+ }
+ : { state: "ready", helper: null, reason: null, pendingInteractionId: null };
+ return view;
+}
+
+async function openWithPendingRequest(page: Page) {
+ await page.addInitScript(() => window.localStorage.clear());
+ await page.route(CLEAN_ROOM_API, async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ ...cleanRoomPayload("MCK-1000"),
+ view: interactionView("MCK-1000", "pending"),
+ }),
+ });
+ });
+ await page.goto("/#/chat");
+ await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
+}
+
+test.describe("pending-request live region", () => {
+ test("the pending announcement is dropped once the request is answered", async ({ page }) => {
+ await openWithPendingRequest(page);
+
+ const live = page.getByTestId("thread-live-region");
+ await expect(live).toHaveText(PENDING_ANNOUNCEMENT);
+ await expect(page.locator('[data-composer-state="waiting"]')).toBeVisible();
+
+ let interactionRequests = 0;
+ await page.route("**/api/capability/ui/interaction", async (route) => {
+ interactionRequests += 1;
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ ...cleanRoomPayload("MCK-1000"),
+ view: interactionView("MCK-1000", "answered"),
+ }),
+ });
+ });
+
+ const card = page.locator('[data-interaction-id="ix-questions-01"]');
+ await card.getByRole("radio", { name: "Yes — runner owns it" }).check();
+ await card.getByRole("button", { name: "Submit answers" }).click();
+
+ await expect(card).toHaveAttribute("data-interaction-state", "answered");
+ expect(interactionRequests).toBe(1);
+ await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1);
+ await expect(live).not.toContainText(PENDING_ANNOUNCEMENT);
+ await expect(live).toHaveText("Your answer was recorded.");
+
+ // A later turn must not resurrect the pending guidance either.
+ await page.route("**/api/capability/ui/message", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/x-ndjson",
+ body: turnStreamBody({
+ ...cleanRoomPayload("MCK-1000"),
+ view: interactionView("MCK-1000", "answered", { extraTurn: true }),
+ }),
+ });
+ });
+ await page.locator("#composer-input").fill("Carry on with the plan.");
+ await page.getByTestId("composer-send").click();
+
+ await expect(page.locator("[data-turn-id]")).toHaveCount(2);
+ await expect(live).not.toContainText(PENDING_ANNOUNCEMENT);
+ });
+
+ test("a request withdrawn server-side replaces the pending announcement", async ({ page }) => {
+ // Nobody answers here: the request goes away while the session is
+ // reconnecting, so the correction has to come from the settled snapshot.
+ await page.addInitScript(() => window.localStorage.clear());
+ await page.route(CLEAN_ROOM_API, async (route) => {
+ const view = interactionView("MCK-1000", "pending");
+ view.composer.state = "reconnecting";
+ view.connection = { state: "reconnecting", attempt: 2 };
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ ...cleanRoomPayload("MCK-1000"), view }),
+ });
+ });
+ await page.goto("/#/chat");
+ await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
+
+ const live = page.getByTestId("thread-live-region");
+ await expect(live).toHaveText(PENDING_ANNOUNCEMENT);
+
+ await page.route("**/api/capability/ui/reconnect", async (route) => {
+ await route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ ...cleanRoomPayload("MCK-1000"),
+ view: interactionView("MCK-1000", "answered", { withdrawn: true }),
+ }),
+ });
+ });
+ await page.getByRole("button", { name: "Retry now" }).click();
+
+ await expect(page.locator('[data-interaction-state="withdrawn"]')).toBeVisible();
+ await expect(live).not.toContainText(PENDING_ANNOUNCEMENT);
+ await expect(live).toHaveText("The pending request is resolved. Withdrawn.");
+ });
+});
+
+/* -------------------------------------------- Capability streamed live turn */
+
+/**
+ * Streaming is proved against the real package server, not a stub: port 4185
+ * runs the same built bundle and the same session middleware with a scripted
+ * Codex provider (`vite.issue-thread-stream.config.ts`), so the NDJSON turn
+ * stream, the live session, the projection, and the browser client are all the
+ * shipped code. A `route.fulfill` stub cannot show this — it can only deliver a
+ * body in one piece, which is exactly the behaviour under repair.
+ */
+
+const STREAM_ORIGIN = "http://127.0.0.1:4185";
+const STREAM_REPLY =
+ "Reading the clean-room issue. It is blank, with one mock agent and one mock task. " +
+ "Recording a first status against the mock control plane. Done — every record stayed in the mock port.";
+
+async function openStreamingCleanRoom(page: Page): Promise {
+ await page.addInitScript(() => window.localStorage.clear());
+ await page.goto(`${STREAM_ORIGIN}/#/chat`);
+ await expect(page.locator('[data-thread-state="settled"]')).toBeVisible({ timeout: 60_000 });
+}
+
+test.describe("Capability streamed live turn", () => {
+ test("shows sanitized thinking progress before assistant text arrives", async ({ page }) => {
+ await openStreamingCleanRoom(page);
+
+ await page.locator("#composer-input").fill("Show progress while you inspect this issue.");
+ await page.getByTestId("composer-send").click();
+ const liveActivity = page.getByTestId("live-activity");
+ await expect(liveActivity).toBeVisible();
+
+ const progress = page.locator('[data-thread-item="progress_activity"][data-activity="thinking"]');
+ await expect(progress).toBeVisible({ timeout: 30_000 });
+ await expect(progress).toHaveAttribute("data-status", "running");
+ await expect(progress).toContainText("Thinking");
+ await expect(liveActivity).toContainText(/Reasoning|Thinking|Codex/);
+ await expect(page.locator('[data-thread-item="agent_message"]')).toHaveCount(0);
+ await expect(page.locator("body")).not.toContainText(
+ "PRIVATE reasoning text must never reach the browser.",
+ );
+
+ await expect(page.locator('[data-thread-item="agent_message"]')).toContainText(STREAM_REPLY, {
+ timeout: 60_000,
+ });
+ await expect(progress).toHaveAttribute("data-status", "complete");
+ });
+
+ test("one assistant card grows while the turn POST is still open", async ({ page }) => {
+ await openStreamingCleanRoom(page);
+
+ const states: Array<{ id: string; body: string }> = [];
+ let pendingWhileGrowing = 0;
+ let settledWhileGrowing = 0;
+ let turnFinished = false;
+ // Sample the rendered card, not the network: what has to be proved is that
+ // a reader sees the reply grow, and only the DOM can say that.
+ const sampler = (async () => {
+ while (!turnFinished) {
+ const sample = await page.evaluate(() => {
+ const card = document.querySelector('[data-thread-item="agent_message"]');
+ const app = document.querySelector(".pit-app");
+ return {
+ id: card?.id ?? null,
+ body: card?.querySelector(".pit-card-body")?.textContent ?? null,
+ threadState: app?.getAttribute("data-thread-state") ?? null,
+ composer:
+ document.querySelector("[data-composer-state]")?.getAttribute("data-composer-state") ??
+ null,
+ };
+ });
+ if (sample.id !== null && sample.body !== null && sample.body.length > 0) {
+ if (states.at(-1)?.body !== sample.body) states.push({ id: sample.id, body: sample.body });
+ if (sample.body !== STREAM_REPLY) {
+ pendingWhileGrowing += 1;
+ if (sample.threadState === "settled") settledWhileGrowing += 1;
+ }
+ }
+ await page.waitForTimeout(60);
+ }
+ })();
+
+ await page.locator("#composer-input").fill("Read this issue and record a status.");
+ await page.getByTestId("composer-send").click();
+
+ // The whole reply only appears at the end; the card gets there in pieces.
+ await expect(page.locator('[data-thread-item="agent_message"]')).toContainText(STREAM_REPLY, {
+ timeout: 60_000,
+ });
+ turnFinished = true;
+ await sampler;
+
+ const bodies = states.map((state) => state.body);
+ expect(bodies.length).toBeGreaterThanOrEqual(3);
+ // One card: every observed state belonged to the same transcript entry.
+ expect(new Set(states.map((state) => state.id)).size).toBe(1);
+ for (let index = 1; index < bodies.length; index += 1) {
+ expect(bodies[index]!.startsWith(bodies[index - 1]!)).toBe(true);
+ }
+ expect(bodies.at(-1)).toBe(STREAM_REPLY);
+ // Partial states were observed, and the surface never claimed to be
+ // settled while one of them was on screen.
+ expect(pendingWhileGrowing).toBeGreaterThanOrEqual(2);
+ expect(settledWhileGrowing).toBe(0);
+
+ // Settled arrives only after the terminal frame.
+ await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
+ await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1);
+ await expect(page.locator('[data-thread-item="agent_message"]')).toHaveCount(1);
+ await expect(page.locator('[data-thread-item="agent_message"]')).toHaveAttribute(
+ "data-streaming",
+ "false",
+ );
+ });
+
+ test("Stop interrupts the streamed turn and keeps the partial reply", async ({ page }) => {
+ await openStreamingCleanRoom(page);
+
+ await page.locator("#composer-input").fill("Start a long answer so I can stop it.");
+ await page.getByTestId("composer-send").click();
+
+ const card = page.locator('[data-thread-item="agent_message"]');
+ await expect(card).toBeVisible({ timeout: 30_000 });
+ await expect(card).toHaveAttribute("data-streaming", "true");
+ const partial = ((await card.locator(".pit-card-body").textContent()) ?? "").trim();
+ expect(partial.length).toBeGreaterThan(0);
+ expect(STREAM_REPLY.startsWith(partial)).toBe(true);
+
+ await page.getByTestId("composer-stop").click();
+
+ // The stopped turn settles on what it had said, and nothing lands later.
+ await expect(page.locator('[data-thread-state="settled"]')).toBeVisible({ timeout: 30_000 });
+ await expect(page.getByTestId("stopped-marker")).toBeVisible();
+ const stopped = ((await card.locator(".pit-card-body").textContent()) ?? "").trim();
+ expect(STREAM_REPLY.startsWith(stopped)).toBe(true);
+ expect(stopped).not.toBe(STREAM_REPLY);
+ await page.waitForTimeout(1_500);
+ expect(((await card.locator(".pit-card-body").textContent()) ?? "").trim()).toBe(stopped);
+ await expect(page.locator('[data-thread-item="agent_message"]')).toHaveCount(1);
+ });
+});
diff --git a/packages/paperclip-runner/devtools/issue-thread/playwright.config.ts b/packages/paperclip-runner/devtools/issue-thread/playwright.config.ts
new file mode 100644
index 0000000000..ddd35cecbc
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/playwright.config.ts
@@ -0,0 +1,46 @@
+import { defineConfig } from "@playwright/test";
+
+/**
+ * Focused browser suite for the Capability issue thread.
+ *
+ * The suite runs the built app through `vite preview` on loopback. Fake-mode
+ * routes render from package fixtures, so no provider, runnerd, or Codex
+ * process is started here.
+ */
+export default defineConfig({
+ testDir: ".",
+ testMatch: ["issue-thread.spec.ts"],
+ fullyParallel: false,
+ workers: 1,
+ reporter: "line",
+ use: {
+ baseURL: "http://127.0.0.1:4184",
+ viewport: { width: 1440, height: 900 },
+ launchOptions: {
+ // Hosts without the Playwright chromium system libraries can point at a
+ // preinstalled Chromium instead of running `pnpm verify:rootless`.
+ ...(process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH === undefined
+ ? {}
+ : { executablePath: process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH }),
+ },
+ },
+ webServer: [
+ {
+ command:
+ "pnpm exec vite preview --config vite.issue-thread.config.ts --host 127.0.0.1 --port 4184",
+ url: "http://127.0.0.1:4184",
+ reuseExistingServer: false,
+ timeout: 60_000,
+ },
+ {
+ // Same app and same package server, with a scripted Codex provider, so
+ // the streaming checks can drive a real NDJSON turn without a provider
+ // process. See `vite.issue-thread-stream.config.ts`.
+ command:
+ "pnpm exec vite preview --config vite.issue-thread-stream.config.ts --host 127.0.0.1 --port 4185",
+ url: "http://127.0.0.1:4185",
+ reuseExistingServer: false,
+ timeout: 60_000,
+ },
+ ],
+});
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx
new file mode 100644
index 0000000000..5ebeca9d49
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx
@@ -0,0 +1,1589 @@
+import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
+
+import {
+ CAPABILITY_DEFAULT_FIXTURE_PROFILE,
+ capabilityIssueThreadFixture,
+} from "../../../src/issue-thread/fixtures";
+import type {
+ CapabilityEvidenceSectionId,
+ CapabilityIssueThreadSnapshot,
+} from "../../../src/issue-thread/types";
+import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools";
+import { capabilityDenialCount } from "../../../src/issue-thread/types";
+import { Composer } from "./Composer";
+import { EvidencePanel } from "./EvidencePanel";
+import { Icon } from "./Icons";
+import { IssueHeader } from "./IssueHeader";
+import { applyFakeInteractionResponse } from "./fake-store";
+import type { CapabilityInteractionResponse } from "./InteractionCard";
+import {
+ capabilityLiveClient,
+ recallSession,
+ rememberSession,
+ type CapabilityCleanRoomIdentity,
+ type CapabilityHarnessConfiguration,
+} from "./live-client";
+import { parseCapabilityRoute, capabilityRouteHref, type CapabilityRoute } from "./route";
+import { SurfaceNav, type CapabilityChatHistoryItem } from "./SurfaceNav";
+import { EvalAssertions, TurnGroup, type EvalAssertion } from "./ThreadItems";
+
+const PANEL_OPEN_KEY = "paperclip-runner.capability.panel.open";
+/**
+ * The clean room keeps its own panel preference. Evidence is collapsed by
+ * default there on purpose (revision 5: "the default view reads as plain
+ * chat"), and inheriting an explorer session's opened drawer would quietly
+ * break that on the very first visit.
+ */
+const CHAT_PANEL_OPEN_KEY = "paperclip-runner.capability.chat.panel.open";
+const PANEL_WIDTH_KEY = "paperclip-runner.capability.panel.width";
+const PANEL_MIN = 320;
+const PANEL_MAX = 960;
+/** Play-all cadence for the replay strip (§6); slow enough to read a turn. */
+const REPLAY_STEP_MS = 800;
+/**
+ * The pending-request guidance is a constant because the settle path has to
+ * recognise its own stale announcement: a screen reader must not still be told
+ * a request is waiting after it was answered (TASK-16978).
+ */
+const PENDING_ANNOUNCEMENT = "A request is waiting for your answer.";
+const ANSWERED_ANNOUNCEMENT = "Your answer was recorded.";
+const SETTLED_ANNOUNCEMENT = "The pending request is resolved.";
+const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts", "wc-workspace-changes", "fr-file-reference", "pe-structured-plan", "te-command-execution", "mp-mcp-progress", "rs-web-research", "da-subagent-delegation", "mr-model-routing", "cc-context-compaction", "ag-generated-artifact", "rv-review-mode", "hk-hook-lifecycle", "mc-memory-citations", "sr-safety-review", "ti-terminal-input", "wt-intentional-wait", "pn-provider-notices", "ax-acpx-lifecycle", "ax-acpx-permissions", "ax-acpx-tools", "ax-acpx-events", "ax-acpx-failures"];
+const CHAT_HISTORY_KEY = "paperclip-runner.capability.chat.history.v1";
+const CHAT_HARNESS_KEY = "paperclip-runner.capability.chat.harness.v1";
+const MODEL_PRESETS = {
+ codex: ["gpt-5.4-mini", "gpt-5.4"],
+ opencode: ["openrouter/deepseek/deepseek-v4-flash-0731"],
+ acpx: ["openrouter/deepseek/deepseek-v4-flash-0731", "claude-sonnet-5", "gpt-5.6-sol"],
+} as const;
+const ACPX_AGENT_MODELS = {
+ claude: "claude-sonnet-5",
+ codex: "gpt-5.6-sol",
+} as const;
+
+function acpxLabel(configuration: CapabilityHarnessConfiguration | null | undefined): string {
+ if (configuration?.provider !== "acpx") return configuration?.provider ?? "starting";
+ const agent = configuration.acpxAgent ?? "codex";
+ return `Real ${agent === "claude" ? "Claude" : "Codex"} via ACPX`;
+}
+
+interface EmbeddedEvalCheck {
+ id: string;
+ title: string;
+ description: string;
+ passed: boolean;
+ detail: string;
+ definition: Record;
+ anchor: { kind: "item" | "turn" | "run"; id: string };
+}
+
+interface EmbeddedEvalReport {
+ attemptId: string;
+ caseId: string;
+ disposition: string;
+ passed: boolean;
+ checks: EmbeddedEvalCheck[];
+ run: {
+ model: string;
+ provider: string;
+ driver: string;
+ providerVersion: string | null;
+ runnerProvider: string;
+ configuration: string;
+ sessionId: string;
+ providerSessionId: string | null;
+ agentVersion: string | null;
+ retainedSession: boolean | null;
+ retainedSessionStatus: string | null;
+ fixtureDigest: string;
+ runnerPackageDigest: string;
+ runnerdDigest: string;
+ runnerBuild: string;
+ startedAt: string;
+ finishedAt: string;
+ durationMs: number;
+ initialRevision: number;
+ finalRevision: number;
+ usage: {
+ agentTurns: number;
+ providerRequests: number | null;
+ inputTokens: number;
+ outputTokens: number;
+ cachedInputTokens: number;
+ reasoningTokens: number;
+ providerReportedCostNanodollars?: number;
+ estimatedCostNanodollars: number;
+ pricingVersion: string;
+ } | null;
+ };
+ view: CapabilityIssueThreadSnapshot;
+ devtools: CapabilityDevtoolsSnapshot;
+ navigation: { suiteHref: string; previous: { label: string; href: string } | null; next: { label: string; href: string } | null };
+}
+
+declare global {
+ interface Window { __PAPERCLIP_EVAL_REPORT__?: EmbeddedEvalReport }
+}
+
+interface StoredChatSession {
+ sessionId: string;
+ snapshot: CapabilityIssueThreadSnapshot;
+ identity: CapabilityCleanRoomIdentity | null;
+ updatedAt: string;
+ configuration?: CapabilityHarnessConfiguration;
+ runtime?: Awaited>["runtime"];
+}
+
+function defaultHarness(provider: CapabilityHarnessConfiguration["provider"] = "codex"): CapabilityHarnessConfiguration {
+ return {
+ provider,
+ model: MODEL_PRESETS[provider][0],
+ ...(provider === "acpx" ? { acpxAgent: "codex" as const } : {}),
+ lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
+ };
+}
+
+function readHarnessConfiguration(): CapabilityHarnessConfiguration {
+ try {
+ const value = JSON.parse(window.localStorage.getItem(CHAT_HARNESS_KEY) ?? "null") as Partial | null;
+ if (value && (value.provider === "codex" || value.provider === "opencode" || value.provider === "acpx") && typeof value.model === "string") {
+ const lifecyclePolicy = value.lifecyclePolicy?.mode === "per_turn"
+ ? { mode: "per_turn" as const, idleTimeoutMs: null }
+ : value.lifecyclePolicy?.mode === "warm" && Number.isSafeInteger(value.lifecyclePolicy.idleTimeoutMs) && Number(value.lifecyclePolicy.idleTimeoutMs) > 0
+ ? { mode: "warm" as const, idleTimeoutMs: Number(value.lifecyclePolicy.idleTimeoutMs) }
+ : { mode: "warm" as const, idleTimeoutMs: 300_000 };
+ return {
+ provider: value.provider,
+ model: value.model,
+ ...(value.provider === "acpx" ? {
+ acpxAgent: value.acpxAgent === "claude" ? value.acpxAgent : "codex",
+ } : {}),
+ lifecyclePolicy,
+ };
+ }
+ } catch {
+ // Invalid preferences fall back to the qualified defaults.
+ }
+ return defaultHarness();
+}
+
+function persistHarnessConfiguration(configuration: CapabilityHarnessConfiguration): void {
+ try {
+ window.localStorage.setItem(CHAT_HARNESS_KEY, JSON.stringify(configuration));
+ } catch {
+ // Provider/model preference is convenient but not authority-bearing.
+ }
+}
+
+function readChatHistory(): StoredChatSession[] {
+ try {
+ const parsed = JSON.parse(window.localStorage.getItem(CHAT_HISTORY_KEY) ?? "[]") as unknown;
+ if (!Array.isArray(parsed)) return [];
+ return parsed.filter((item): item is StoredChatSession =>
+ typeof item === "object" && item !== null &&
+ typeof (item as StoredChatSession).sessionId === "string" &&
+ typeof (item as StoredChatSession).updatedAt === "string" &&
+ typeof (item as StoredChatSession).snapshot === "object",
+ ).slice(0, 12);
+ } catch {
+ return [];
+ }
+}
+
+function persistChatHistory(history: StoredChatSession[]): void {
+ try {
+ window.localStorage.setItem(CHAT_HISTORY_KEY, JSON.stringify(history.slice(0, 12)));
+ } catch {
+ // History is a convenience; a blocked or full storage quota must not stop chat.
+ }
+}
+
+function readStoredNumber(key: string, fallback: number): number {
+ try {
+ const raw = window.localStorage.getItem(key);
+ const parsed = raw === null ? Number.NaN : Number.parseInt(raw, 10);
+ return Number.isFinite(parsed) ? Math.min(PANEL_MAX, Math.max(PANEL_MIN, parsed)) : fallback;
+ } catch {
+ return fallback;
+ }
+}
+
+function readStoredFlag(key: string, fallback: boolean): boolean {
+ try {
+ const raw = window.localStorage.getItem(key);
+ return raw === null ? fallback : raw === "true";
+ } catch {
+ return fallback;
+ }
+}
+
+/**
+ * Scroll an Evidence target into the panel viewport. `start` alignment has to
+ * account for the sticky panel head, which would otherwise sit on top of the
+ * section header the deep link was supposed to reveal.
+ */
+function scrollEvidenceIntoView(target: Element, block: "start" | "center"): void {
+ target.scrollIntoView({ block });
+ if (block !== "start") return;
+ const panel = target.closest(".pit-panel");
+ const head = panel?.querySelector(".pit-panel-chrome");
+ if (panel == null || head == null) return;
+ panel.scrollTop = Math.max(0, panel.scrollTop - head.offsetHeight);
+}
+
+function describe(cause: unknown): string {
+ return String(cause instanceof Error ? cause.message : cause);
+}
+
+/** Resolution copy for a request that settled without this client answering it. */
+function settledAnnouncement(snapshot: CapabilityIssueThreadSnapshot, interactionId: string): string {
+ for (const turn of snapshot.turns) {
+ for (const item of turn.items) {
+ if (item.kind === "interaction" && item.interactionId === interactionId) {
+ return `${SETTLED_ANNOUNCEMENT} ${item.stateLabel}.`;
+ }
+ }
+ }
+ return SETTLED_ANNOUNCEMENT;
+}
+
+function currentTurnActivity(snapshot: CapabilityIssueThreadSnapshot): string {
+ const harness = snapshot.identity.agentLabel.replace(/^Real /, "");
+ const turn = snapshot.turns.at(-1);
+ if (turn === undefined) return `Dispatching turn to ${harness}`;
+ const activity = [...turn.items].reverse().find((item) =>
+ item.kind === "tool_activity" || item.kind === "progress_activity" || item.kind === "provider_activity" ||
+ (item.kind === "agent_message" && item.streaming),
+ );
+ if (activity?.kind === "tool_activity") {
+ return activity.status === "running"
+ ? `Paperclip tool · ${activity.operationId}`
+ : `Paperclip tool completed · ${activity.operationId}`;
+ }
+ if (activity?.kind === "progress_activity") return activity.summary;
+ if (activity?.kind === "agent_message") return `Receiving ${harness} response`;
+ return `Waiting for ${harness} activity`;
+}
+
+function useRoute(): CapabilityRoute {
+ const [route, setRoute] = useState(() => parseCapabilityRoute(window.location));
+ useEffect(() => {
+ const onChange = () => setRoute(parseCapabilityRoute(window.location));
+ window.addEventListener("hashchange", onChange);
+ window.addEventListener("popstate", onChange);
+ return () => {
+ window.removeEventListener("hashchange", onChange);
+ window.removeEventListener("popstate", onChange);
+ };
+ }, []);
+ return route;
+}
+
+function useLayout(): "side" | "overlay" | "segment" {
+ const [layout, setLayout] = useState<"side" | "overlay" | "segment">(() =>
+ window.innerWidth <= 767 ? "segment" : window.innerWidth <= 1100 ? "overlay" : "side",
+ );
+ useEffect(() => {
+ const onResize = () =>
+ setLayout(window.innerWidth <= 767 ? "segment" : window.innerWidth <= 1100 ? "overlay" : "side");
+ window.addEventListener("resize", onResize);
+ return () => window.removeEventListener("resize", onResize);
+ }, []);
+ return layout;
+}
+
+export function App() {
+ const embeddedEval = window.__PAPERCLIP_EVAL_REPORT__ ?? null;
+ const route = useRoute();
+ const chat = route.surface === "chat";
+ const layout = useLayout();
+
+ useEffect(() => {
+ if (embeddedEval !== null) {
+ document.title = `${embeddedEval.passed ? "✓" : "✕"} ${embeddedEval.caseId} · paperclip-runner eval`;
+ return;
+ }
+ document.title = chat
+ ? "🫧 Mock Paperclip · Issue thread"
+ : "🧯 Mock Paperclip · Issue thread";
+ }, [chat, embeddedEval]);
+
+ const [snapshot, setSnapshot] = useState(null);
+ const [devtools, setDevtools] = useState(null);
+ const [identity, setIdentity] = useState(null);
+ const initialHarnessRef = useRef(readHarnessConfiguration());
+ const [harness, setHarness] = useState(initialHarnessRef.current);
+ const [activeHarness, setActiveHarness] = useState(null);
+ const [providerRuntime, setProviderRuntime] = useState>["runtime"]>(undefined);
+ const [chatHistory, setChatHistory] = useState(readChatHistory);
+ const [historicSessionId, setHistoricSessionId] = useState(null);
+ const liveRoomRef = useRef(null);
+ /**
+ * Which surface produced `snapshot`. A hash change commits the new route in
+ * the same frame that still renders the old snapshot, so without this a
+ * capture or a test can catch one frame of "settled" that belongs to the
+ * surface it just navigated away from.
+ */
+ const [snapshotSurface, setSnapshotSurface] = useState(route.surface);
+ const [error, setError] = useState(null);
+ const [actionError, setActionError] = useState(null);
+ const [loadNonce, setLoadNonce] = useState(0);
+ const [settled, setSettled] = useState(false);
+ const [panelOpen, setPanelOpen] = useState(() => embeddedEval !== null ||
+ readStoredFlag(route.surface === "chat" ? CHAT_PANEL_OPEN_KEY : PANEL_OPEN_KEY, false));
+ const [panelWidth, setPanelWidth] = useState(() => readStoredNumber(PANEL_WIDTH_KEY, 384));
+ const [segment, setSegment] = useState<"thread" | "evidence">(route.segment);
+ const [openSections, setOpenSections] = useState(["tools"]);
+ const [selectedTurnId, setSelectedTurnId] = useState("all");
+ const [highlightedRecordId, setHighlightedRecordId] = useState(route.record);
+ const [focusInteractionId, setFocusInteractionId] = useState(null);
+ const [confirmReset, setConfirmReset] = useState(false);
+ const [announcement, setAnnouncement] = useState("");
+ const [showJump, setShowJump] = useState(false);
+ const [reconnected, setReconnected] = useState(false);
+ const [playing, setPlaying] = useState(false);
+ /** True while a turn stream is open, so the surface never reads as settled. */
+ const [streamingTurn, setStreamingTurn] = useState(false);
+ const scrollRef = useRef(null);
+ const cancelResetRef = useRef(null);
+ /** Last announced pending request, so the live region tracks transitions. */
+ const announcedPendingRef = useRef(null);
+ /**
+ * Turn generation. Reset, `New chat`, and leaving the surface all bump it, so
+ * a frame from a turn that has since been abandoned is dropped instead of
+ * appended to a thread it no longer belongs to.
+ */
+ const turnGenerationRef = useRef(0);
+ const turnAbortRef = useRef(null);
+
+ useEffect(() => {
+ if (embeddedEval !== null) setPanelOpen(true);
+ }, [embeddedEval]);
+
+ /** Abandons any open turn stream: the server sees the disconnect and stops. */
+ const abandonTurn = useCallback(() => {
+ turnGenerationRef.current += 1;
+ const controller = turnAbortRef.current;
+ turnAbortRef.current = null;
+ setStreamingTurn(false);
+ controller?.abort();
+ }, []);
+
+ /* --------------------------------------------------------------- loading */
+
+ useEffect(() => {
+ let cancelled = false;
+ setSettled(false);
+ setError(null);
+ if (embeddedEval !== null) {
+ setIdentity(null);
+ setSnapshot({
+ ...embeddedEval.view,
+ composer: { state: "disabled", helper: null, reason: "Immutable eval recording", pendingInteractionId: null },
+ });
+ setDevtools(embeddedEval.devtools);
+ setSnapshotSurface("issue");
+ } else if (chat) {
+ // The clean room has no fixture fallback: if real Codex and real runnerd
+ // cannot start, the surface says so rather than rendering a canned thread.
+ void (async () => {
+ try {
+ const response = await capabilityLiveClient.loadCleanRoom(
+ recallSession("cleanroom"),
+ initialHarnessRef.current,
+ );
+ if (cancelled) return;
+ rememberSession(response.sessionId, "cleanroom");
+ setIdentity(response.identity ?? null);
+ const configuration = response.configuration ?? initialHarnessRef.current;
+ setActiveHarness(configuration);
+ // The server-returned immutable session configuration is authoritative
+ // for both the active status and the options initially shown for the
+ // next chat after a restore.
+ setHarness(configuration);
+ setProviderRuntime(response.runtime);
+ setHistoricSessionId(null);
+ setSnapshot(response.view);
+ setSnapshotSurface("chat");
+ } catch (cause) {
+ if (!cancelled) setError(String(cause instanceof Error ? cause.message : cause));
+ }
+ })();
+ } else if (route.mode === "live") {
+ void (async () => {
+ try {
+ const response = await capabilityLiveClient.load(recallSession());
+ if (cancelled) return;
+ rememberSession(response.sessionId);
+ setSnapshot(response.view);
+ setSnapshotSurface("issue");
+ } catch (cause) {
+ if (!cancelled) setError(String(cause instanceof Error ? cause.message : cause));
+ }
+ })();
+ } else {
+ setIdentity(null);
+ setSnapshot(capabilityIssueThreadFixture(route.shot ?? "thread-baseline", route.fixtureProfile));
+ setSnapshotSurface("issue");
+ }
+ return () => {
+ cancelled = true;
+ // Leaving the surface (or reloading it) must not leave a turn streaming
+ // into a thread that is no longer on screen.
+ abandonTurn();
+ };
+ }, [abandonTurn, chat, embeddedEval, route.mode, route.shot, route.fixtureProfile, loadNonce]);
+
+ /* ------------------------------------------------------- deep-link params */
+
+ useEffect(() => {
+ if (route.panel === null) return;
+ setPanelOpen(true);
+ setSegment("evidence");
+ setOpenSections((current) =>
+ current.includes(route.panel as CapabilityEvidenceSectionId)
+ ? current
+ : [...current, route.panel as CapabilityEvidenceSectionId],
+ );
+ setHighlightedRecordId(route.record);
+ }, [route.panel, route.record]);
+
+ useLayoutEffect(() => {
+ // A deep link has to land on what it addressed. With `rec` that is the
+ // record; without it, the opened section's header — otherwise the link
+ // half-arrives with the section still below the fold. Wait for the bundled
+ // faces before measuring: a host fallback can otherwise move the retained
+ // scroll position by a pixel even after the final webfonts replace it.
+ if (route.panel === null || snapshot === null) return;
+ const panel = route.panel;
+ const record = route.record;
+ let cancelled = false;
+ void (async () => {
+ if (typeof document.fonts?.ready?.then === "function") {
+ await document.fonts.ready;
+ }
+ if (cancelled) return;
+ const target =
+ record === null
+ ? document.querySelector(`[data-evidence-section="${CSS.escape(panel)}"]`)
+ : document.querySelector(`[data-record-id="${CSS.escape(record)}"]`);
+ if (target !== null) {
+ scrollEvidenceIntoView(target, record === null ? "start" : "center");
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [route.panel, route.record, snapshot, panelOpen, segment, openSections]);
+
+ useEffect(() => {
+ setSegment(route.segment);
+ }, [route.segment]);
+
+ /* -------------------------------------------------- settle + auto-follow */
+
+ useEffect(() => {
+ if (snapshot === null) return;
+ let cancelled = false;
+ const scroller = scrollRef.current;
+ void (async () => {
+ if (typeof document.fonts?.ready?.then === "function") {
+ await document.fonts.ready;
+ }
+ if (cancelled) return;
+ if (scroller !== null) scroller.scrollTop = scroller.scrollHeight;
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
+ if (!cancelled) setSettled(true);
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [snapshot]);
+
+ useEffect(() => {
+ if (!panelOpen || snapshot === null || route.mode !== "live") return;
+ if (historicSessionId !== null) return;
+ let cancelled = false;
+ void capabilityLiveClient.devtools(snapshot.sessionId)
+ .then((next) => { if (!cancelled) setDevtools(next); })
+ .catch((cause) => { if (!cancelled) setActionError(describe(cause)); });
+ return () => { cancelled = true; };
+ }, [historicSessionId, panelOpen, route.mode, snapshot?.renderedAt, snapshot?.sessionId]);
+
+ useEffect(() => {
+ if (!chat || snapshot === null || historicSessionId !== null) return;
+ const record: StoredChatSession = {
+ sessionId: snapshot.sessionId,
+ snapshot,
+ identity,
+ updatedAt: snapshot.renderedAt,
+ ...(activeHarness === null ? {} : { configuration: activeHarness }),
+ ...(providerRuntime === undefined ? {} : { runtime: providerRuntime }),
+ };
+ liveRoomRef.current = record;
+ setChatHistory((current) => {
+ const next = [record, ...current.filter((item) => item.sessionId !== record.sessionId)].slice(0, 12);
+ persistChatHistory(next);
+ return next;
+ });
+ }, [activeHarness, chat, historicSessionId, identity, providerRuntime, snapshot?.renderedAt, snapshot?.sessionId]);
+
+ useEffect(() => {
+ if (snapshot === null) return;
+ const pending = snapshot.composer.pendingInteractionId;
+ const announced = announcedPendingRef.current;
+ announcedPendingRef.current = pending;
+
+ if (pending !== null) {
+ // Announce a request once, when it arrives. Re-announcing on every
+ // snapshot would talk over the outcome the user just triggered: a live
+ // submit re-renders with the same request still pending.
+ if (pending !== announced) setAnnouncement(PENDING_ANNOUNCEMENT);
+ return;
+ }
+ if (snapshot.connection.state === "reconnecting") {
+ setAnnouncement(`Connection lost — retrying (attempt ${snapshot.connection.attempt}).`);
+ return;
+ }
+ if (announced === null) return;
+ // The request settled. Anything the resolution itself announced stands;
+ // only the now-false "still waiting" guidance is replaced, so a screen
+ // reader never carries pending-state copy into later turns (TASK-16978).
+ setAnnouncement((current) =>
+ current === PENDING_ANNOUNCEMENT ? settledAnnouncement(snapshot, announced) : current,
+ );
+ }, [snapshot]);
+
+ /* -------------------------------------------------------------- handlers */
+
+ const persistPanel = useCallback(
+ (open: boolean, width: number) => {
+ try {
+ window.localStorage.setItem(chat ? CHAT_PANEL_OPEN_KEY : PANEL_OPEN_KEY, String(open));
+ window.localStorage.setItem(PANEL_WIDTH_KEY, String(width));
+ } catch {
+ // Panel preference is a convenience, never a correctness requirement.
+ }
+ },
+ [chat],
+ );
+
+ const openEvidence = useCallback(
+ (section: CapabilityEvidenceSectionId, recordId: string) => {
+ setPanelOpen(true);
+ setSegment("evidence");
+ setSelectedTurnId("all");
+ setOpenSections((current) => (current.includes(section) ? current : [...current, section]));
+ setHighlightedRecordId(recordId);
+ persistPanel(true, panelWidth);
+ window.setTimeout(() => {
+ document
+ .querySelector(`[data-record-id="${CSS.escape(recordId)}"]`)
+ ?.scrollIntoView({ block: "center" });
+ }, 0);
+ },
+ [panelWidth, persistPanel],
+ );
+
+ const closeEvidence = useCallback(() => {
+ setPanelOpen(false);
+ persistPanel(false, panelWidth);
+ if (layout === "segment") setSegment("thread");
+ // Focus came into the panel when it opened (§9.2), so hand it back to the
+ // visible control that owns the panel rather than dropping it on `body`.
+ window.setTimeout(() => {
+ const candidates = ['[data-testid="evidence-toggle"]', '[data-testid="segment-thread"]'];
+ for (const selector of candidates) {
+ const control = document.querySelector(selector);
+ if (control !== null && control.offsetParent !== null) {
+ control.focus();
+ return;
+ }
+ }
+ }, 0);
+ }, [layout, panelWidth, persistPanel]);
+
+ const jumpToThread = useCallback((anchorId: string) => {
+ setSegment("thread");
+ const anchor = document.getElementById(anchorId) ?? document.querySelector(`#${CSS.escape(anchorId)}`);
+ anchor?.scrollIntoView({ block: "center" });
+ }, []);
+
+ const respond = useCallback(
+ (response: CapabilityInteractionResponse) => {
+ if (snapshot === null) return;
+ if (route.mode !== "live") {
+ setSnapshot(applyFakeInteractionResponse(snapshot, response));
+ setAnnouncement(ANSWERED_ANNOUNCEMENT);
+ return;
+ }
+
+ // Network effects must never live inside a React state updater. Strict
+ // Mode intentionally invokes updater functions more than once in
+ // development, which previously submitted every interaction twice: the
+ // first request resolved it and the duplicate produced "not pending".
+ const sessionId = snapshot.sessionId;
+ setActionError(null);
+ setSnapshot((current) => current === null ? current : {
+ ...current,
+ turns: current.turns.map((turn) => ({
+ ...turn,
+ items: turn.items.map((item) =>
+ item.kind === "interaction" && item.interactionId === response.interactionId
+ ? { ...item, state: "submitting" as const, stateLabel: "Submitting…" }
+ : item,
+ ),
+ })),
+ });
+ void capabilityLiveClient
+ .respond(sessionId, response.interactionId, response.outcome, response.result)
+ .then((next) => {
+ setSnapshot(next.view);
+ setAnnouncement(ANSWERED_ANNOUNCEMENT);
+ })
+ .catch((cause) => {
+ setActionError(describe(cause));
+ setSnapshot((current) => current === null ? current : {
+ ...current,
+ turns: current.turns.map((turn) => ({
+ ...turn,
+ items: turn.items.map((item) =>
+ item.kind === "interaction" &&
+ item.interactionId === response.interactionId &&
+ item.state === "submitting"
+ ? { ...item, state: "pending" as const, stateLabel: "Waiting for you" }
+ : item,
+ ),
+ })),
+ });
+ });
+ },
+ [route.mode, snapshot],
+ );
+
+ /**
+ * Sends one message and renders the turn as it arrives.
+ *
+ * Each frame is a whole server projection, so the surface still never patches
+ * state locally — it just gets more than one projection per turn. The settled
+ * payload is applied last and stays the authority.
+ */
+ const send = useCallback(
+ (message: string) => {
+ if (snapshot === null) return;
+ if (route.mode !== "live") return;
+ setActionError(null);
+ abandonTurn();
+ const generation = turnGenerationRef.current;
+ const controller = new AbortController();
+ turnAbortRef.current = controller;
+ setStreamingTurn(true);
+ setSnapshot((current) =>
+ current === null ? current : { ...current, composer: { ...current.composer, state: "sending" } },
+ );
+ void capabilityLiveClient
+ .send(snapshot.sessionId, message, {
+ signal: controller.signal,
+ onFrame: (view) => {
+ if (turnGenerationRef.current === generation) setSnapshot(view);
+ },
+ })
+ .then((next) => {
+ if (turnGenerationRef.current === generation) {
+ setSnapshot(next.view);
+ setProviderRuntime(next.runtime);
+ }
+ })
+ .catch((cause) => {
+ if (turnGenerationRef.current !== generation || controller.signal.aborted) return;
+ setActionError(describe(cause));
+ // A refused turn must not strand the composer in `sending`.
+ setSnapshot((stale) =>
+ stale === null ? stale : { ...stale, composer: { ...stale.composer, state: "ready" } },
+ );
+ })
+ .finally(() => {
+ if (turnGenerationRef.current !== generation) return;
+ turnAbortRef.current = null;
+ setStreamingTurn(false);
+ });
+ },
+ [abandonTurn, route.mode, snapshot],
+ );
+
+ const stop = useCallback(() => {
+ setSnapshot((current) => {
+ if (current === null) return current;
+ if (route.mode === "live") {
+ // Stop reaches the provider interrupt through its own request; the open
+ // turn stream keeps rendering and its terminal frame — not this
+ // response — decides what the stopped turn finally looks like.
+ const streaming = turnAbortRef.current !== null;
+ void capabilityLiveClient
+ .stop(current.sessionId)
+ .then((next) => {
+ if (!streaming && turnAbortRef.current === null) setSnapshot(next.view);
+ })
+ .catch((cause) => setActionError(describe(cause)));
+ return current;
+ }
+ const turns = current.turns.map((turn, index) =>
+ index === current.turns.length - 1 ? { ...turn, stoppedByUser: true } : turn,
+ );
+ return {
+ ...current,
+ turns,
+ composer: { state: "ready", helper: null, reason: null, pendingInteractionId: null },
+ };
+ });
+ setAnnouncement("Turn stopped. Partial output is preserved.");
+ }, [route.mode]);
+
+ /**
+ * Adopts a rotated clean room. Reset and `New chat` both land here: the
+ * server always answers with a new session id and new mock identities, so the
+ * client's job is only to forget the old ones.
+ */
+ const adoptCleanRoom = useCallback((next: Awaited>) => {
+ rememberSession(next.sessionId, "cleanroom");
+ setIdentity(next.identity ?? null);
+ const configuration = next.configuration ?? harness;
+ setActiveHarness(configuration);
+ setHarness(configuration);
+ setProviderRuntime(next.runtime);
+ setHistoricSessionId(null);
+ setSnapshot(next.view);
+ setDevtools(null);
+ setError(null);
+ setActionError(null);
+ setAnnouncement(
+ `New clean-room chat started on ${next.view.issue.identifier}. The previous session was closed.`,
+ );
+ }, [harness]);
+
+ const newChat = useCallback(() => {
+ const model = harness.model?.trim() ?? "";
+ if (!model) {
+ setActionError("Choose or enter a model before starting a new chat.");
+ return;
+ }
+ if (harness.provider === "opencode" && !model.includes("/")) {
+ setActionError("OpenCode models must use provider/model form.");
+ return;
+ }
+ if (harness.provider === "acpx") {
+ const agent = harness.acpxAgent ?? "codex";
+ if (model !== ACPX_AGENT_MODELS[agent]) {
+ setActionError(`The qualified ACPX ${agent} profile requires exact model ${ACPX_AGENT_MODELS[agent]}.`);
+ return;
+ }
+ }
+ const configuration = { ...harness, model };
+ persistHarnessConfiguration(configuration);
+ setConfirmReset(false);
+ // The room this turn belongs to is about to be retired, so the stream is
+ // dropped before the request that retires it.
+ abandonTurn();
+ setAnnouncement("Starting a new clean-room chat…");
+ void capabilityLiveClient
+ .newCleanRoom(liveRoomRef.current?.sessionId ?? snapshot?.sessionId ?? null, configuration)
+ .then(adoptCleanRoom)
+ .catch((cause) => setActionError(describe(cause)));
+ }, [abandonTurn, adoptCleanRoom, harness, snapshot]);
+
+ const selectChatHistory = useCallback((sessionId: string) => {
+ const live = liveRoomRef.current;
+ if (live?.sessionId === sessionId) {
+ setSnapshot(live.snapshot);
+ setIdentity(live.identity);
+ setActiveHarness(live.configuration ?? null);
+ if (live.configuration !== undefined) setHarness(live.configuration);
+ setProviderRuntime(live.runtime);
+ setDevtools(null);
+ setHistoricSessionId(null);
+ setActionError(null);
+ return;
+ }
+ const archived = chatHistory.find((item) => item.sessionId === sessionId);
+ if (archived === undefined) return;
+ abandonTurn();
+ const configuration = archived.configuration ?? defaultHarness();
+ void capabilityLiveClient.loadCleanRoom(sessionId, configuration)
+ .then((restored) => {
+ rememberSession(restored.sessionId, "cleanroom");
+ setSnapshot(restored.view);
+ setIdentity(restored.identity ?? archived.identity);
+ setActiveHarness(restored.configuration ?? configuration);
+ setHarness(restored.configuration ?? configuration);
+ setProviderRuntime(restored.runtime);
+ setDevtools(null);
+ setHistoricSessionId(null);
+ setActionError(null);
+ setAnnouncement("Archived chat restored. The runner will resume when you send a message.");
+ })
+ .catch((cause) => setActionError(describe(cause)));
+ }, [abandonTurn, chatHistory]);
+
+ const reset = useCallback(() => {
+ setConfirmReset(false);
+ abandonTurn();
+ if (chat && snapshot !== null) {
+ void capabilityLiveClient
+ .reset(snapshot.sessionId)
+ .then(adoptCleanRoom)
+ .catch((cause) => setActionError(describe(cause)));
+ return;
+ }
+ if (route.mode === "live" && snapshot !== null) {
+ void capabilityLiveClient
+ .reset(snapshot.sessionId)
+ .then((next) => {
+ rememberSession(next.sessionId);
+ setSnapshot(next.view);
+ })
+ .catch((cause) => setActionError(describe(cause)));
+ return;
+ }
+ setSnapshot(capabilityIssueThreadFixture("thread-baseline", route.fixtureProfile));
+ setAnnouncement("Scenario reset. The mock state is back to its clean seed.");
+ }, [abandonTurn, adoptCleanRoom, chat, route.fixtureProfile, route.mode, snapshot]);
+
+ const retry = useCallback(() => {
+ if (route.mode === "live" && snapshot !== null) {
+ void capabilityLiveClient
+ .reconnect(snapshot.sessionId)
+ .then((next) => setSnapshot(next.view))
+ .catch((cause) => setActionError(describe(cause)));
+ return;
+ }
+ setSnapshot((current) =>
+ current === null
+ ? current
+ : {
+ ...current,
+ connection: { state: "connected", attempt: 0 },
+ composer: { state: "ready", helper: null, reason: null, pendingInteractionId: null },
+ },
+ );
+ setReconnected(true);
+ window.setTimeout(() => setReconnected(false), 3_000);
+ }, [route.mode, snapshot]);
+
+ useEffect(() => {
+ if (!confirmReset) return;
+ cancelResetRef.current?.focus();
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") setConfirmReset(false);
+ }
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [confirmReset]);
+
+ /* ----------------------------------------------------------- replay (§6) */
+
+ // `?at=` is the source of truth for where the recording is parked,
+ // so step / next-turn / play-all and the deep link all move the same value.
+ const replay = useMemo(() => {
+ if (snapshot === null || snapshot.replay === null) return null;
+ const { total } = snapshot.replay;
+ const ordinal =
+ route.at === null ? snapshot.replay.ordinal : Math.min(total, Math.max(0, route.at));
+ return { ordinal, total };
+ }, [snapshot, route.at]);
+
+ const seekReplay = useCallback(
+ (ordinal: number) => {
+ setPlaying(false);
+ window.location.hash = capabilityRouteHref(route, { at: ordinal });
+ },
+ [route],
+ );
+
+ useEffect(() => {
+ if (!playing || replay === null) return;
+ if (replay.ordinal >= replay.total) {
+ setPlaying(false);
+ return;
+ }
+ const timer = window.setTimeout(() => {
+ window.location.hash = capabilityRouteHref(route, { at: replay.ordinal + 1 });
+ }, REPLAY_STEP_MS);
+ return () => window.clearTimeout(timer);
+ }, [playing, replay, route]);
+
+ useEffect(() => {
+ if (replay === null) setPlaying(false);
+ }, [replay]);
+
+ /* ---------------------------------------------------------------- render */
+
+ const denialCount = useMemo(
+ () => (snapshot === null ? 0 : capabilityDenialCount(snapshot.evidence, null)),
+ [snapshot],
+ );
+ const historyItems: CapabilityChatHistoryItem[] = chatHistory.map((item) => ({
+ sessionId: item.sessionId,
+ identifier: item.snapshot.issue.identifier,
+ title: item.snapshot.issue.title,
+ updatedAt: item.updatedAt,
+ current: liveRoomRef.current?.sessionId === item.sessionId,
+ }));
+ const surfaceNav = (
+ { window.location.hash = "#/chat"; }}
+ onSelectHistory={selectChatHistory}
+ />
+ );
+
+ if (error !== null) {
+ return (
+
+ {surfaceNav}
+
+
+ {chat
+ ? `The clean-room chat could not start the selected provider: ${error}`
+ : error}
+
+ {chat ? (
+ <>
+
+ The clean room only runs against the selected real provider through real runnerd, so it does not fall
+ back to a fixture or a recording.
+
+
+
+ {harness.provider === "acpx" ? (
+
+ ) : null}
+
+ {harness.lifecyclePolicy.mode === "warm" ? (
+
+ ) : null}
+
+
+
+ >
+ ) : null}
+
+
+
+ );
+ }
+
+ if (snapshot === null) {
+ return (
+
+ {surfaceNav}
+
+
+ {chat
+ ? "Starting real runnerd and the selected provider for a fresh mock tenant…"
+ : "Loading…"}
+
+
+
+ );
+ }
+
+ const showThread = layout !== "segment" || segment === "thread";
+ const showPanel = layout === "segment" ? segment === "evidence" : panelOpen;
+
+ return (
+
+ {embeddedEval === null ? surfaceNav : null}
+
+
{
+ const next = !panelOpen;
+ setPanelOpen(next);
+ persistPanel(next, panelWidth);
+ if (layout === "segment") setSegment(next ? "evidence" : "thread");
+ }}
+ onSelectScenario={(scenario) => {
+ window.location.hash = capabilityRouteHref(route, { fixtureProfile: scenario });
+ }}
+ onReplay={() => {
+ window.location.hash = capabilityRouteHref(route, {
+ shot: "replay-mode",
+ mode: "replay",
+ at: 12,
+ });
+ }}
+ onReset={() => setConfirmReset(true)}
+ onStop={stop}
+ onSelectSegment={setSegment}
+ />
+
+ {chat && embeddedEval === null ? (
+
+
+ {harness.provider === "acpx" ? (
+
+ ) : null}
+
+ {harness.lifecyclePolicy.mode === "warm" ? (
+
+ ) : null}
+
+
+
+ Active: {acpxLabel(activeHarness)}
+ {activeHarness?.model ? ` · ${activeHarness.model}` : ""}
+ {activeHarness ? ` · ${activeHarness.lifecyclePolicy.mode === "warm" ? `warm ${Math.round(activeHarness.lifecyclePolicy.idleTimeoutMs / 1_000)}s` : "turn by turn"}` : ""}
+ {providerRuntime?.runnerPid ? ` · runner PID ${providerRuntime.runnerPid}` : ""}
+ {activeHarness?.provider === "acpx" && providerRuntime?.sidecarPid ? ` · sidecar PID ${providerRuntime.sidecarPid}` : ""}
+ {activeHarness?.provider === "acpx" && providerRuntime?.agentPid ? ` · agent PID ${providerRuntime.agentPid}` : ""}
+ {activeHarness?.provider === "acpx" && providerRuntime?.driverSessionId ? ` · ACPX record ${providerRuntime.driverSessionId}` : ""}
+ {providerRuntime?.providerSessionId ? ` · session ${providerRuntime.providerSessionId}` : ""}
+ {activeHarness?.provider === "acpx" && providerRuntime?.providerVersion ? ` · ACPX ${providerRuntime.providerVersion}` : ""}
+ {activeHarness?.provider === "acpx" && providerRuntime?.agentServerVersion ? ` · agent ${providerRuntime.agentServerVersion}` : ""}
+ {activeHarness?.provider === "acpx" && providerRuntime?.acpProtocolVersion ? ` · ACP ${providerRuntime.acpProtocolVersion}` : ""}
+ {providerRuntime?.status ? ` · ${providerRuntime.status}` : ""}
+
+
+ ) : null}
+
+ {embeddedEval !== null ? (
+
+ ) : null}
+
+ {actionError !== null ? (
+
+ ⚠
+ {actionError}
+
+
+ ) : null}
+
+ {snapshot.connection.state === "reconnecting" ? (
+
+ ⏳
+ Connection lost — retrying (attempt {snapshot.connection.attempt})
+
+ ) : reconnected ? (
+
+ ✓
+ Reconnected
+
+ ) : null}
+
+ {streamingTurn ? (
+
+
+
+ {currentTurnActivity(snapshot)}
+ live
+
+ ) : null}
+
+ {replay !== null ? (
+
+
+ Replay {replay.ordinal}/{replay.total}
+
+
+
+
+
+
+ ) : null}
+
+
+
+
+
{
+ const element = event.currentTarget;
+ const distance =
+ element.scrollHeight - element.scrollTop - element.clientHeight;
+ setShowJump(distance > 300);
+ }}
+ >
+
+ {embeddedEval !== null ? (
+
Eval execution
+ ) : null}
+ {snapshot.turns.length === 0 ? (
+
+ Start a clean-room chat
+
+ This is a blank thread on a brand-new mock tenant:{" "}
+ {snapshot.issue.identifier} in{" "}
+ Mock Paperclip (clean room). Nothing has been said, called, or
+ recorded yet.
+
+
+ Your first message starts a real {snapshot.identity.agentLabel.replace(/^Real /, "")} turn through real runnerd. The agent may
+ call the semantic tools this session exposes, and every record it creates lands
+ in the mock control plane only — never a real Paperclip API. Detailed tool,
+ policy, event, and state evidence stays in the Evidence drawer until you open
+ it.
+
+
+ ) : (
+ snapshot.turns.map((turn) => (
+
check.anchor.kind === "item")
+ .map((check) => [check.anchor.id, [{ ...check } satisfies EvalAssertion]]) ?? [])}
+ terminalAssertions={embeddedEval?.checks
+ .filter((check) => check.anchor.kind === "turn" && check.anchor.id === turn.id)
+ .map((check) => ({ ...check })) ?? []}
+ />
+ ))
+ )}
+ {embeddedEval !== null ? (
+
+ Post-run state
+ Final mock control-plane revision {embeddedEval.run.finalRevision}
+ check.anchor.kind === "run")} />
+
+ ) : null}
+
+
+ {showJump ? (
+
+ ) : null}
+
+
+ (chat ? newChat() : setConfirmReset(true))}
+ resetLabel={chat ? "New chat" : "Reset scenario"}
+ onFocusPending={(interactionId) => {
+ setFocusInteractionId(interactionId);
+ document
+ .getElementById(`interaction-${interactionId}`)
+ ?.scrollIntoView({ block: "center" });
+ }}
+ />
+
+
+ {showPanel && layout === "side" ? (
+
{
+ const splitter = event.currentTarget;
+ splitter.setPointerCapture(event.pointerId);
+ const resize = (pointer: PointerEvent) => {
+ const next = Math.min(PANEL_MAX, Math.max(PANEL_MIN, window.innerWidth - pointer.clientX));
+ setPanelWidth(next);
+ };
+ const finish = () => {
+ splitter.removeEventListener("pointermove", resize);
+ splitter.removeEventListener("pointerup", finish);
+ splitter.removeEventListener("pointercancel", finish);
+ setPanelWidth((current) => {
+ persistPanel(panelOpen, current);
+ return current;
+ });
+ };
+ splitter.addEventListener("pointermove", resize);
+ splitter.addEventListener("pointerup", finish);
+ splitter.addEventListener("pointercancel", finish);
+ }}
+ onKeyDown={(event) => {
+ const step = event.shiftKey ? 64 : 16;
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ setPanelWidth((current) => {
+ const next = Math.min(PANEL_MAX, current + step);
+ persistPanel(panelOpen, next);
+ return next;
+ });
+ } else if (event.key === "ArrowRight") {
+ event.preventDefault();
+ setPanelWidth((current) => {
+ const next = Math.max(PANEL_MIN, current - step);
+ persistPanel(panelOpen, next);
+ return next;
+ });
+ }
+ }}
+ />
+ ) : null}
+
+ {showPanel ? (
+ {
+ abandonTurn();
+ setActionError(null);
+ void capabilityLiveClient.fork(snapshot.sessionId, revision)
+ .then((next) => {
+ rememberSession(next.sessionId, chat ? "cleanroom" : "issue");
+ setSnapshot(next.view);
+ setIdentity(next.identity ?? null);
+ setDevtools(null);
+ })
+ .catch((cause) => setActionError(describe(cause)));
+ }}
+ layout={layout}
+ width={panelWidth}
+ selectedTurnId={selectedTurnId}
+ openSections={openSections}
+ highlightedRecordId={highlightedRecordId}
+ onSelectTurn={setSelectedTurnId}
+ onToggleSection={(section) =>
+ setOpenSections((current) =>
+ current.includes(section)
+ ? current.filter((entry) => entry !== section)
+ : [...current, section],
+ )
+ }
+ onClose={closeEvidence}
+ onJumpToThread={jumpToThread}
+ onInvokeTool={route.mode === "live" && historicSessionId === null ? async (operationId, input) => {
+ setActionError(null);
+ const next = await capabilityLiveClient.invokeTool(snapshot.sessionId, operationId, input);
+ setSnapshot(next.view);
+ setSelectedTurnId(next.toolTurnId);
+ return next.toolResult;
+ } : undefined}
+ />
+ ) : null}
+
+
+ {confirmReset ? (
+
+
+
+ {chat ? "Reset this chat?" : "Reset scenario?"}
+
+
+ {chat
+ ? "This stops any active turn, closes this session's authority, and opens a new mock tenant with new identities. The transcript and its mock records will be lost."
+ : "This clears the mock state and starts a clean session. The transcript will be lost."}
+
+
+
+
+
+
+
+ ) : null}
+
+
+ {announcement}
+
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/Composer.tsx b/packages/paperclip-runner/devtools/issue-thread/src/Composer.tsx
new file mode 100644
index 0000000000..e1c5242b38
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/Composer.tsx
@@ -0,0 +1,161 @@
+import { useEffect, useRef, useState, type FormEvent } from "react";
+
+import type { CapabilityComposerModel } from "../../../src/issue-thread/types";
+
+/**
+ * Composer (contract §4). `data-composer-state` is the single test and
+ * screenshot hook. Draft text survives refresh through localStorage keyed by
+ * session id; Stop never discards the transcript.
+ */
+
+export interface ComposerProps {
+ model: CapabilityComposerModel;
+ sessionId: string;
+ onSend: (message: string) => void;
+ onStop: () => void;
+ onRetry: () => void;
+ onReset: () => void;
+ onFocusPending: (interactionId: string) => void;
+ /** The clean room resets into a new tenant, so it names the action itself. */
+ resetLabel?: string;
+}
+
+function draftKey(sessionId: string): string {
+ return `paperclip-runner.capability.draft.${sessionId}`;
+}
+
+function readDraft(sessionId: string): string {
+ try {
+ return window.localStorage.getItem(draftKey(sessionId)) ?? "";
+ } catch {
+ return "";
+ }
+}
+
+export function Composer(props: ComposerProps) {
+ const {
+ model,
+ sessionId,
+ onSend,
+ onStop,
+ onRetry,
+ onReset,
+ onFocusPending,
+ resetLabel = "Reset scenario",
+ } = props;
+ const [value, setValue] = useState(() => readDraft(sessionId));
+ const inputRef = useRef(null);
+
+ useEffect(() => {
+ setValue(readDraft(sessionId));
+ }, [sessionId]);
+
+ useEffect(() => {
+ try {
+ window.localStorage.setItem(draftKey(sessionId), value);
+ } catch {
+ // A blocked storage quota must not break the session.
+ }
+ }, [sessionId, value]);
+
+ const editable = model.state === "ready" || model.state === "streaming";
+ const canSend = editable && value.trim().length > 0;
+
+ function submit(event: FormEvent) {
+ event.preventDefault();
+ if (!canSend) return;
+ onSend(value.trim());
+ setValue("");
+ }
+
+ return (
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/DevtoolsInspector.tsx b/packages/paperclip-runner/devtools/issue-thread/src/DevtoolsInspector.tsx
new file mode 100644
index 0000000000..acfd180775
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/DevtoolsInspector.tsx
@@ -0,0 +1,646 @@
+import { useEffect, useMemo, useState } from "react";
+
+import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools";
+import { Icon, type IconName } from "./Icons";
+import { EvalAssertions, type EvalAssertion } from "./ThreadItems";
+
+export type CapabilityDevtoolsTab =
+ | "eval"
+ | "evidence"
+ | "timeline"
+ | "state"
+ | "diff"
+ | "documents"
+ | "protocol"
+ | "trace"
+ | "runtime"
+ | "authority";
+type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
+
+export interface EvalInspectorReport {
+ attemptId: string;
+ caseId: string;
+ disposition: string;
+ passed: boolean;
+ checks: EvalAssertion[];
+ run: {
+ model: string;
+ provider: string;
+ driver: string;
+ providerVersion: string | null;
+ runnerProvider: string;
+ configuration: string;
+ sessionId: string;
+ providerSessionId: string | null;
+ agentVersion: string | null;
+ retainedSession: boolean | null;
+ retainedSessionStatus: string | null;
+ fixtureDigest: string;
+ runnerPackageDigest: string;
+ runnerdDigest: string;
+ runnerBuild: string;
+ startedAt: string;
+ finishedAt: string;
+ durationMs: number;
+ initialRevision: number;
+ finalRevision: number;
+ usage: {
+ agentTurns: number;
+ providerRequests: number | null;
+ inputTokens: number;
+ outputTokens: number;
+ cachedInputTokens: number;
+ reasoningTokens: number;
+ providerReportedCostNanodollars?: number;
+ estimatedCostNanodollars: number;
+ pricingVersion: string;
+ } | null;
+ };
+}
+
+function JsonTree({ value, name = "root" }: { value: Json; name?: string }) {
+ if (value === null || typeof value !== "object") {
+ return {JSON.stringify(value)};
+ }
+ const entries = Array.isArray(value)
+ ? value.map((entry, index) => [String(index), entry] as const)
+ : Object.entries(value);
+ return (
+
+
+ {name}{" "}
+
+ {Array.isArray(value) ? `[${entries.length}]` : `{${entries.length}}`}
+
+
+
+ {entries.map(([key, entry]) => (
+
+ {key}
+ {entry !== null && typeof entry === "object" ? (
+
+ ) : (
+
+ )}
+
+ ))}
+
+
+ );
+}
+
+interface DiffRow {
+ path: string;
+ before: Json | undefined;
+ after: Json | undefined;
+}
+
+function diff(
+ before: Json | undefined,
+ after: Json | undefined,
+ path = "state",
+): DiffRow[] {
+ if (JSON.stringify(before) === JSON.stringify(after)) return [];
+ if (
+ before !== null &&
+ after !== null &&
+ typeof before === "object" &&
+ typeof after === "object" &&
+ !Array.isArray(before) &&
+ !Array.isArray(after)
+ ) {
+ const keys = new Set([...Object.keys(before), ...Object.keys(after)]);
+ return [...keys].flatMap((key) =>
+ diff(before[key], after[key], `${path}.${key}`),
+ );
+ }
+ return [{ path, before, after }];
+}
+
+function download(snapshot: CapabilityDevtoolsSnapshot) {
+ const blob = new Blob([JSON.stringify(snapshot, null, 2)], {
+ type: "application/json",
+ });
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = `paperclip-devtools-r${snapshot.currentRevision}.json`;
+ anchor.click();
+ URL.revokeObjectURL(url);
+}
+
+const TABS: ReadonlyArray<{
+ id: CapabilityDevtoolsTab;
+ icon: IconName;
+ label: string;
+}> = [
+ { id: "evidence", icon: "evidence", label: "Evidence" },
+ { id: "timeline", icon: "timeline", label: "Timeline" },
+ { id: "state", icon: "state", label: "State" },
+ { id: "diff", icon: "diff", label: "Diff" },
+ { id: "documents", icon: "documents", label: "Documents" },
+ { id: "protocol", icon: "protocol", label: "Protocol" },
+ { id: "trace", icon: "protocol", label: "Provider trace" },
+ { id: "runtime", icon: "terminal", label: "Runtime" },
+ { id: "authority", icon: "authority", label: "Authority" },
+];
+
+function documentsOf(state: Json): Array<{
+ id: string;
+ key: string;
+ title: string;
+ revisions: Array<{
+ id: string;
+ revision: number;
+ body: string;
+ changeSummary: string | null;
+ createdAt: string;
+ }>;
+}> {
+ if (state === null || typeof state !== "object" || Array.isArray(state))
+ return [];
+ const documents = state.documents;
+ if (!Array.isArray(documents)) return [];
+ return documents.flatMap((value) => {
+ if (value === null || typeof value !== "object" || Array.isArray(value))
+ return [];
+ const revisions = Array.isArray(value.revisions)
+ ? value.revisions.flatMap((candidate) => {
+ if (
+ candidate === null ||
+ typeof candidate !== "object" ||
+ Array.isArray(candidate)
+ )
+ return [];
+ return [
+ {
+ id: String(candidate.id ?? ""),
+ revision: Number(candidate.revision ?? 0),
+ body: String(candidate.body ?? ""),
+ changeSummary:
+ candidate.changeSummary === null
+ ? null
+ : String(candidate.changeSummary ?? ""),
+ createdAt: String(candidate.createdAt ?? ""),
+ },
+ ];
+ })
+ : [];
+ return [
+ {
+ id: String(value.id ?? ""),
+ key: String(value.key ?? ""),
+ title: String(value.title ?? "Untitled"),
+ revisions,
+ },
+ ];
+ });
+}
+
+export function DevtoolsInspector({
+ snapshot,
+ onFork,
+ tab,
+ onTabChange,
+ evalReport,
+}: {
+ snapshot: CapabilityDevtoolsSnapshot;
+ onFork: (revision: number) => void;
+ tab: CapabilityDevtoolsTab;
+ onTabChange: (tab: CapabilityDevtoolsTab) => void;
+ evalReport?: EvalInspectorReport | null;
+}) {
+ const [query, setQuery] = useState("");
+ const latest = snapshot.revisions.at(-1)!;
+ const [revision, setRevision] = useState(latest.revision);
+ const [following, setFollowing] = useState(true);
+ const [compareRevision, setCompareRevision] = useState(
+ snapshot.revisions[0]!.revision,
+ );
+ const selected =
+ snapshot.revisions.find((entry) => entry.revision === revision) ?? latest;
+ const compared =
+ snapshot.revisions.find((entry) => entry.revision === compareRevision) ??
+ snapshot.revisions[0]!;
+ const rows = useMemo(
+ () => diff(compared.state as Json, selected.state as Json),
+ [compared.state, selected.state],
+ );
+ const normalized = query.trim().toLowerCase();
+ const revisions = snapshot.revisions.filter(
+ (entry) =>
+ normalized.length === 0 ||
+ JSON.stringify(entry).toLowerCase().includes(normalized),
+ );
+ const protocol = snapshot.protocol.filter(
+ (entry) =>
+ normalized.length === 0 ||
+ JSON.stringify(entry).toLowerCase().includes(normalized),
+ );
+ const documents = useMemo(
+ () => documentsOf(selected.state as Json),
+ [selected.state],
+ );
+ const [documentId, setDocumentId] = useState("");
+ const document =
+ documents.find((entry) => entry.id === documentId) ?? documents[0] ?? null;
+ const [documentRevision, setDocumentRevision] = useState(0);
+ const visibleDocumentRevision =
+ document?.revisions.find((entry) => entry.revision === documentRevision) ??
+ document?.revisions.at(-1) ??
+ null;
+ useEffect(() => {
+ if (following) setRevision(latest.revision);
+ }, [following, latest.revision]);
+
+ return (
+
+
+
+ State monitor
+
+ setQuery(event.target.value)}
+ placeholder="operation, entity, turn…"
+ />
+
+
+
+
+
+ {[
+ ...(evalReport
+ ? [
+ {
+ id: "eval" as const,
+ icon: "evidence" as const,
+ label: "Eval",
+ },
+ ]
+ : []),
+ ...TABS,
+ ].map(({ id, icon, label }) => (
+
+ ))}
+
+ {tab === "eval" && evalReport ? (
+
+
+
← Eval suite
+
+ {evalReport.passed
+ ? "PASS"
+ : evalReport.disposition.replaceAll("_", " ").toUpperCase()}
+
+
{evalReport.attemptId}
+
+
+
+
- Model
+ -
+ {evalReport.run.model.startsWith(`${evalReport.run.provider}/`)
+ ? evalReport.run.model
+ : `${evalReport.run.provider}/${evalReport.run.model}`}
+
+
+
+
- Configuration
+ - {evalReport.run.configuration}
+
+
+
- Session
+ - {evalReport.run.sessionId}
+
+
+
- Provider session
+ - {evalReport.run.providerSessionId ?? "unavailable"}
+
+
+
- Driver
+ -
+ {evalReport.run.driver}
+ {evalReport.run.providerVersion
+ ? ` · ${evalReport.run.providerVersion}`
+ : ""}
+
+
+ {evalReport.run.agentVersion ? (
+
+
- Agent version
+ - {evalReport.run.agentVersion}
+
+ ) : null}
+
+
- Retained session
+ -
+ {evalReport.run.retainedSession === true
+ ? (evalReport.run.retainedSessionStatus ?? "retained")
+ : "not applicable"}
+
+
+
+
- Duration
+ - {evalReport.run.durationMs} ms
+
+
+
- Fixture
+ - {evalReport.run.fixtureDigest}
+
+
+
- State
+ -
+ r{evalReport.run.initialRevision} → r
+ {evalReport.run.finalRevision}
+
+
+
+
- Tokens
+ -
+ {evalReport.run.usage === null
+ ? "unknown"
+ : `${evalReport.run.usage.inputTokens} in · ${evalReport.run.usage.outputTokens} out · ${evalReport.run.usage.cachedInputTokens} cached`}
+
+
+
+
- Agent turns
+ - {evalReport.run.usage?.agentTurns ?? "unknown"}
+
+
+
- Provider requests
+ - {evalReport.run.usage?.providerRequests ?? "unavailable"}
+
+
+
- Estimated cost
+ -
+ {evalReport.run.usage === null
+ ? "unknown"
+ : `$${(evalReport.run.usage.estimatedCostNanodollars / 1_000_000_000).toFixed(6)} · ${evalReport.run.usage.pricingVersion}`}
+
+
+
+
- Provider list cost
+ -
+ {typeof evalReport.run.usage
+ ?.providerReportedCostNanodollars !== "number"
+ ? "unknown"
+ : `$${(evalReport.run.usage.providerReportedCostNanodollars / 1_000_000_000).toFixed(6)}`}
+
+
+
+
- Runner
+ - {evalReport.run.runnerPackageDigest}
+
+
+
- Runner build
+ - {evalReport.run.runnerBuild}
+
+
+
- runnerd
+ - {evalReport.run.runnerdDigest}
+
+
+
Assertions
+
+
+ ) : null}
+ {tab === "timeline" ? (
+
+ {revisions.map((entry) => (
+
+ ))}
+
+ ) : null}
+ {tab === "state" ? (
+
+
+
+
+
+ ) : null}
+ {tab === "diff" ? (
+
+
+
+
+
+ {rows.map((row) => (
+
+ {row.path}
+ {JSON.stringify(row.before)}
+ {JSON.stringify(row.after)}
+
+ ))}
+ {rows.length === 0 ? (
+
No changes between these revisions.
+ ) : null}
+
+ ) : null}
+ {tab === "documents" ? (
+ documents.length === 0 ? (
+
+
▤
+
No documents exist at r{selected.revision}.
+
+ ) : (
+
+
+ {documents.map((entry) => (
+
+ ))}
+
+ {document !== null && visibleDocumentRevision !== null ? (
+
+
+ {visibleDocumentRevision.changeSummary ? (
+
+ {visibleDocumentRevision.changeSummary}
+
+ ) : null}
+
+ {visibleDocumentRevision.body}
+
+
+ ) : null}
+
+ )
+ ) : null}
+ {tab === "protocol" ? (
+
+ {protocol.map((entry) => (
+
+
+ {entry.boundary} · {entry.event}
+
+
+
+ ))}
+
+ ) : null}
+ {tab === "trace" ? (
+ snapshot.providerTrace ? (
+
+
+ {snapshot.providerTrace.status} · expires{" "}
+ {snapshot.providerTrace.expiresAt}
+
+
Raw frame → Interpretation → PRP events → Presentation
+
+
+ ) : (
+
+
◇
+
+ Raw provider capture was off for this run. Canonical protocol
+ records remain available in Protocol.
+
+
+ )
+ ) : null}
+ {tab === "runtime" ? (
+
+
+
+ ) : null}
+ {tab === "authority" ? (
+
+
+
+ ) : null}
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.test.ts b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.test.ts
new file mode 100644
index 0000000000..67b0959f25
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from "vitest";
+
+import type { CapabilityEvidenceRunnerRecord } from "../../../src/issue-thread/types.js";
+import { groupCapabilityRunnerEvents } from "./EvidencePanel.js";
+
+function record(
+ ordinal: number,
+ event: string,
+ turnId = "turn-1",
+): CapabilityEvidenceRunnerRecord {
+ return {
+ id: `event-${ordinal}`,
+ turnId,
+ kind: "provider_event",
+ ordinal,
+ detail: `provider event · ${event}`,
+ details: [{ label: "Event", value: event }],
+ };
+}
+
+describe("Runner event grouping", () => {
+ it("aggregates each delta category per turn at its first position", () => {
+ const session: CapabilityEvidenceRunnerRecord = {
+ id: "event-1",
+ turnId: "turn-0",
+ kind: "session",
+ ordinal: 1,
+ detail: "session · started",
+ details: [{ label: "Action", value: "started" }],
+ };
+ const groups = groupCapabilityRunnerEvents([
+ session,
+ record(2, "assistant_delta"),
+ record(3, "reasoning_delta"),
+ record(4, "assistant_delta"),
+ record(5, "assistant_delta", "turn-2"),
+ ]);
+
+ expect(groups.map((group) => [group.event, group.records.map((entry) => entry.ordinal)])).toEqual([
+ [null, [1]],
+ ["assistant_delta", [2, 4]],
+ ["reasoning_delta", [3]],
+ ["assistant_delta", [5]],
+ ]);
+ });
+
+ it("leaves non-delta events individually expandable", () => {
+ const groups = groupCapabilityRunnerEvents([
+ record(1, "turn_started"),
+ record(2, "tool_result"),
+ ]);
+
+ expect(groups).toHaveLength(2);
+ expect(groups.every((group) => group.event === null && group.records.length === 1)).toBe(true);
+ });
+});
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx
new file mode 100644
index 0000000000..61aa2d7373
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx
@@ -0,0 +1,622 @@
+import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
+
+import type {
+ CapabilityEvidenceModel,
+ CapabilityEvidenceRunnerRecord,
+ CapabilityEvidenceSectionId,
+ CapabilityEvidenceToolRow,
+ CapabilityIssueThreadSnapshot,
+ CapabilityJsonValue,
+ CapabilityToolDisposition,
+} from "../../../src/issue-thread/types";
+import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools";
+import { DevtoolsInspector, type CapabilityDevtoolsTab, type EvalInspectorReport } from "./DevtoolsInspector";
+import { Icon } from "./Icons";
+import { capabilitySemanticToolDescriptor } from "../../../src/semantic-tools/catalog";
+import {
+ CAPABILITY_EVIDENCE_SECTIONS,
+ capabilityDispositionLabel,
+} from "../../../src/issue-thread/types";
+
+/**
+ * Evidence panel (contract §7): eight accordion sections in fixed order with a
+ * turn selector. Every record is rendered from the snapshot; the panel derives
+ * no verdicts of its own.
+ */
+
+const DISPOSITION_ORDER: CapabilityToolDisposition[] = [
+ "always_agent_tool",
+ "optional_agent_tool",
+ "control_plane_owned",
+];
+
+export interface CapabilityRunnerEventGroup {
+ id: string;
+ event: string | null;
+ records: CapabilityEvidenceRunnerRecord[];
+}
+
+/**
+ * Stream deltas are useful evidence but terrible default reading material.
+ * Aggregate each sanitized delta category within a turn at its first position;
+ * expanding the group still exposes every individual event in ordinal order.
+ */
+export function groupCapabilityRunnerEvents(
+ records: readonly CapabilityEvidenceRunnerRecord[],
+): CapabilityRunnerEventGroup[] {
+ const groups: CapabilityRunnerEventGroup[] = [];
+ const deltaGroups = new Map();
+ for (const record of records) {
+ const event = record.details.find((entry) => entry.label === "Event")?.value ?? null;
+ if (event === null || !event.endsWith("_delta")) {
+ groups.push({ id: record.id, event: null, records: [record] });
+ continue;
+ }
+ const key = `${record.turnId}:${event}`;
+ const existing = deltaGroups.get(key);
+ if (existing !== undefined) {
+ existing.records.push(record);
+ continue;
+ }
+ const group = { id: `delta:${key}`, event, records: [record] };
+ deltaGroups.set(key, group);
+ groups.push(group);
+ }
+ return groups;
+}
+
+function RunnerEvent({ record }: { record: CapabilityEvidenceRunnerRecord }) {
+ return (
+
+
+ #{record.ordinal} {record.kind}
+ {record.detail}
+ ›
+
+
+ {record.details.length === 0 ? (
+ <>
+ - Detail
+ - No additional sanitized fields were retained.
+ >
+ ) : (
+ record.details.map((entry, index) => (
+
+
- {entry.label}
+ - {entry.value}
+
+ ))
+ )}
+
+
+ );
+}
+
+function RunnerEventGroup({ group }: { group: CapabilityRunnerEventGroup }) {
+ if (group.event === null) return ;
+ return (
+
+
+ ›
+ {group.event}
+
+ {group.records.length} streamed update{group.records.length === 1 ? "" : "s"}
+
+
+
+ {group.records.map((record) => )}
+
+
+ );
+}
+
+export interface EvidencePanelProps {
+ snapshot: CapabilityIssueThreadSnapshot;
+ devtools?: CapabilityDevtoolsSnapshot | null;
+ evalReport?: EvalInspectorReport | null;
+ onForkRevision?: (revision: number) => void;
+ layout: "side" | "overlay" | "segment";
+ width: number;
+ selectedTurnId: string | "all";
+ openSections: CapabilityEvidenceSectionId[];
+ highlightedRecordId: string | null;
+ onSelectTurn: (turnId: string | "all") => void;
+ onToggleSection: (section: CapabilityEvidenceSectionId) => void;
+ onClose: () => void;
+ onJumpToThread: (anchorId: string) => void;
+ onInvokeTool?: (operationId: string, input: CapabilityJsonValue) => Promise;
+}
+
+function sectionCount(
+ evidence: CapabilityEvidenceModel,
+ section: CapabilityEvidenceSectionId,
+ turnId: string | "all",
+): string {
+ if (section === "tools") {
+ const records = filterByTurn(evidence.tools, turnId);
+ return String(new Set(records.flatMap((record) => record.rows.map((row) => row.operationId))).size);
+ }
+ if (section === "parity") {
+ const rows = filterByTurn(evidence.parity, turnId);
+ const passing = rows.filter((row) => row.verdict === "pass").length;
+ return `${passing}/${rows.length}`;
+ }
+ const rows = filterByTurn(
+ evidence[section] as ReadonlyArray<{ turnId: string }>,
+ turnId,
+ );
+ return String(rows.length);
+}
+
+function starterInput(schema: { properties?: Readonly>; required?: readonly string[] } | undefined): Record {
+ const result: Record = {};
+ for (const name of schema?.required ?? []) {
+ const property = schema?.properties?.[name];
+ if (property?.default !== undefined) result[name] = property.default;
+ else if (property?.enum?.[0] !== undefined) result[name] = property.enum[0];
+ else if (name === "idempotencyKey") result[name] = `devtools-${Date.now()}`;
+ else if (property?.type === "integer" || property?.type === "number") result[name] = 0;
+ else if (property?.type === "boolean") result[name] = false;
+ else if (property?.type === "array") result[name] = [];
+ else if (property?.type === "object") result[name] = {};
+ else result[name] = "";
+ }
+ return result;
+}
+
+function ToolsBrowser({ records, onInvoke }: { records: CapabilityEvidenceModel["tools"]; onInvoke?: (operationId: string, input: CapabilityJsonValue) => Promise }) {
+ const [collapsed, setCollapsed] = useState([]);
+ const [selected, setSelected] = useState(null);
+ const [inputText, setInputText] = useState("{}");
+ const [invocationResult, setInvocationResult] = useState(null);
+ const [invocationError, setInvocationError] = useState(null);
+ const [invoking, setInvoking] = useState(false);
+ const rows = useMemo(() => {
+ const unique = new Map();
+ for (const record of records) {
+ for (const row of record.rows) unique.set(row.operationId, row);
+ }
+ return [...unique.values()];
+ }, [records]);
+ const descriptor = selected === null ? undefined : capabilitySemanticToolDescriptor(selected.operationId);
+
+ useEffect(() => {
+ if (selected === null) return;
+ setInputText(JSON.stringify(starterInput(descriptor?.inputSchema), null, 2));
+ setInvocationResult(null);
+ setInvocationError(null);
+ }, [descriptor, selected]);
+
+ useEffect(() => {
+ if (selected === null) return;
+ const close = (event: KeyboardEvent) => { if (event.key === "Escape") setSelected(null); };
+ document.addEventListener("keydown", close);
+ return () => document.removeEventListener("keydown", close);
+ }, [selected]);
+
+ return (
+ <>
+
+ {DISPOSITION_ORDER.map((disposition) => {
+ const group = rows.filter((row) => row.disposition === disposition);
+ if (group.length === 0) return null;
+ const folded = collapsed.includes(disposition);
+ const title = disposition === "control_plane_owned"
+ ? "Control plane (not exposed to the agent)"
+ : capabilityDispositionLabel(disposition);
+ return (
+
+
+
+
+ {!folded ? (
+
+ {group.map((row) => (
+
+ ))}
+
+ ) : null}
+
+ );
+ })}
+
+
+ {selected !== null ? (
+ { if (event.target === event.currentTarget) setSelected(null); }}>
+
+
+
+
+
{selected.operationId}
+
{descriptor?.description ?? selected.description}
+
+ - Placement
- {capabilityDispositionLabel(selected.disposition)}
+ - Required claims
- {descriptor?.requiredClaims.join(", ") || selected.grant || "None"}
+ - Task modes
- {descriptor?.allowedModes.join(", ") || "—"}
+ - Actor roles
- {descriptor?.allowedRoles?.join(", ") || "Any allowed role"}
+
+
+
Input schema
+
{JSON.stringify(descriptor?.inputSchema ?? {}, null, 2)}
+
+
+ {onInvoke !== undefined ? (
+
+
Invoke in conversation
+
Runs through the real semantic dispatcher and records a turn, evidence, and company-state changes.
+
+
+ ) : null}
+
+
+
+ ) : null}
+ >
+ );
+}
+
+function filterByTurn(
+ rows: ReadonlyArray,
+ turnId: string | "all",
+): T[] {
+ return turnId === "all" ? [...rows] : rows.filter((row) => row.turnId === turnId);
+}
+
+function Section({
+ id,
+ title,
+ count,
+ open,
+ onToggle,
+ children,
+}: {
+ id: CapabilityEvidenceSectionId;
+ title: string;
+ count: string;
+ open: boolean;
+ onToggle: () => void;
+ children: ReactNode;
+}) {
+ return (
+
+
+
+
+
+ {children}
+
+
+ );
+}
+
+export function EvidencePanel(props: EvidencePanelProps) {
+ const [devtoolsTab, setDevtoolsTab] = useState(props.evalReport ? "eval" : "evidence");
+ const {
+ snapshot,
+ devtools,
+ evalReport,
+ onForkRevision = () => undefined,
+ layout,
+ width,
+ selectedTurnId,
+ openSections,
+ highlightedRecordId,
+ onSelectTurn,
+ onToggleSection,
+ onClose,
+ onJumpToThread,
+ onInvokeTool,
+ } = props;
+ const headingRef = useRef(null);
+ const evidence = snapshot.evidence;
+
+ useEffect(() => {
+ // Contract §9.2: opening Evidence moves focus to its heading.
+ headingRef.current?.focus();
+ }, []);
+
+ useEffect(() => {
+ // Contract §9.1: Escape closes the desktop overlay sheet. The side panel is
+ // a persistent region rather than a sheet, and the mobile segment is a tab
+ // view, so neither of those is dismissible this way.
+ if (layout !== "overlay") return;
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key !== "Escape") return;
+ // A modal dialog owns Escape while it is open (§6 reset confirm).
+ if (document.querySelector('[role="dialog"][aria-modal="true"]') !== null) return;
+ onClose();
+ }
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [layout, onClose]);
+
+ const isOpen = (section: CapabilityEvidenceSectionId) => openSections.includes(section);
+
+ return (
+
+ );
+}
+
+export const CAPABILITY_EVIDENCE_SECTION_IDS = CAPABILITY_EVIDENCE_SECTIONS.map(
+ (section) => section.id,
+);
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/Icons.tsx b/packages/paperclip-runner/devtools/issue-thread/src/Icons.tsx
new file mode 100644
index 0000000000..2ce971dc73
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/Icons.tsx
@@ -0,0 +1,32 @@
+export type IconName =
+ | "activity" | "authority" | "branch" | "close" | "diff" | "documents"
+ | "download" | "evidence" | "pause" | "play" | "protocol" | "reset"
+ | "spark" | "state" | "stop" | "terminal" | "timeline";
+
+const PATHS: Record = {
+ activity: "M3 12h3l2-6 4 12 3-9 2 5h4",
+ authority: "M12 3l7 4v5c0 4.6-2.8 7.6-7 9-4.2-1.4-7-4.4-7-9V7zM9 12l2 2 4-4",
+ branch: "M6 3v12a3 3 0 0 0 3 3h6M15 18l-3-3m3 3-3 3M6 7h6a3 3 0 0 0 3-3V3",
+ close: "M5 5l14 14M19 5 5 19",
+ diff: "M8 5v14M4 9h8M17 7v10M14 12h6",
+ documents: "M6 3h9l3 3v15H6zM15 3v4h4M9 11h6M9 15h6",
+ download: "M12 3v12m-5-5 5 5 5-5M5 21h14",
+ evidence: "M4 5h16v14H4zM8 9h8M8 13h5",
+ pause: "M8 5v14M16 5v14",
+ play: "M8 5v14l11-7z",
+ protocol: "M4 8h14m-3-3 3 3-3 3M20 16H6m3-3-3 3 3 3",
+ reset: "M5 8a8 8 0 1 1-1 7M5 8V3m0 5h5",
+ spark: "M12 2l1.7 5.3L19 9l-5.3 1.7L12 16l-1.7-5.3L5 9l5.3-1.7zM19 16l.8 2.2L22 19l-2.2.8L19 22l-.8-2.2L16 19l2.2-.8z",
+ state: "M9 4H6a2 2 0 0 0-2 2v3M15 4h3a2 2 0 0 1 2 2v3M9 20H6a2 2 0 0 1-2-2v-3M15 20h3a2 2 0 0 0 2-2v-3M9 9h6v6H9z",
+ stop: "M7 7h10v10H7z",
+ terminal: "M5 7l4 5-4 5m7 0h7",
+ timeline: "M5 5v14M5 8h5M5 13h9M5 18h13",
+};
+
+export function Icon({ name }: { name: IconName }) {
+ return (
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/InteractionCard.tsx b/packages/paperclip-runner/devtools/issue-thread/src/InteractionCard.tsx
new file mode 100644
index 0000000000..bd464a1f6c
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/InteractionCard.tsx
@@ -0,0 +1,528 @@
+import { useEffect, useId, useRef, useState } from "react";
+
+import type {
+ CapabilityThreadInteractionCard,
+ CapabilityThreadInteractionState,
+} from "../../../src/issue-thread/types";
+import { Chip } from "./primitives";
+
+/**
+ * Native interaction cards (contract §5). All five `request_human_input`
+ * kinds render typed controls; the card never asks the user to type an answer
+ * into the composer.
+ *
+ * The submit handler posts to the package server, which stores the response in
+ * the mock control plane before the runner is resumed. The card only leaves
+ * `submitting` when the server acknowledges — there is no optimistic
+ * resolution here.
+ */
+
+export interface CapabilityInteractionResponse {
+ interactionId: string;
+ outcome: "answered" | "accepted" | "rejected";
+ result: Record;
+}
+
+const STATE_GLYPH: Record = {
+ pending: "⏳",
+ submitting: "⏳",
+ accepted: "✓",
+ answered: "✓",
+ rejected: "✕",
+ stale_target: "⌛",
+ superseded_by_comment: "⌛",
+ expired: "⌛",
+ withdrawn: "⌛",
+ issue_closed: "⌛",
+};
+
+const RESOLVED_STATES = new Set([
+ "accepted",
+ "answered",
+ "rejected",
+ "stale_target",
+ "superseded_by_comment",
+ "expired",
+ "withdrawn",
+ "issue_closed",
+]);
+
+function stateTone(state: CapabilityThreadInteractionState) {
+ if (state === "accepted" || state === "answered") return "success" as const;
+ if (state === "rejected") return "danger" as const;
+ if (state === "pending" || state === "submitting") return "accent" as const;
+ return undefined;
+}
+
+export function InteractionCard({
+ card,
+ onRespond,
+ onOpenEvidence,
+ autoFocus,
+}: {
+ card: CapabilityThreadInteractionCard;
+ onRespond?: (response: CapabilityInteractionResponse) => void;
+ onOpenEvidence?: (section: string, recordId: string) => void;
+ autoFocus?: boolean;
+}) {
+ const titleId = useId();
+ const [answers, setAnswers] = useState>({});
+ const [selected, setSelected] = useState(() =>
+ card.payload.kind === "checkbox"
+ ? card.payload.options.filter((option) => option.defaultSelected).map((option) => option.id)
+ : card.payload.kind === "suggest_tasks"
+ ? card.payload.tasks.map((task) => task.id)
+ : [],
+ );
+ const [verdicts, setVerdicts] = useState>({});
+ const [reasons, setReasons] = useState>({});
+ const [rejectReason, setRejectReason] = useState("");
+ const [error, setError] = useState(null);
+ const firstControl = useRef(null);
+ const stateChip = useRef(null);
+ const previousState = useRef(card.state);
+ const resolved = RESOLVED_STATES.has(card.state);
+ const busy = card.state === "submitting";
+
+ useEffect(() => {
+ // Contract §9.2: resolving a card moves focus to its state chip. The
+ // controls the user just used unmount on resolve, so without this the
+ // keyboard caret would fall back to `body` exactly when the card changes.
+ const from = previousState.current;
+ previousState.current = card.state;
+ if (from === card.state) return;
+ if (!RESOLVED_STATES.has(from) && RESOLVED_STATES.has(card.state)) {
+ stateChip.current?.focus();
+ }
+ }, [card.state]);
+
+ function respond(response: CapabilityInteractionResponse) {
+ setError(null);
+ onRespond?.(response);
+ }
+
+ function toggle(list: string[], id: string): string[] {
+ return list.includes(id) ? list.filter((entry) => entry !== id) : [...list, id];
+ }
+
+ return (
+
+
+
+ {card.title}
+
+
+ {STATE_GLYPH[card.state]}
+ {card.stateLabel}
+
+
+
+ {card.prompt}
+
+ {card.target !== null ? (
+
+ Target:{" "}
+
+ {card.target.label}
+
+
+ ) : null}
+
+ {card.payload.kind === "confirmation" ? (
+ {card.payload.targetSummary}
+ ) : null}
+
+ {resolved ? (
+
+ {card.resolvedSummary.length > 0 ? (
+
+ {card.resolvedSummary.map((entry) => (
+ - {entry}
+ ))}
+
+ ) : null}
+ {card.reason !== null ?
{card.reason}
: null}
+ {card.supersededBy !== null ? (
+
+ {card.supersededBy.label}
+
+ ) : null}
+
+ ) : (
+
+ {card.payload.kind === "questions"
+ ? card.payload.questions.map((question, index) => (
+
+
+ {question.prompt}
+
+ {question.control === "text" ? (
+
+ setAnswers((current) => ({ ...current, [question.id]: event.target.value }))
+ }
+ />
+ ) : question.control === "select" ? (
+
+ ) : (
+
+ {(question.options ?? []).map((option, optionIndex) => (
+
+ ))}
+
+ )}
+
+ ))
+ : null}
+
+ {card.payload.kind === "checkbox"
+ ? card.payload.options.map((option, index) => (
+
+ ))
+ : null}
+
+ {card.payload.kind === "suggest_tasks"
+ ? card.payload.tasks.map((task) => (
+
+ ))
+ : null}
+
+ {card.payload.kind === "item_verdicts"
+ ? card.payload.items.map((item) => (
+
+
+ {item.title}
+
+
+ {(["approve", "reject", "defer"] as const).map((verdict) => (
+
+ ))}
+
+ {item.requireReason === true ? (
+
+ ))
+ : null}
+
+ {card.payload.kind === "confirmation" && card.payload.requireRejectReason ? (
+
+
+
+ ) : null}
+
+ {error !== null ? (
+
+ {error}
+
+ ) : null}
+
+
+ {card.payload.kind === "confirmation" ? (
+ <>
+
+
+ >
+ ) : null}
+
+ {card.payload.kind === "questions" ? (
+
+ ) : null}
+
+ {card.payload.kind === "checkbox" ? (
+ <>
+
+
+ >
+ ) : null}
+
+ {card.payload.kind === "suggest_tasks" ? (
+
+ ) : null}
+
+ {card.payload.kind === "item_verdicts" ? (
+
+ ) : null}
+
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx b/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx
new file mode 100644
index 0000000000..6013871557
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx
@@ -0,0 +1,213 @@
+import { useEffect, useRef, useState } from "react";
+
+import type { CapabilityIssueThreadSnapshot } from "../../../src/issue-thread/types";
+import { Icon } from "./Icons";
+import { Chip, PriorityIcon, StatusBadge } from "./primitives";
+
+/**
+ * Sticky issue header (contract §1 and §2). The three identity chips are never
+ * hidden by scroll, mode is data rather than styling, and on mobile Stop stays
+ * outside the overflow menu whenever a turn is active.
+ */
+
+export interface IssueHeaderProps {
+ snapshot: CapabilityIssueThreadSnapshot;
+ scenarios: string[];
+ /** `chat` is the clean room: no preset scenario, no recording to replay. */
+ surface?: "issue" | "chat";
+ /** Immutable eval recordings use the same inspector without live controls. */
+ readOnly?: boolean;
+ /** Clean-room tenant token, rendered so identity rotation is visible. */
+ cleanRoomToken?: string | null;
+ evidenceOpen: boolean;
+ denialCount: number;
+ segment: "thread" | "evidence";
+ onToggleEvidence: () => void;
+ onSelectScenario: (scenario: string) => void;
+ onReplay: () => void;
+ onReset: () => void;
+ onStop: () => void;
+ onSelectSegment: (segment: "thread" | "evidence") => void;
+}
+
+export function IssueHeader(props: IssueHeaderProps) {
+ const {
+ snapshot,
+ scenarios,
+ surface = "issue",
+ readOnly = false,
+ cleanRoomToken = null,
+ evidenceOpen,
+ denialCount,
+ segment,
+ onToggleEvidence,
+ onSelectScenario,
+ onReplay,
+ onReset,
+ onStop,
+ onSelectSegment,
+ } = props;
+ const chat = surface === "chat";
+ const [menuOpen, setMenuOpen] = useState(false);
+ const menuRef = useRef(null);
+ const turnActive = snapshot.composer.state === "streaming" || snapshot.composer.state === "sending";
+
+ useEffect(() => {
+ if (!menuOpen) return;
+ function onKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") setMenuOpen(false);
+ }
+ document.addEventListener("keydown", onKeyDown);
+ return () => document.removeEventListener("keydown", onKeyDown);
+ }, [menuOpen]);
+
+ return (
+
+
+
+ {snapshot.issue.identifier}
+
{snapshot.issue.title}
+
+
+
+ {!chat && !readOnly ? (
+ <>
+
+
+
+ >
+ ) : null}
+ {!readOnly ? : null}
+ {!readOnly ? : null}
+
+
+
+
+
+
+
+ {snapshot.identity.agentLabel}
+ {snapshot.identity.replaySource !== null
+ ? ` · ${snapshot.identity.replaySource} source`
+ : ""}
+
+
+
+ {snapshot.identity.runnerLabel}
+
+ {snapshot.identity.runnerAttached ? "attached" : "detached"}
+
+
+
+ {snapshot.identity.controlPlaneLabel}
+
+ {chat && cleanRoomToken !== null ? (
+
+ Clean room {cleanRoomToken}
+
+ ) : null}
+
+
+ {snapshot.issue.assignee !== null ?
{snapshot.issue.assignee} : null}
+
{snapshot.issue.runState}
+
+
+
+ {menuOpen ? (
+
+ {chat ? null : (
+ <>
+
+
+ >
+ )}
+
+
+ ) : null}
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/SurfaceNav.tsx b/packages/paperclip-runner/devtools/issue-thread/src/SurfaceNav.tsx
new file mode 100644
index 0000000000..9c0cf47c67
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/SurfaceNav.tsx
@@ -0,0 +1,57 @@
+import type { CapabilitySurface } from "./route";
+import { Icon } from "./Icons";
+
+/**
+ * Top-level surface switch (Capability plan revision 5).
+ *
+ * Capability now has two primary paths, not one path with a mode toggle: the
+ * preset scenario explorer and a clean-room chat that starts from nothing. They
+ * are different jobs, so they get links rather than tabs — the clean-room entry
+ * has to be reachable from the landing page without first choosing a scenario.
+ *
+ * These are real anchors on purpose. The entry must survive a hard refresh, be
+ * copyable, and open in a new tab, which a button handler would not give.
+ */
+export interface CapabilityChatHistoryItem {
+ sessionId: string;
+ identifier: string;
+ title: string;
+ updatedAt: string;
+ current: boolean;
+}
+
+export function SurfaceNav({ surface, history = [], activeSessionId = null, onNewChat, onSelectHistory }: {
+ surface: CapabilitySurface;
+ history?: CapabilityChatHistoryItem[];
+ activeSessionId?: string | null;
+ onNewChat?: () => void;
+ onSelectHistory?: (sessionId: string) => void;
+}) {
+ return (
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx b/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx
new file mode 100644
index 0000000000..a5efd9b300
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx
@@ -0,0 +1,616 @@
+import { useState } from "react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+
+import type {
+ CapabilityEvidenceSectionId,
+ CapabilityThreadItem,
+ CapabilityThreadTurn,
+} from "../../../src/issue-thread/types";
+import { InteractionCard, type CapabilityInteractionResponse } from "./InteractionCard";
+import { Chip, StatusBadge, Timestamp, formatBytes } from "./primitives";
+
+export interface EvalAssertion {
+ id: string;
+ title: string;
+ description: string;
+ passed: boolean;
+ detail: string;
+ definition: Record;
+}
+
+export function EvalAssertions({ assertions }: { assertions: EvalAssertion[] }) {
+ const [selected, setSelected] = useState(null);
+ if (assertions.length === 0) return null;
+ return (
+ <>
+
+ {assertions.map((assertion) => (
+
+ ))}
+
+ {selected !== null ? (
+ { if (event.target === event.currentTarget) setSelected(null); }}>
+
+
+
{selected.description}
+
Observed result
{selected.detail}
+
Original case definition
{JSON.stringify(selected.definition, null, 2)}
+
+
+ ) : null}
+ >
+ );
+}
+
+/** Progressive disclosure budget from contract §3 (T4). */
+const VISIBLE_STRIPS = 3;
+
+const STATUS_GLYPH = { ok: "✓", denied: "✕", running: "⏳" } as const;
+
+function MarkdownBody({ children }: { children: string }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export interface ThreadCallbacks {
+ onOpenEvidence: (section: CapabilityEvidenceSectionId, recordId: string) => void;
+ onRespond: (response: CapabilityInteractionResponse) => void;
+ focusInteractionId: string | null;
+}
+
+function ToolStrip({
+ item,
+ callbacks,
+}: {
+ item: Extract;
+ callbacks: ThreadCallbacks;
+}) {
+ return (
+
+
+
+ {STATUS_GLYPH[item.status]}
+
+ {item.status}
+ {item.operationId}
+ {item.summary}
+ ›
+
+
+
Sanitized tool input and result
+
+
{JSON.stringify(item.input, null, 2)}
+
{JSON.stringify(item.result, null, 2)}
+
+
+
+
+ );
+}
+
+function ProgressItem({
+ item,
+}: {
+ item: Extract;
+}) {
+ return (
+
+
+
+ {item.status === "running" ? "⏳" : "✓"}
+
+ {item.label}
+ {item.summary}
+ ›
+
+
+ {Object.entries(item.details).map(([name, value]) =>
+ name === "withheld" ? null : (
+
+
{name}
+
{typeof value === "string" ? value : JSON.stringify(value, null, 2)}
+
+ ),
+ )}
+ {Array.isArray(item.details.withheld) ? (
+
+ Not available here: {item.details.withheld.join(", ")}.
+
+ ) : null}
+
+
+ );
+}
+
+function valueRecord(value: unknown): Record {
+ return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {};
+}
+
+function ProviderActivity({ item, callbacks }: {
+ item: Extract;
+ callbacks: ThreadCallbacks;
+}) {
+ const payload = valueRecord(item.payload);
+ const steps = Array.isArray(payload.steps) ? payload.steps.map(valueRecord) : [];
+ const children = Array.isArray(payload.children) ? payload.children.map(valueRecord) : [];
+ const sources = Array.isArray(payload.sources) ? payload.sources.map(valueRecord) : [];
+ const output = typeof payload.output === "string" ? payload.output : "";
+ const glyph = item.status === "running" ? "⏳" : item.status === "failed" ? "✕" : item.status === "interrupted" ? "■" : "✓";
+ return (
+
+ {glyph}{item.title}{item.summary}›
+
+ {steps.length > 0 ?
{steps.map((step, index) => - {step.status === "completed" ? "✓" : step.status === "blocked" ? "!" : step.status === "in_progress" ? "◐" : "○"}{String(step.body ?? "")}
)}
: null}
+ {children.length > 0 ?
{children.map((child, index) => - {String(child.role ?? "Child agent")}{String(child.status ?? "unknown")}{String(child.summary ?? "")}
)}
: null}
+ {sources.length > 0 ?
{sources.map((source, index) => { const url = typeof source.url === "string" && /^https?:\/\//.test(source.url) ? source.url : null; return - {url === null ? {String(source.title ?? "Unavailable source")} : {String(source.title ?? url)}}Provider-reported
; })}
: null}
+ {output.length > 0 ?
{output} : null}
+ {item.family === "model_identity" ?
- Requested
- {String(payload.requestedModel ?? "—")}
- Effective
- {String(payload.effectiveModel ?? "—")}
: null}
+ {item.family === "artifact" && typeof payload.reference === "string" ?
: null}
+
+
+
+ );
+}
+
+function FileDiff({ diff }: { diff: string | null }) {
+ if (diff === null) return Binary or oversized file; textual diff unavailable.
;
+ return (
+
+ {diff.split("\n").map((line, index) => (
+ {line || " "}{"\n"}
+ ))}
+
+ );
+}
+
+function WorkspaceChanges({
+ item,
+}: {
+ item: Extract;
+}) {
+ const [expanded, setExpanded] = useState(false);
+ const [selectedPath, setSelectedPath] = useState(null);
+ const files = item.changeSet.files;
+ const visible = expanded ? files : files.slice(0, 3);
+ const selected = files.find((file) => file.path === selectedPath) ?? null;
+ const stats = (file: (typeof files)[number]) => (
+
+ {file.additions === null ? null : +{file.additions}}
+ {file.deletions === null ? null : -{file.deletions}}
+
+ );
+
+ return (
+ <>
+
+
+ ▣
+
+ {item.changeSet.complete ? "Edited" : "Editing"} {files.length} file{files.length === 1 ? "" : "s"}
+ {item.changeSet.source === "runner_verified" ? "Verified from workspace" : "Reported by harness"}
+
+
+ {item.changeSet.totals.additions === null ? null : +{item.changeSet.totals.additions}}
+ {item.changeSet.totals.deletions === null ? null : -{item.changeSet.totals.deletions}}
+
+
+
+
+ {visible.map((file) => (
+
+ ))}
+
+ {files.length > 3 ? (
+
+ ) : null}
+
+ {selected !== null ? (
+ { if (event.target === event.currentTarget) setSelectedPath(null); }}>
+
+
+ {files.length > 1 ? (
+
+ ) : null}
+
+
+
+ ) : null}
+ >
+ );
+}
+
+function WorkspaceFileReference({
+ item,
+}: {
+ item: Extract;
+}) {
+ const [open, setOpen] = useState(false);
+ const extension = item.reference.path.includes(".")
+ ? item.reference.path.split(".").at(-1)?.toUpperCase()
+ : "FILE";
+ const presentation = item.reference.presentation === "document"
+ ? "Document"
+ : item.reference.presentation === "code"
+ ? "Code"
+ : item.reference.presentation === "image"
+ ? "Image"
+ : "File";
+ return (
+ <>
+
+ ▤
+
+ {item.reference.displayName}
+ {presentation} · {extension}{item.reference.line === null ? "" : ` · line ${item.reference.line}`}
+
+
+
+ {open ? (
+ { if (event.target === event.currentTarget) setOpen(false); }}>
+
+
+
+ {item.reference.preview === null
+ ?
Preview unavailable. The reference remains recorded with its normalized workspace path.
+ : item.reference.presentation === "document"
+ ?
{item.reference.preview}
+ :
{item.reference.preview}}
+ {item.reference.previewTruncated ?
Preview truncated by the runner.
: null}
+
+
+
+ ) : null}
+ >
+ );
+}
+
+function ThreadItemView({
+ item,
+ callbacks,
+}: {
+ item: CapabilityThreadItem;
+ callbacks: ThreadCallbacks;
+}) {
+ switch (item.kind) {
+ case "user_message":
+ return (
+
+
+ {item.author}
+
+
+ {item.body}
+
+ );
+
+ case "agent_message":
+ return (
+
+
+ {item.author}
+
+ {item.streaming ? (
+
+ ⏳
+ Streaming
+
+ ) : null}
+
+ {item.body}
+
+ );
+
+ case "durable_comment":
+ return (
+
+
+ {item.author}
+
+
+ ◆
+ Recorded to mock thread
+
+
+ {item.body}
+
+
+ );
+
+ case "tool_activity":
+ return ;
+
+ case "progress_activity":
+ return ;
+
+ case "workspace_changes":
+ return ;
+
+ case "workspace_file_reference":
+ return ;
+
+ case "provider_activity":
+ return ;
+
+ case "denial":
+ return (
+
+
+
+ ✕
+
+ {item.operationId}
+
+ {item.reason}
+
+
+
+
+
+
+ );
+
+ case "interaction":
+ return (
+
+ callbacks.onOpenEvidence(section as CapabilityEvidenceSectionId, recordId)
+ }
+ />
+ );
+
+ case "document":
+ return (
+
+
+ {item.title}
+ {item.documentKey}
+
+
+
+
+ {item.revisionFrom === null ? `r${item.revisionTo}` : `r${item.revisionFrom} → r${item.revisionTo}`}
+
+ {" · "}
+ {item.author}
+ {item.staleBehind !== null ? (
+ <>
+ {" "}
+
+ ⌛
+ Stale — {item.staleBehind} newer revision(s)
+
+ >
+ ) : null}
+
+
+
+ );
+
+ case "deliverable":
+ return (
+
+
+ {item.filename}
+
+
+
+
+ {item.deliverableKind} · {formatBytes(item.byteSize)} · registered by {item.registeredBy}
+
+
+
+
+ );
+
+ case "dependency":
+ return (
+
+
+ Delegation
+
+
+
+ {item.createdTasks.map((task) => (
+ -
+ {task.identifier} {task.title}
+
+ ))}
+ {item.blockerEdges.map((edge) => (
+ - {edge}
+ ))}
+
+
+ );
+
+ case "disposition":
+ return (
+
+
+
+ {item.operationId}
+
+
+ {item.body}
+ {item.blockerOwner !== null ? (
+ Blocker owner: {item.blockerOwner}
+ ) : null}
+
+ );
+
+ case "system_notice":
+ return (
+
+ {item.glyph}
+ {item.text}
+
+
+ );
+ }
+}
+
+export function TurnGroup({
+ turn,
+ callbacks,
+ assertions = {},
+ terminalAssertions = [],
+}: {
+ turn: CapabilityThreadTurn;
+ callbacks: ThreadCallbacks;
+ assertions?: Record;
+ terminalAssertions?: EvalAssertion[];
+}) {
+ const [showAllStrips, setShowAllStrips] = useState(false);
+ const stripIndexes = turn.items
+ .map((item, index) => (item.kind === "tool_activity" ? index : -1))
+ .filter((index) => index >= 0);
+ const hiddenStripIndexes = new Set(
+ showAllStrips ? [] : stripIndexes.slice(VISIBLE_STRIPS),
+ );
+
+ return (
+
+
+
+ Turn {turn.ordinal} · {turn.mode} · {turn.toolCallCount} tool call
+ {turn.toolCallCount === 1 ? "" : "s"} ·{" "}
+ {new Date(turn.at).toISOString().slice(11, 19)}
+
+ {turn.stoppedByUser ? (
+
+ Stopped by user
+
+ ) : null}
+
+ {turn.items.map((item, index) => hiddenStripIndexes.has(index) ? null : (
+
+
+
+
+ ))}
+ {hiddenStripIndexes.size > 0 ? (
+
+ ) : null}
+
+
+ );
+}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/fake-store.ts b/packages/paperclip-runner/devtools/issue-thread/src/fake-store.ts
new file mode 100644
index 0000000000..b4dd0817c3
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/fake-store.ts
@@ -0,0 +1,82 @@
+import type {
+ CapabilityIssueThreadSnapshot,
+ CapabilityThreadInteractionState,
+ CapabilityThreadItem,
+} from "../../../src/issue-thread/types";
+import type { CapabilityInteractionResponse } from "./InteractionCard";
+
+/**
+ * `fake`-mode interaction store.
+ *
+ * The screenshot matrix runs without a session server, so this module plays
+ * the mock control plane's role for the deterministic fixtures only: it stores
+ * the response and then hands back a new snapshot, mirroring the live authority
+ * path (store first, resume second) rather than resolving the card optimistically
+ * in the card component. Live mode never reaches this code — it posts to the
+ * package server instead.
+ */
+
+function resolvedState(
+ outcome: CapabilityInteractionResponse["outcome"],
+): { state: CapabilityThreadInteractionState; label: string } {
+ if (outcome === "accepted") return { state: "accepted", label: "Accepted" };
+ if (outcome === "rejected") return { state: "rejected", label: "Changes requested" };
+ return { state: "answered", label: "Answered" };
+}
+
+function summarise(response: CapabilityInteractionResponse): string[] {
+ const result = response.result;
+ if (Array.isArray(result.selected)) return result.selected.map((entry) => String(entry));
+ if (Array.isArray(result.acceptedTaskIds)) {
+ return result.acceptedTaskIds.map((entry) => `Accepted ${String(entry)}`);
+ }
+ if (Array.isArray(result.verdicts)) {
+ return result.verdicts.map((entry) => {
+ const verdict = entry as { id?: unknown; verdict?: unknown };
+ return `${String(verdict.id)} — ${String(verdict.verdict)}`;
+ });
+ }
+ if (typeof result.answers === "object" && result.answers !== null) {
+ return Object.entries(result.answers as Record).map(
+ ([key, value]) => `${key} — ${String(value)}`,
+ );
+ }
+ if (result.accepted === true) return ["Accepted"];
+ return [];
+}
+
+function applyToItem(
+ item: CapabilityThreadItem,
+ response: CapabilityInteractionResponse,
+): CapabilityThreadItem {
+ if (item.kind !== "interaction" || item.interactionId !== response.interactionId) return item;
+ const view = resolvedState(response.outcome);
+ return {
+ ...item,
+ state: view.state,
+ stateLabel: view.label,
+ resolvedSummary: summarise(response),
+ reason: typeof response.result.reason === "string" ? response.result.reason : null,
+ };
+}
+
+/** Store the typed response, then return the snapshot the card renders from. */
+export function applyFakeInteractionResponse(
+ snapshot: CapabilityIssueThreadSnapshot,
+ response: CapabilityInteractionResponse,
+): CapabilityIssueThreadSnapshot {
+ const turns = snapshot.turns.map((turn) => ({
+ ...turn,
+ items: turn.items.map((item) => applyToItem(item, response)),
+ }));
+ const stillPending = turns.some(
+ (turn) => turn.items.some((item) => item.kind === "interaction" && item.state === "pending"),
+ );
+ return {
+ ...snapshot,
+ turns,
+ composer: stillPending
+ ? snapshot.composer
+ : { state: "ready", helper: null, reason: null, pendingInteractionId: null },
+ };
+}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/fonts/NOTICE.md b/packages/paperclip-runner/devtools/issue-thread/src/fonts/NOTICE.md
new file mode 100644
index 0000000000..1fa3ee3b75
--- /dev/null
+++ b/packages/paperclip-runner/devtools/issue-thread/src/fonts/NOTICE.md
@@ -0,0 +1,78 @@
+# Capability issue-thread fonts
+
+The Capability issue-thread bundle includes Latin-subset WOFF2 files so captures
+do not depend on fonts installed on the host.
+
+## Inter
+
+- Upstream: https://github.com/rsms/inter
+- Version: 4.1
+- Source: `ui/public/fonts/InterVariable.woff2`
+- License: SIL Open Font License 1.1
+- License text: https://github.com/rsms/inter/blob/v4.1/LICENSE.txt
+
+The bundled file is subset from the repository's unmodified upstream Inter 4.1
+variable WOFF2. CSS exposes weights 400 through 700.
+
+## DejaVu Sans Mono
+
+- Upstream: https://dejavu-fonts.github.io/
+- Version: 2.37
+- Source faces: DejaVu Sans Mono Book and Bold; DejaVu Sans Book for status glyphs
+- License: Bitstream Vera Fonts license; DejaVu changes are public domain
+- License text: https://dejavu-fonts.github.io/License.html
+
+The bundled files are Latin subsets of the Book and Bold faces. The source font
+copyright and permission notice follow.
+
+> Copyright (c) 2003 by Bitstream, Inc. All Rights Reserved. Bitstream Vera is a
+> trademark of Bitstream, Inc.
+>
+> Permission is hereby granted, free of charge, to any person obtaining a copy
+> of the fonts accompanying this license ("Fonts") and associated documentation
+> files (the "Font Software"), to reproduce and distribute the Font Software,
+> including without limitation the rights to use, copy, merge, publish,
+> distribute, and/or sell copies of the Font Software, and to permit persons to
+> whom the Font Software is furnished to do so, subject to the following
+> conditions:
+>
+> The above copyright and trademark notices and this permission notice shall be
+> included in all copies of one or more of the Font Software typefaces.
+>
+> The Font Software may be modified, altered, or added to, and in particular the
+> designs of glyphs or characters in the Fonts may be modified and additional
+> glyphs or characters may be added to the Fonts, only if the fonts are renamed
+> to names not containing either the words "Bitstream" or the word "Vera".
+>
+> This License becomes null and void to the extent applicable to Fonts or Font
+> Software that has been modified and is distributed under the "Bitstream Vera"
+> names.
+>
+> The Font Software may be sold as part of a larger software package but no copy
+> of one or more of the Font Software typefaces may be sold by itself.
+>
+> THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+> OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY,
+> FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT,
+> TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL BITSTREAM OR THE GNOME FOUNDATION
+> BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL,
+> SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION
+> OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO
+> USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.
+>
+> Except as contained in this notice, the names of Gnome, the Gnome Foundation,
+> and Bitstream Inc., shall not be used in advertising or otherwise to promote
+> the sale, use or other dealings in the Font Software without prior written
+> authorization from the Gnome Foundation or Bitstream Inc., respectively.
+
+## Noto Sans Symbols 2
+
+- Upstream: https://github.com/notofonts/symbols
+- Version: 2.003
+- Source face: Noto Sans Symbols 2 Regular
+- License: SIL Open Font License 1.1
+- License text: https://github.com/notofonts/symbols/blob/main/OFL.txt
+
+The 1.2 kB bundled subset contains only the two hourglass glyphs that are not
+present in Inter or DejaVu Sans. It is exposed under the same package-specific
+symbol fallback family using a disjoint `unicode-range`.
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/fonts/dejavu-sans-mono-latin-400.woff2 b/packages/paperclip-runner/devtools/issue-thread/src/fonts/dejavu-sans-mono-latin-400.woff2
new file mode 100644
index 0000000000000000000000000000000000000000..7700aef0cc9bef9d601d923a2f69127d4e9864bc
GIT binary patch
literal 46872
zcmV(=K-s@{Pew8T0RR910Jj(b5dZ)H0k1d!0JgON0$#WP00000000000000000000
z0000QG#elsf+hx50E;{bgfIz#ED;O}g@_=8pfdqB0we>GTnmv{00bZf!4wC}BMgok
zTTXG2N72Q0EYTcNh+K#=JtZi@B`Bw+&6K0M$gbj!P9CX`5gjy)KACgI
zvisUon)4D5EA>a~1Q{FrY&b57-0ZGi*;LK+jcvM_P0)=7W2F`MFlU9Oe%n$d^K#_%
z94X2YOCkx9*|sdR6obYQ253BXM+#|i%S&%h}o-)Nd(I(Ec
zs$ByQ5m`pJlZ&py+BjO2X3SY;|KwNXHxINAYN%UK5ZU2y_8+)$Q-thQsl7Q)Sl#Rt
z@VT3kA@kkKR1Nws{8Gf<=vEO#jZbbkZ4f<`IkSR`xg4*X5s@qvug(5-I-Ul!uj!uk
z7?CR<>{B)WzUN9}_5Tl)D88qMXh5{v;JyM?F%4x1O7)p>7LTv7l*4C6Q_pv@j`Lha
zu6}HhJ@aQAC!s~E^ErI0o$S?=RF@VXtEbD3?)DVj6c#re1D+~~$drxL808(B1R0~d
ztlj0}fhBku7#kz&9kEV4@kLc7{>i_O|61L<`^@v)`(Z=`GCQSWBNiiN=JYur-DeYMO^fNIr@y2HSHq6?hSgVrR$f5RXM4F=ahzD*!m$Pp5UBoneu9iE?CkHeu@^H@D
zUT;822EZ9Zwr68!>;nn=N)cL3Xa9feE6n#f8Snf$J}stqAK0dtl#abRW_)Amw6a4k^=HoB~S
zqt-NfZ-2A7Xv+n{2jQYSRi;s$I!0Soc&01;#Jm0^z6+yoI!mjU(^B0LG&OK%y(YZj
zxeK|R@cf2+!TBAw?d**{FQAr+gb&5rDb#VtF^R;NC?OXKo#zU7xqp(?ICjFp907IwTfZ*U!D1dhS&-F
z$Oph9&=i;x3s-K$gLy}WiP#Bhh7ZC&*+Zh##0-F{BiajYeI4|T0
z7qI_pnzWpMP~!$h_e~fAih=KvJE7+Mglz4OjzL{D@kC;-`1|Kq*XPi@MkeCCJ<*)s
zA+{gbHY2BVx+h=<6=kdnf&brEwc7Uq(gR2y4=FiNnncbFcd3o?#@1;=N=3Q({rmpI
zfB(JziH{FJL?9&)N(3m`AQXlatr-B6Z2;DINqPb$hZQ|Z)}*XWAD{vW$ukt_Kv7Pj
zJV~A;c~j7MQw&8lA
z{zsaz#>`ACr2^PcU=R$tI;VZ<+`lM4N=2nqed?n$vVe=N_>
zgIHq50+7_VQ>}GtyjZ>xK&k+Kvv+5Gb^uES5E1}D!gIJRrMpwExUWlNW^o7_v3tnQ
zDyPp$y3e(=Qng02HrWR(FaRbjJSMHBt!!n5bNiFbX7BD1sz4Ixq|7|juVs2``?<7?
z^RaRVI{d?vusR?Vl0dk#@Jik5kHwq?i|aTzSIS*qTud#Py5%gj!(xjCF9?;i(0dZ}
zH!rTa&o5T3SgTe&D;x-7q%mG3RBgpdcYwiTg!9_U_C0tXw|}6r32_-OrfXAE{<^{(rf#($qu~aYP&l
z$a;V_zVw@7Q)L1bObV^D&g64>m&;@k0dT%#dJVXMABr0`Lsr1c3WJ1>$#ELdwjDZxK1P(fR4hfb@77{Uco-o$G7*O(5q#j&M{3yv-G7
zz!TatmY*^iN2Xh7o7Wb#0vHP0unoUdiVbMeVbG9CGgc6Z#^mw85pu{d#|nEqN1uFW
zHno}46Ui8&%Yb6Sj5RwxvXvnyR;#W4HP_u(l$J9Z+7=%N71+Ie-}pnvR^x2Z1tICHnL0$JfU>?2rT2
zDdp}=7*xphIJTur{^q8iqr6EkR(jZl1v49HCMQMy=`g(9M;;w3a*lDtzTI+Y*M$~+
z@a#YvZuCOJO0T42sYUuGol1Su59y~gfd05Gm^6qaU6!dEZ6kA+W#LAjk!`@TYxfmk
zAzqgy>@EaF+KMkFD(oP+2!s|URh$fs@-=DFY3!
z!xm$E-a^h1NG0l$4nCssMir;49#&d|)|->rO%nqAUSaEizSYbZ-7qx|QgS0*8KxQV
z$6qx2(%@qsNAWy33wT(Cn!%_=b5m(c|4m~bwQby2>ijKfeV#%d2yJ|4P;mzzX5X~M
zo28FK>)Hf|X3JT?iw&khN1Ib_MJzVz9`J2j6~ZHmc8k@-dD-p2Wu?A3&?e~KOHa8~
zp;(^u#|>dPbhMLA*(lv^u^=GCjAm^C!Uxt5gK4pBMl}PTpm#9ifEGAp*Cx}kfGdQ-
zNo-+xpxM1OkkKSGwFlvY$O;L^L*UcCV5o;$qrRf~TZ6mDr@*c5WLDMKs4BZ>vPiFd2Xq3i
zr2%+uPiD}-Gd`wN9^w>2kJ!a
za(u)}%h-0SvP67Z`GnFDW;LD@9@k9P0&J)#X@7N>0g(vdk=p|Pl3Me;hH<+V7zPgwecx9kbt|g{YF9$NDAOddGQVx2MPoh^$y`p)yqX
zERJ~>5FI5T{pH|&5=dncvFk(GZ4{CsGLo4DG4T%aP?|YR%wjf?T+MmQ(N6n5V%f13k<3v1dFT{0HaiHCvsemgX5s>dUjxDpd
zt-PhDN4`9~zNLODC7#-BnH!s3F<}|_e&_MfY-u+RYuoPkhvCrILty0X>+eiQE`V(2xDer%!G7P^vdCFW>)rAYx-=l@T&Vw#I!5Tp3
z4F)y(uIKxh8k+xYHN;smFol)n%SE_-VR9>f~
z{0c1AR)U5PSRgoYtuBawA$Gm(1K`XE`ko2j2z1v#eHYMS)JM35&q*sUn$i&V5}NlI
zARhU|jgoN6bAk1g()uAYDloJ#^s
z;_a7x;FF8{mP{fbQJ`52HIBm5C`g1;8ApxD_L5$9$Zf}+WxM3E(-B7j9dbidZl9q=
z(?JC4vqQpT8VFL<8E?~8M7Jo5xGl%TcJye7P>!r#jq*UsokKKBMdgF94872k
za^Rv%y$X>g$cY$vUcYS}O|H=G5-MRY@;N7e&|aIsssf{`h1Oqb!w{p&ew$=bH{w0+
z-5O`Z@+H*TK;1NV&gK}iUQ;el$;8_jUloS${MY7X^=xi
z>CwuF7%@canbRgC6VB+g4LqPo#1DT1P=H=00CW3KU64!I;Fbb7Sgl1uhXnU&h0*xb
z5#_TL2n&<}1*}jIqB1C($%8i|U>4x=5kO8@N>3ny@EpLMs1PST2nP&UpRv8VX-EYY
z;6j;7pSx?cTRW6utpq?oA0boo_2ty(&FK{RvKa}Ou!w|^iUrvB2+m22Qg+n7N{4zJrzBv^w&K!c8d`^n
zj4dOPA-yu?InVm&O64*B9C+%GJG+E>8C`ZBQC~p`BT`Oz#2qa|>=IK*GEq-1S)*@&
zk5#T#d^-@#1(v-LJXQmixofBrZ*d0dg)EOSY{xhxo0hX0iurIPX?XrZK*Sr)g7~ao
z{9B*)9e>W_z(L;O;UGBEYQ#94NH!B5B{vJ0OUK#)_twVtmwZSU*asaM1l$&_v%1f_
zZ(j!^kighFOpwR4mP2l1@RO44?T@HlL-FiqB8U842ZnCV(jm8IT}Q#QsSODX%!e4^
zSlBXahmAym(3XAYghP=F$+++_=ZHh?#9f*bl7}jrugU03k-)pU(Ae@MM=(+%NXSAq
zDZArqwCph`hBHj&emmCh!5HbUEI;~3oO#jnYD!c33Dp02l3%h*C}2DB1ywv-Gw2fq
zH!8qbO(>8Xo>!T%3JT~3J|X*gStSF?r~MV%>zi&*uXOuBxZ<|<>^*}#x}GTCGzv1k
zo{sG-hN+~@no^g%V22MJI%FrOQrbWzY{kErPEI$4juc-#q7Agf@YXeJV99>)@z2qD
zxEPz0ay1j8S2iu3jZ?GmcyEbj&8~KAV>TU=+}dVhpYSpuVBY4)lGS_6X}wqenC=zV
z&@3by;xd{e>Gq;{mULI}Gs_SFnW~+k+%2k=NTNo_#?DIm@D*z!F}aG37-A=|RY6MQ
z^j$5U3`T$|gOF>K=OR8HDalV+W;)FQCn6owZJ>7Wb!_ugyxWfZ$p|`DNM)1Hw4(nZ
zGStpH$T@?hck1Z3@`9pixFDl7qX^v8
z@6w9S%>7!c>(q4oo!@;nx5U~WwU(kYuOZo*xZ4hIpvIP`CKuMHufJHw6sp$|FTh
z6_>^NHe!s??{%>^Bzt$a$?T^W7YlozLz}%13}1#N>9S42ID~ZDMp1Ge39+dGPK&Z+
zx;4HEkAA|5aNT9JO^KJWfRE}AJ=m@)+~ImNvfLBg=TVOH%#~cYL4C>RmHRa$9n)>=
z*Nxk8f1Zc(DQr6g2xjK9k?-m($@?2b3Qh#R?N_PRl6LHm9NEuA7k)12%oY$XZyT1F
z{lN1W3$UD1O;;vIViH2^7vB>G9d3n(Qz!0+Bdz9VbS+He{>?Xo$fKs5+f^P|oUR4M
zO~u*r27N&?EU|UJgKdu6s}On7@8MHOY|Ia8uWI&%trc>%#|~ere*$32O(ic@3=iTm
zRG%>9g3Qo)<+JN1`~r47-t3$)0DOGPV>*qfBj5#KsG##p6!l^=y+UUE>6rAhdPi4W
z8tyCxkZm8KQw_vJ0hB>>A?1=y<47RjZx?>_UzVsqWsJqu;UYeB?t7>BL?#~j1+S$g
z-o*mmtasi?y>NBcgxBeCE(RTqqwm#kxHO)b-h?LBqV86)N1sp)>b85#;Z7n~8ixAy
zR0O9-h0r9tY8ckS|Du4Hg24EhpTH(rYZsvo^*|tDRFMOp734uD&+w)Dw((2Mrk-PF
zIaT}ZAv%w0wpr@Z$Ba{w0bl9rCG~kpr~cwe>U?+r&o|8~f&`MMY{thrF(dfM+jmlm
z&ah>-3!KVEF;}09U3M4%P!gOV<3V%(!&4{4m}dTe02wh{j4{CyOjTAjWKHsy+PXX`
zL~R(dzOC}PvLCLIM|ClVwf}2ao*#CMA1?b8#MbY#9ml~6F>!9u-&hdtYRsRtkq5?Y
z2gc9Vk#X!2Ft2h?_v4(2+jwzEvzhe(OU-b9uAN%4mx@*A#2%Atyp%
zpmMPAAn7AI0a9HbV%N1e6|31>ElMq0sxo0yFXdZfnF`R7RwU}yJXV#CGD7kk=6mb^
zbkH(A&xcx*j%{Df$MXvVd4rk_
zw)*;w*s0@mCX2>;WYiiB+r=s8mJRx7fAD0r^7f^2k~z1uJ#yETaAq3Niejm9pPIrc
zEv&2+-@T6jRy}f8W1Als18@30BIKor_(kz4ECB#8L=4>){;sT-q{cpqB14rhnA;1Z
z<`>?;ohD`V30S06RIA_@{6f?XgM@vc4xsOBV{jBkb#Q7~JO<|gtMjbN=Ng)rO>KnDDq7ccTBWEA9vCZ041Ixj^zC#6@qga#7v$34Wre&SaO@gp9
z-M^!sR(?|g-P3XTwJYB*QKGc?uJfx`yjK|K+Z5lMwVX?$N4n`y3MaI|O)^LGks^tL
zKn||ul(M*hH~fO=v;J)7+FIJKB4f!7ur+zzaBOKpkz=908Sl`3k%7MTh7~SVV{7+S
z+TrG_5+FR-jZ1`)O+<9L|3zIQoPp@%$7v)0Fa!#~J9=r?y|{`&15fIVziu+|p35~!
z>u>As0FS$?4X<+|kGQQn3biFo=K{F1v-ax>Vaka>-=hDroWBJR9+^gP0YzXIf8w*q*)7le(G4@ehg6&0W)ATrLeNDr~!RU6L>O@4Qz
z&vGVl2ki;pd?fqwl1D}@>pR-O9Xnn*V7y{+vQLg_vR9_xw`KImVKx~{=>qcP(D^*|UT-jcXp`gPTz?Otm`z%))cM*fDo5B7t1MKqOHOgD8Yc@CERq
z^$jlCwl;8Mw7wpA(WjKlfOx_<_wKc-?Ha$JEI7qe8ebhe{c(ikw>ER?KE3y%fbtVfn>nLYCl4
zhi5zv@`T@}h#d#mUs@u|htpe$<}{^re(}9D>nPxaR6VL>@3oyb| )>XPy-1Bq!Z1VZRRF+cOr=LtQa^NafB@$~iNvdVB
zDb65fk({iK9)dGKaY`YWFl&1bSN%fio~t#H>cAocF3fFc&l>?{^sMQ={$j1-nCIp6
zlLJ`v{Y$rePY3e=G3
z(6N-hy(|e{3V9n}!n#$nxr=25tkC`LT-I^9i&t&OZ5(8y+B0n*BcpQtv9ftv<6as~
zI@bpiNwL$JN$GGW3rEbu-Xz)W;W0@}@)71JffEUtvo!ctEGtj>Di8h83uS?_(^G^t
zJ}nbofR2@)8jpI*t)*k}^jIC!mV)xatC)Jcy=zC=)*Y(HomvGv#Zx}mXEEq}s*R|q
zYz~$kzmQu7Bu=7Cw>^nGQL$|Y7FfVgbCbR61|>|F(ue4yG%pnIsqSI8{=IUJ;&&V*
z)zLeCQMbtMX&7s>5FghruiUu{u94NNY98Dzu9dT?7b#6)j(6QL>!C227fRH@?>>y5
zujk6{#I?I~7U8Bdi;QKD+=S*G@;gG~q6MiFB1+v(?LE*b;(T)CoJYIT`4nfPFxk>@
zlhxZ~P!V;`R-&=e4XcE5=DLh^D)x!DqqpYgYFyMyzODeL>b+A-aR~Rsqs3G+MBZypUCgmA!zS$UQ4nVaZHOtN
ztg(MJyDK;~8#(@iLqLrFvThb7y%hsF*ZSwiZ?I@v>zuE1In+gR^-wP){+im{Lmr_}
zcTIJ<0quTW0Tb0%9YTF~Yf?J3dLf(edvV6+LMfe2b-RQkS=Cb3Uxa&a0~HT^PN>$k
zpmjlv`7S{u4brsx*cCL2JqD{9qH#>Xk7E;3XW)gz#J%rZheBSb+v|<<>+Z;~
z(3<~4QUOoCSqkXB>Z@I|uBm~gR!YZoEg8TSt&(aY<$Uw-
zSCK<~(sc4Ucew$)m>5k#S>`-}|3NMSB5t0F@N4aLP1Nj1uoix=PyZn;JMMSsF-#4j
z@0reVJcaw2(Ya;!wHltY`1F)#Sc5n2qAS9ifK+#42l5N>5Pwt?9sY>;NCsc=j=7Gv
z$zeo+1GdVAuxc&RbgT^G^qzMeDE8SSv^rSKMV=y7!XhW!^w}W1I$|<iOI1q4IIic%P
zzemo{PiXK6%F59O?9^SMjYc6%`3s38iYHQ0yS!B-MmQ`keppt&^fTmzK8>6e6f8Q)
z(S+8^z&rF+PF@2e1RAs6S*=)C}22dM~g(6S7
z84{Qb{btDv?*=o;zS@8YuH&@NIt3^Qz2V5LtjgBgj|M(f6%MpXn_y?9FOFSydtJA2
zykNtzCs~eKR~lwvUhrI$^7t2bUp~suty#SR%sXDL`>c0wcp=tdIAC{09ZvXRybcsh&YEHhh?=ef8W$Djh$wu)NB_ziXJ
z#sXIGU8oMCVdQKL17R9*EANg{{dWc*#R~n(T61e!Z;BzTzX69BjNs7?R
zx@9Ba%F<(N94!y*pklmRlI@e>J_ShTt;L0w&g5abNRI4OHfMqxJeviU1}#~GuttDn
zLw!M2<^g%XoKI#ZlGZi%qP0t6Rw_2T^K0L95E6E3kt8>Z1p^OjUvBCjq2;O7^L#r3
zE_{sL3JMPN@YC`Z7ON|8(n-lvUPPdIV#M_uxGd#ZhqL~Jh2&d3y0myX^>Z1#rO3-9
z_Hjfcv7xKy|Kz_b-w5zKBk1NWFV#w>=CruEZI}Y+gbXgy<&zmKNf|lu^(l^`fJdzm
zed3<0E?vjSnvXE>=`y62Lf97`K4m=u;Rx@c2434{7nPUVj4NAAJ{@?F_ty8@*e9D?
zQ-bq6lCN9#N%MxJuslDZ20nL%;7mw}m~(3|T&||&vG`6NpRVFj*!IAG)eUdz)@B?&
z(tRJ^gbHv$t!i5K#{9FWrghWKJLJRKY=Vc#XD1M4`db5Blg9>})z7-_J=6DK3u49&
z`OI_i0%(ClZrU_%g8ho4r!|@6=g4f%D2S*`9O6n_dW9yl#g#eg7%>(R%kI>ZeB0$D
z|52z%8{3Q*X+C+_Sa>ew1={W>Q`RVLVr|j8NYpM~tHaz~^s)zlWeh9+U|RqQj%$W>1s{6w23kE=4^!k`1LH_uv>e_e9MalmWMf;a)+2f3V<9+dq+yLgPYu^r
z=Lg!B`i(z~r4J%)V+vBp;?%}cC6Mj!@5Ew~B^Y|*>qL};qJwwFC*%6~?RxMQE5F-(
zbRE80{EJuc_q*Ub#y@&q-qSG*#sEB>!)Gk&@EG!;r=NMC1-zU%b=Sd*44JoBMCH1E
zi__)ANQVYbgqB^n5N9)i$1t%@aO$QLEp6~AoSB6%eqg5BSvr$-&C#sktIX3_r?wbv
zqYk1~yJCGuDbuNrdq(vOC}_YK(t^1~wZ0K@4&FOvuoD#vMv|0i+le4z2QCXfyizQ$
z@KJKAPZA?VYjg1nAWzH|rlux^^20fS(r1jrz-z87PY2fM>MYd)Y`=`N+doLF2MD&i
z^55x;@F#HbyUR?#a-X_8Z|Uj1xEPp(Ea
z&W|hA0W!$&?UM4W<_$27s&j+JHc0bK>dXDwr}-QRuiR3)&Sn)2mW6vrNcHZf3FXjDO^+N1C)?UtEwb|8YmTkcutDWVECdJI6C^>`Tw|Ee>`t+-F_*7c-xuL<@@h9TzV
zoJTc{B3hNPig6&3Z>*aFMRG`Sc*>v^Cmj)5CJE(E&TLdpmtl3AVai93nLedw%uPby
zn|;r6vn_7r6%8n1Yh7Kw>gY%N`S**z@bX^=^22@fhozsc
z(LaU~GBh<4$?}#!CG;l2QNsmIE?TLg4r=RRkn2F8mf6Bl!)2nEbo-gR%n6;|lb2UW
ze0sD7cVH_^%L}L!!s^T)N8qslKTm@X|EQemkKk|L$^S>s%Uihk2r-(2$5nme(YL^r
zt_^mDylJlCATzIwVkwDG#-TBu~F52
zHXGD2GONnxPC>re?MPIhJo!Teldn~Y6%1{s6{^UdOq8J`+=5aRqU{Vpl921VC*-7c
zLoAmxyi93xO2nkDPbMasB`o9Ae~Y+bk?Zm2jlkh9qY>N}1OwCv7YAe}Eaimm&mn(9
zU%K?gLi8yojWzkz=x2|-HIGN&tQ!9Ha-ST_jP{hqVx+WM1*G#vgx9}FhEWmtY@u3-
z!~u7BYVIUm>tQr+xvaCKgBKr&19&@Kbg=y#;9UHhxhRjNwVC%xUG{rZdW439jhvvn
zczm6^c_P3_PQ=-TL|e!4sXMTw_2R#EXxJJJaTIY6Y}39)~v2uZS(2scX46HOuin2)wmt9ER|Pj=0DVDX|%jZHPU(LLj`^
zxWN)MVZ;hcWiivzn9THahCW&i_JtD^tVvwpci*e#8`n=x78Z?-PYAinP2nT5a-)tz
zhp-ejGd&kxSsF-oc7-MhflEs&TGNKfGJHL0C)^~^&235wsopU0FD%ZC$qd_kxgeT+
zNwd4mBh8veJQgy$U0nROBv6&2yxXd7d2rA)SAcZ>Tu+81DIi |