feat(runner): polish live devtools console

This commit is contained in:
Dotta committed 2026-08-20 10:38:04 -05:00
1 parent dafb8848d5
commit 357caf4e3c
13 files changed
+596 -151

No files matched your search

@@ -256,6 +256,22 @@ test.describe("Capability issue thread", () => {
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();
@@ -398,7 +414,7 @@ test.describe("Capability issue thread", () => {
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: "Evidence" })).toBeFocused();
await expect(page.getByRole("heading", { name: "Developer tools" })).toBeFocused();
});
test("Escape closes the desktop overlay sheet and returns focus to its toggle", async ({
@@ -412,7 +428,7 @@ test.describe("Capability issue thread", () => {
const panel = page.getByTestId("evidence-panel");
await expect(panel).toHaveAttribute("data-layout", "overlay");
await expect(page.getByRole("heading", { name: "Evidence" })).toBeFocused();
await expect(page.getByRole("heading", { name: "Developer tools" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(panel).toHaveCount(0);
@@ -463,6 +479,28 @@ test.describe("Capability issue thread", () => {
*/
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 = {
@@ -656,6 +694,17 @@ test.describe("Capability clean-room chat", () => {
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();
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) => {
@@ -1018,11 +1067,14 @@ test.describe("Capability streamed live turn", () => {
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.",
@@ -12,6 +12,7 @@ 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";
@@ -35,7 +36,7 @@ const PANEL_OPEN_KEY = "paperclip-runner.capability.panel.open";
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 = 640;
const PANEL_MAX = 960;
/** Play-all cadence for the replay strip (§6); slow enough to read a turn. */
const REPLAY_STEP_MS = 800;
/**
@@ -76,7 +77,7 @@ function scrollEvidenceIntoView(target: Element, block: "start" | "center"): voi
target.scrollIntoView({ block });
if (block !== "start") return;
const panel = target.closest<HTMLElement>(".pit-panel");
const head = panel?.querySelector<HTMLElement>(".pit-panel-head");
const head = panel?.querySelector<HTMLElement>(".pit-panel-chrome");
if (panel == null || head == null) return;
panel.scrollTop = Math.max(0, panel.scrollTop - head.offsetHeight);
}
@@ -97,6 +98,23 @@ function settledAnnouncement(snapshot: CapabilityIssueThreadSnapshot, interactio
return SETTLED_ANNOUNCEMENT;
}
function currentTurnActivity(snapshot: CapabilityIssueThreadSnapshot): string {
const turn = snapshot.turns.at(-1);
if (turn === undefined) return "Dispatching turn to Codex";
const activity = [...turn.items].reverse().find((item) =>
item.kind === "tool_activity" || item.kind === "progress_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 Codex response";
return "Waiting for Codex activity";
}
function useRoute(): CapabilityRoute {
const [route, setRoute] = useState(() => parseCapabilityRoute(window.location));
useEffect(() => {
@@ -299,13 +317,13 @@ export function App() {
}, [snapshot]);
useEffect(() => {
if (!panelOpen || snapshot === null || route.mode !== "live" || streamingTurn) return;
if (!panelOpen || snapshot === null || route.mode !== "live") 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; };
}, [panelOpen, route.mode, snapshot?.renderedAt, snapshot?.sessionId, streamingTurn]);
}, [panelOpen, route.mode, snapshot?.renderedAt, snapshot?.sessionId]);
useEffect(() => {
if (snapshot === null) return;
@@ -736,6 +754,15 @@ export function App() {
</p>
) : null}
{streamingTurn ? (
<div className="pit-live-activity" role="status" aria-live="polite" data-testid="live-activity">
<span className="pit-live-activity-pulse" aria-hidden="true" />
<Icon name="terminal" />
<span>{currentTurnActivity(snapshot)}</span>
<span className="pit-live-activity-tail" aria-hidden="true">live</span>
</div>
) : null}
{replay !== null ? (
<div className="pit-replay-strip" data-testid="replay-strip">
<span>
@@ -875,6 +902,26 @@ export function App() {
aria-valuemax={PANEL_MAX}
aria-valuenow={panelWidth}
tabIndex={0}
onPointerDown={(event) => {
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") {
@@ -1,8 +1,9 @@
import { useEffect, useMemo, useState } from "react";
import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools";
import { Icon } from "./Icons";
type Tab = "timeline" | "state" | "diff" | "protocol" | "runtime" | "authority";
type Tab = "timeline" | "state" | "diff" | "documents" | "protocol" | "runtime" | "authority";
type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
function JsonTree({ value, name = "root" }: { value: Json; name?: string }) {
@@ -54,6 +55,36 @@ function download(snapshot: CapabilityDevtoolsSnapshot) {
URL.revokeObjectURL(url);
}
const TABS: ReadonlyArray<{ id: Tab; glyph: string; label: string }> = [
{ id: "timeline", glyph: "◫", label: "Timeline" },
{ id: "state", glyph: "{}", label: "State" },
{ id: "diff", glyph: "±", label: "Diff" },
{ id: "documents", glyph: "▤", label: "Documents" },
{ id: "protocol", glyph: "⇄", label: "Protocol" },
{ id: "runtime", glyph: ">_", label: "Runtime" },
{ id: "authority", glyph: "◇", 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 }: { snapshot: CapabilityDevtoolsSnapshot; onFork: (revision: number) => void }) {
const [tab, setTab] = useState<Tab>("timeline");
const [query, setQuery] = useState("");
@@ -74,6 +105,11 @@ export function DevtoolsInspector({ snapshot, onFork }: { snapshot: CapabilityDe
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]);
@@ -81,15 +117,15 @@ export function DevtoolsInspector({ snapshot, onFork }: { snapshot: CapabilityDe
return (
<section className="pit-devtools" data-testid="devtools-inspector">
<div className="pit-devtools-toolbar">
<label className="pit-card-meta" htmlFor="devtools-search">Filter</label>
<span className="pit-devtools-brand"><Icon name="activity" /> State monitor</span>
<input id="devtools-search" className="pit-input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="operation, entity, turn…" />
<button className="pit-button" type="button" aria-pressed={!following} onClick={() => setFollowing((value) => !value)}>{following ? "Pause" : "Follow latest"}</button>
<button className="pit-button" type="button" onClick={() => download(snapshot)}>Export</button>
<button className="pit-button" type="button" onClick={() => onFork(revision)}>Fork r{revision}</button>
<button className="pit-icon-button" type="button" aria-pressed={!following} onClick={() => setFollowing((value) => !value)} title={following ? "Pause live updates" : "Follow latest revision"} aria-label={following ? "Pause live updates" : "Follow latest revision"}><Icon name={following ? "pause" : "play"} /></button>
<button className="pit-icon-button" type="button" onClick={() => download(snapshot)} title="Export redacted snapshot" aria-label="Export redacted snapshot"><Icon name="download" /></button>
<button className="pit-button" type="button" onClick={() => onFork(revision)}><Icon name="branch" /> Fork r{revision}</button>
</div>
<div className="pit-devtools-tabs" role="tablist" aria-label="Developer tools">
{(["timeline", "state", "diff", "protocol", "runtime", "authority"] as const).map((id) => (
<button key={id} type="button" role="tab" aria-selected={tab === id} className="pit-tab" onClick={() => setTab(id)}>{id[0]!.toUpperCase() + id.slice(1)}</button>
{TABS.map(({ id, glyph, label }) => (
<button key={id} type="button" role="tab" aria-selected={tab === id} className="pit-tab" onClick={() => setTab(id)}><span className="pit-tab-glyph" aria-hidden="true">{glyph}</span>{label}</button>
))}
</div>
{tab === "timeline" ? (
@@ -102,7 +138,7 @@ export function DevtoolsInspector({ snapshot, onFork }: { snapshot: CapabilityDe
</div>
) : null}
{tab === "state" ? (
<div>
<div className="pit-devtools-pane">
<label className="pit-card-meta" htmlFor="devtools-revision">Revision</label>
<select id="devtools-revision" className="pit-select" value={revision} onChange={(event) => setRevision(Number(event.target.value))}>
{snapshot.revisions.map((entry) => <option key={entry.revision} value={entry.revision}>r{entry.revision} · {entry.operationId}</option>)}
@@ -111,7 +147,7 @@ export function DevtoolsInspector({ snapshot, onFork }: { snapshot: CapabilityDe
</div>
) : null}
{tab === "diff" ? (
<div>
<div className="pit-devtools-pane">
<div className="pit-button-row">
<select className="pit-select" aria-label="Compare from revision" value={compareRevision} onChange={(event) => setCompareRevision(Number(event.target.value))}>
{snapshot.revisions.map((entry) => <option key={entry.revision} value={entry.revision}>from r{entry.revision}</option>)}
@@ -124,9 +160,25 @@ export function DevtoolsInspector({ snapshot, onFork }: { snapshot: CapabilityDe
{rows.length === 0 ? <p className="pit-muted">No changes between these revisions.</p> : null}
</div>
) : null}
{tab === "documents" ? (
documents.length === 0 ? <div className="pit-devtools-empty"><span aria-hidden="true">▤</span><p>No documents exist at r{selected.revision}.</p></div> : (
<div className="pit-document-browser">
<div className="pit-document-sidebar">
{documents.map((entry) => <button type="button" key={entry.id} data-selected={entry.id === document?.id} onClick={() => { setDocumentId(entry.id); setDocumentRevision(0); }}><strong>{entry.key}</strong><span>{entry.title}</span><small>{entry.revisions.length} revision{entry.revisions.length === 1 ? "" : "s"}</small></button>)}
</div>
{document !== null && visibleDocumentRevision !== null ? (
<article className="pit-document-viewer">
<header><div><span className="pit-card-meta">{document.key}</span><h4>{document.title}</h4></div><select className="pit-select" aria-label="Document revision" value={visibleDocumentRevision.revision} onChange={(event) => setDocumentRevision(Number(event.target.value))}>{document.revisions.map((entry) => <option key={entry.id} value={entry.revision}>r{entry.revision}</option>)}</select></header>
{visibleDocumentRevision.changeSummary ? <p className="pit-document-summary">{visibleDocumentRevision.changeSummary}</p> : null}
<pre className="pit-document-body">{visibleDocumentRevision.body}</pre>
</article>
) : null}
</div>
)
) : null}
{tab === "protocol" ? <div className="pit-devtools-list">{protocol.map((entry) => <details className="pit-runner-event" key={entry.id}><summary><strong>{entry.boundary}</strong> · {entry.event}</summary><JsonTree value={entry.detail as Json} /></details>)}</div> : null}
{tab === "runtime" ? <JsonTree value={snapshot.runtime as Json} /> : null}
{tab === "authority" ? <JsonTree value={snapshot.authority as Json} /> : null}
{tab === "runtime" ? <div className="pit-devtools-pane"><JsonTree value={snapshot.runtime as Json} /></div> : null}
{tab === "authority" ? <div className="pit-devtools-pane"><JsonTree value={snapshot.authority as Json} /></div> : null}
</section>
);
}
@@ -9,6 +9,7 @@ import type {
} from "../../../src/issue-thread/types";
import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools";
import { DevtoolsInspector } from "./DevtoolsInspector";
import { Icon } from "./Icons";
import {
CAPABILITY_EVIDENCE_SECTIONS,
capabilityDispositionLabel,
@@ -230,45 +231,32 @@ export function EvidencePanel(props: EvidencePanelProps) {
style={layout === "side" ? { width: `${width}px` } : undefined}
aria-label="Evidence"
>
<div className="pit-panel-head">
<div className="pit-header-controls">
<h2 className="pit-panel-title" tabIndex={-1} ref={headingRef}>
Evidence
</h2>
<button type="button" className="pit-button" onClick={onClose}>
Close
</button>
</div>
<label className="pit-card-meta" htmlFor="evidence-turn-selector">
Turn
</label>
<select
className="pit-select"
id="evidence-turn-selector"
value={selectedTurnId}
onChange={(event) => onSelectTurn(event.target.value as string | "all")}
>
{snapshot.turns.map((turn) => (
<option key={turn.id} value={turn.id}>
Turn {turn.ordinal}
</option>
))}
<option value="all">All turns</option>
</select>
<div className="pit-panel-chrome">
<h2 className="pit-visually-hidden" tabIndex={-1} ref={headingRef}>Developer tools</h2>
<span className="pit-panel-kicker"><Icon name="activity" /> Paperclip DevTools</span>
<button type="button" className="pit-icon-button" onClick={onClose} aria-label="Close DevTools" title="Close DevTools">
<Icon name="close" />
</button>
</div>
{devtools !== undefined ? (
<>
<h3 className="pit-evidence-heading">Company DevTools</h3>
{devtools === null ? (
<p className="pit-muted pit-devtools-loading">Loading company state…</p>
) : (
<DevtoolsInspector snapshot={devtools} onFork={onForkRevision} />
)}
<h3 className="pit-evidence-heading">Evidence log</h3>
</>
) : null}
<div className="pit-evidence-filter">
<label className="pit-card-meta" htmlFor="evidence-turn-selector">Evidence scope</label>
<select className="pit-select" id="evidence-turn-selector" value={selectedTurnId} onChange={(event) => onSelectTurn(event.target.value as string | "all")}>
{snapshot.turns.map((turn) => <option key={turn.id} value={turn.id}>Turn {turn.ordinal}</option>)}
<option value="all">All turns</option>
</select>
</div>
<Section
id="tools"
title="Tools exposed"
@@ -0,0 +1,25 @@
export type IconName =
| "activity" | "branch" | "close" | "download" | "evidence" | "pause"
| "play" | "reset" | "spark" | "stop" | "terminal";
const PATHS: Record<IconName, string> = {
activity: "M3 12h3l2-6 4 12 3-9 2 5h4",
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",
download: "M12 3v12m-5-5 5 5 5-5M5 21h14",
evidence: "M4 5h16v14H4zM8 9h8M8 13h5",
pause: "M8 5v14M16 5v14",
play: "M8 5v14l11-7z",
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",
stop: "M7 7h10v10H7z",
terminal: "M5 7l4 5-4 5m7 0h7",
};
export function Icon({ name }: { name: IconName }) {
return (
<svg className="pit-icon" viewBox="0 0 24 24" aria-hidden="true">
<path d={PATHS[name]} />
</svg>
);
}
@@ -1,6 +1,7 @@
import { useEffect, useRef, useState } from "react";
import type { CapabilityIssueThreadSnapshot } from "../../../src/issue-thread/types";
import { Icon } from "./Icons";
import { Chip, PriorityIcon, StatusBadge } from "./primitives";
/**
@@ -61,17 +62,34 @@ export function IssueHeader(props: IssueHeaderProps) {
return (
<header className="pit-header" data-session-mode={snapshot.mode}>
<div className="pit-header-title-row">
<span className="pit-identifier">{snapshot.issue.identifier}</span>
<h1 className="pit-title">{snapshot.issue.title}</h1>
<StatusBadge status={snapshot.issue.status} />
<PriorityIcon priority={snapshot.issue.priority} />
{snapshot.issue.assignee !== null ? (
<span className="pit-assignee">{snapshot.issue.assignee}</span>
) : null}
<div className="pit-header-primary">
<div className="pit-header-title-row">
<span className="pit-identifier">{snapshot.issue.identifier}</span>
<h1 className="pit-title">{snapshot.issue.title}</h1>
<StatusBadge status={snapshot.issue.status} />
</div>
<div className="pit-header-controls">
{chat ? (
<button type="button" className="pit-button" onClick={onNewChat} data-testid="new-chat-button">
<Icon name="spark" /> New chat
</button>
) : (
<>
<label className="pit-visually-hidden" htmlFor="scenario-picker">Scenario</label>
<select className="pit-select pit-desktop-only" id="scenario-picker" value={snapshot.issue.fixtureProfile} onChange={(event) => onSelectScenario(event.target.value)} data-testid="scenario-picker">
{scenarios.map((scenario) => <option key={scenario} value={scenario}>{scenario}</option>)}
</select>
<button type="button" className="pit-button pit-desktop-only" onClick={onReplay} data-testid="replay-button"><Icon name="play" /> Replay</button>
</>
)}
<button type="button" className="pit-icon-button pit-desktop-only" data-variant="destructive" onClick={onReset} data-testid="reset-button" title={chat ? "Reset chat" : "Reset scenario"} aria-label={chat ? "Reset chat" : "Reset scenario"}><Icon name="reset" /></button>
<button type="button" className="pit-icon-button" data-variant="destructive" onClick={onStop} disabled={!turnActive} data-testid="stop-button" title="Stop turn" aria-label="Stop turn"><Icon name="stop" /></button>
<button type="button" className="pit-button pit-desktop-only" aria-expanded={evidenceOpen} onClick={onToggleEvidence} data-testid="evidence-toggle"><Icon name="evidence" /> DevTools{denialCount > 0 ? ` (${denialCount})` : ""}</button>
</div>
</div>
<div className="pit-chip-row" data-testid="identity-chips">
<div className="pit-header-context">
<div className="pit-chip-row" data-testid="identity-chips">
<Chip tone={snapshot.mode === "live" ? "live" : undefined} testId="agent-chip">
{snapshot.identity.agentLabel}
{snapshot.identity.replaySource !== null
@@ -97,68 +115,10 @@ export function IssueHeader(props: IssueHeaderProps) {
Clean room {cleanRoomToken}
</Chip>
) : null}
</div>
<div className="pit-header-controls">
<span className="pit-run-state">{snapshot.issue.runState}</span>
{chat ? (
<button
type="button"
className="pit-button"
onClick={onNewChat}
data-testid="new-chat-button"
>
New chat
</button>
) : (
<>
<label className="pit-visually-hidden" htmlFor="scenario-picker">
Scenario
</label>
<select
className="pit-select pit-desktop-only"
id="scenario-picker"
value={snapshot.issue.fixtureProfile}
onChange={(event) => onSelectScenario(event.target.value)}
data-testid="scenario-picker"
>
{scenarios.map((scenario) => (
<option key={scenario} value={scenario}>
{scenario}
</option>
))}
</select>
<button
type="button"
className="pit-button pit-desktop-only"
onClick={onReplay}
data-testid="replay-button"
>
Replay
</button>
</>
)}
<button
type="button"
className="pit-button pit-desktop-only"
data-variant="destructive"
onClick={onReset}
data-testid="reset-button"
>
Reset
</button>
<button
type="button"
className="pit-button"
data-variant="destructive"
onClick={onStop}
disabled={!turnActive}
data-testid="stop-button"
>
Stop
</button>
</div>
<PriorityIcon priority={snapshot.issue.priority} />
{snapshot.issue.assignee !== null ? <span className="pit-assignee">{snapshot.issue.assignee}</span> : null}
<span className="pit-run-state" title={snapshot.issue.runState}>{snapshot.issue.runState}</span>
<div className="pit-menu pit-mobile-only" ref={menuRef}>
<button
@@ -222,16 +182,6 @@ export function IssueHeader(props: IssueHeaderProps) {
) : null}
</div>
<button
type="button"
className="pit-button pit-desktop-only"
aria-expanded={evidenceOpen}
onClick={onToggleEvidence}
data-testid="evidence-toggle"
>
Evidence
{denialCount > 0 ? ` (${denialCount})` : ""}
</button>
</div>
<div className="pit-segmented" role="tablist" aria-label="Thread or evidence">
@@ -283,10 +283,24 @@ textarea {
flex: none;
display: flex;
flex-direction: column;
gap: var(--pit-space-2);
gap: var(--pit-space-3);
padding: var(--pit-space-3) var(--pit-space-4);
background: var(--pit-surface);
border-bottom: 1px solid var(--pit-border);
box-shadow: var(--pit-shadow-sm);
}
.pit-header-primary,
.pit-header-context {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pit-space-3);
min-width: 0;
}
.pit-header-context {
justify-content: flex-start;
}
.pit-header-title-row {
@@ -311,6 +325,17 @@ textarea {
min-width: 0;
}
.pit-icon {
width: var(--pit-space-4);
height: var(--pit-space-4);
flex: none;
fill: none;
stroke: currentColor;
stroke-width: 1.75;
stroke-linecap: round;
stroke-linejoin: round;
}
.pit-assignee {
font-size: var(--pit-text-xs);
color: var(--pit-muted-foreground);
@@ -439,6 +464,10 @@ textarea {
font-family: var(--pit-font-mono);
font-size: var(--pit-text-xs);
color: var(--pit-faint-foreground);
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pit-button {
@@ -456,6 +485,34 @@ textarea {
cursor: pointer;
}
.pit-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--pit-touch-target);
height: var(--pit-touch-target);
padding: 0;
border: 1px solid var(--pit-border-strong);
border-radius: var(--pit-radius-sm);
background: var(--pit-surface-raised);
color: var(--pit-foreground);
cursor: pointer;
}
.pit-icon-button:hover:not(:disabled) {
border-color: var(--pit-accent);
background: var(--pit-accent-surface);
}
.pit-icon-button:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.pit-icon-button[data-variant="destructive"] {
color: var(--pit-danger);
}
.pit-button:hover:not(:disabled) {
background: var(--pit-surface);
}
@@ -1078,18 +1135,31 @@ textarea {
/* -------------------------------------------------------- evidence panel */
.pit-splitter {
width: 0.5rem;
position: relative;
width: var(--pit-space-2);
flex: none;
cursor: col-resize;
background: var(--pit-border);
touch-action: none;
background: var(--pit-surface-sunken);
border: 0;
padding: 0;
}
.pit-splitter:hover {
.pit-splitter::after {
content: "";
position: absolute;
inset: 0 auto 0 50%;
width: 1px;
background: var(--pit-border-strong);
}
.pit-splitter:hover::after,
.pit-splitter:focus-visible::after {
width: var(--pit-space-1);
transform: translateX(-50%);
background: var(--pit-accent);
}
.pit-panel {
flex: none;
display: flex;
@@ -1100,16 +1170,30 @@ textarea {
overflow-y: auto;
}
.pit-panel-head {
.pit-panel-chrome {
position: sticky;
top: 0;
background: var(--pit-surface-sunken);
border-bottom: 1px solid var(--pit-border);
padding: var(--pit-space-3);
padding: var(--pit-space-2) var(--pit-space-3);
display: flex;
flex-direction: column;
align-items: center;
justify-content: space-between;
gap: var(--pit-space-2);
z-index: 2;
z-index: 3;
}
.pit-panel-kicker,
.pit-devtools-brand {
display: inline-flex;
align-items: center;
gap: var(--pit-space-2);
color: var(--pit-muted-foreground);
font-family: var(--pit-font-mono);
font-size: var(--pit-text-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.pit-panel-title {
@@ -1529,7 +1613,7 @@ textarea {
}
.pit-devtools {
padding: var(--pit-space-3);
padding: 0;
border-bottom: 1px solid var(--pit-border);
background: var(--pit-surface-sunken);
}
@@ -1545,7 +1629,21 @@ textarea {
align-items: center;
gap: var(--pit-space-2);
flex-wrap: wrap;
margin-bottom: var(--pit-space-3);
}
.pit-devtools-toolbar {
padding: var(--pit-space-3);
}
.pit-devtools-tabs {
gap: 0;
flex-wrap: nowrap;
overflow-x: auto;
margin: 0;
padding: 0 var(--pit-space-2);
border-top: 1px solid var(--pit-border);
border-bottom: 1px solid var(--pit-border-strong);
background: var(--pit-surface);
}
.pit-input {
@@ -1560,21 +1658,36 @@ textarea {
.pit-tab {
min-height: var(--pit-touch-target);
border: 1px solid var(--pit-border);
border-radius: var(--pit-radius-sm);
background: var(--pit-surface);
padding: 0 var(--pit-space-2);
gap: var(--pit-space-1);
border: 0;
border-bottom: var(--pit-space-1) solid transparent;
border-radius: 0;
background: transparent;
padding: 0 var(--pit-space-3);
color: var(--pit-muted-foreground);
white-space: nowrap;
cursor: pointer;
}
.pit-tab[aria-selected="true"] {
border-color: var(--pit-accent);
border-bottom-color: var(--pit-accent);
background: var(--pit-accent-surface);
color: var(--pit-foreground);
}
.pit-tab-glyph {
font-family: var(--pit-font-mono);
color: var(--pit-live);
}
.pit-devtools-list {
display: grid;
gap: var(--pit-space-2);
padding: var(--pit-space-3);
}
.pit-devtools-pane {
padding: var(--pit-space-3);
}
.pit-devtools-event {
@@ -1638,6 +1751,155 @@ textarea {
overflow-wrap: anywhere;
}
.pit-evidence-filter {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pit-space-3);
padding: var(--pit-space-3);
border-top: 1px solid var(--pit-border);
border-bottom: 1px solid var(--pit-border);
background: var(--pit-surface);
}
.pit-evidence-filter .pit-select {
flex: 1;
}
.pit-document-browser {
display: grid;
grid-template-columns: minmax(10rem, 0.35fr) minmax(0, 1fr);
min-height: 20rem;
}
.pit-document-sidebar {
border-right: 1px solid var(--pit-border);
background: var(--pit-surface);
}
.pit-document-sidebar button {
display: grid;
width: 100%;
gap: var(--pit-space-1);
padding: var(--pit-space-3);
border: 0;
border-bottom: 1px solid var(--pit-border);
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
}
.pit-document-sidebar button[data-selected="true"] {
border-left: var(--pit-space-1) solid var(--pit-accent);
background: var(--pit-accent-surface);
}
.pit-document-sidebar span,
.pit-document-sidebar small {
color: var(--pit-muted-foreground);
}
.pit-document-viewer {
min-width: 0;
background: var(--pit-surface-sunken);
}
.pit-document-viewer header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--pit-space-3);
padding: var(--pit-space-3);
border-bottom: 1px solid var(--pit-border);
}
.pit-document-viewer h4,
.pit-document-summary {
margin: 0;
}
.pit-document-summary {
padding: var(--pit-space-2) var(--pit-space-3);
color: var(--pit-muted-foreground);
border-bottom: 1px solid var(--pit-border);
}
.pit-document-body {
margin: 0;
padding: var(--pit-space-4);
white-space: pre-wrap;
overflow-wrap: anywhere;
font-family: var(--pit-font-mono);
font-size: var(--pit-text-xs);
line-height: var(--pit-leading-relaxed);
}
.pit-devtools-empty {
display: grid;
place-items: center;
padding: var(--pit-space-6);
color: var(--pit-muted-foreground);
text-align: center;
}
.pit-devtools-empty > span {
font-size: var(--pit-text-xl);
}
.pit-live-activity {
display: flex;
align-items: center;
gap: var(--pit-space-2);
min-height: var(--pit-touch-target);
padding: 0 var(--pit-space-4);
border-bottom: 1px solid var(--pit-live);
background: var(--pit-live-surface);
color: var(--pit-foreground);
font-family: var(--pit-font-mono);
font-size: var(--pit-text-xs);
}
.pit-live-activity-pulse {
width: var(--pit-space-2);
height: var(--pit-space-2);
border-radius: 50%;
background: var(--pit-live);
animation: pit-pulse 0.8s ease-in-out infinite;
}
.pit-live-activity-tail {
margin-left: auto;
color: var(--pit-live);
text-transform: uppercase;
}
@media (max-width: 767px) {
.pit-header-primary {
align-items: flex-start;
}
.pit-header-title-row {
flex: 1;
}
.pit-header-context .pit-priority,
.pit-header-context .pit-assignee,
.pit-header-context .pit-run-state,
.pit-chip-row .pit-chip:not([data-testid="agent-chip"]) {
display: none;
}
.pit-document-browser {
grid-template-columns: minmax(0, 1fr);
}
.pit-document-sidebar {
border-right: 0;
border-bottom: 1px solid var(--pit-border);
}
}
.pit-diff-row del {
color: var(--pit-danger);
}
@@ -108,14 +108,16 @@ provider, runner, or control-plane credential.
## DevTools state inspector
Opening **Evidence** also opens a Redux-DevTools-style inspector over the live
Opening **DevTools** opens a Redux-DevTools-style inspector over the live
mock company. Its timeline starts at the pristine fixture, adds a revision for
each successful semantic mutation, and lets the operator inspect or diff the
complete browser-safe company scaffolding: company, actors, tasks, comments,
documents, interactions, approvals, artifacts, work products, blockers,
workspace services, budgets, runs, wakes, audit records, decisions,
idempotency records, and configured faults. Separate tabs expose the protocol
boundary records, runner/runtime facts, and effective authority.
boundary records, runner/runtime facts, and effective authority. A dedicated
**Documents** tab renders every mock document and lets the operator switch
between its retained revisions without digging through raw JSON.
**Pause** pins a revision while work continues, **Export** downloads the
redacted DevTools snapshot, and **Fork rN** retires the current mock session and
@@ -124,6 +126,13 @@ session checkpoints, artifact content references, working directories, and
secret-shaped strings remain server-side or are withheld by the explicit
browser projection.
While a turn runs, a live status rail appears immediately after send and tracks
the newest safe Codex activity. Reasoning, planning, shell-command, file-change,
MCP/dynamic-tool, assistant-stream, and Paperclip semantic-tool lifecycle events
each trigger an interim frame. Discrete tools remain separate rows; only noisy
text deltas are grouped. Raw provider text, command output, and chain of thought
stay withheld.
## Remote preview gateway
The tailnet preview keeps the package server on loopback and exposes it through
@@ -101,7 +101,7 @@ same Codex thread once you answer.
## 4. Look at the evidence, when you want it
Click `Evidence`. The drawer carries the same eight sections the scenario path
Click `DevTools`. The drawer carries the same eight evidence sections the scenario path
uses: tools exposed, calls and typed results, authorization decisions,
control-plane actions, runner events, state diffs, traceability, and parity.
Above those sections, the DevTools inspector provides six tabs:
@@ -109,6 +109,7 @@ Above those sections, the DevTools inspector provides six tabs:
- **Timeline** selects any retained control-plane revision.
- **State** browses the full mock company tree.
- **Diff** compares any two revisions field by field.
- **Documents** reads document bodies and switches between revisions.
- **Protocol**, **Runtime**, and **Authority** show the surrounding scaffolding
that explains why each mutation happened.
@@ -116,6 +117,12 @@ Use **Pause** to stop following the newest revision, **Export** to download a
redacted JSON snapshot, or **Fork rN** to replace this chat with a fresh live
branch rooted at the selected mock-company revision.
Drag the bright divider at the drawer's left edge to resize it (up to 960px), or
focus that divider and use the arrow keys. As soon as you send a message, the
live activity rail reports the current Codex stage; shell commands, built-in
Codex tools, and Paperclip semantic calls also appear as individual activity
rows while the turn is still open.
Two things are worth finding on your first pass:
- **Tools exposed** lists `decide_approval`, `control_workspace_service`, and
@@ -80,6 +80,16 @@ const PROGRESS_EVENTS = {
running: "File-change progress is arriving from Codex.",
complete: "File-change activity completed.",
},
reasoning_started: { activity: "thinking", label: "Thinking", running: "Codex started reasoning.", complete: "Reasoning started." },
reasoning_completed: { activity: "thinking", label: "Thinking", running: "Codex completed reasoning.", complete: "Reasoning completed." },
plan_started: { activity: "planning", label: "Planning", running: "Codex started planning.", complete: "Planning started." },
plan_completed: { activity: "planning", label: "Planning", running: "Codex completed planning.", complete: "Planning completed." },
command_started: { activity: "command", label: "Shell command", running: "Codex started a shell command.", complete: "Shell command started." },
command_completed: { activity: "command", label: "Shell command", running: "Codex completed a shell command.", complete: "Shell command completed." },
tool_started: { activity: "command", label: "Codex tool", running: "Codex started a tool call.", complete: "Tool call started." },
tool_completed: { activity: "command", label: "Codex tool", running: "Codex completed a tool call.", complete: "Tool call completed." },
file_change_started: { activity: "file_change", label: "File change", running: "Codex started a file change.", complete: "File change started." },
file_change_completed: { activity: "file_change", label: "File change", running: "Codex completed a file change.", complete: "File change completed." },
} as const;
export interface CapabilityLiveProjectionInput {
@@ -757,6 +767,7 @@ export function projectCapabilityIssueThread(
const progressGroups = new Map<
string,
{
id: string;
turnId: string;
event: keyof typeof PROGRESS_EVENTS;
at: string;
@@ -767,20 +778,23 @@ export function projectCapabilityIssueThread(
if (entry.kind !== "provider_event" || entry.turnId === null) continue;
const event = readString(entry.data.event) as keyof typeof PROGRESS_EVENTS;
if (!(event in PROGRESS_EVENTS)) continue;
const key = `${entry.turnId}:${event}`;
// Deltas are a stream and stay grouped for readability. Lifecycle records
// are discrete Codex tools/items: retain one row per start/completion so a
// burst of shell commands never collapses into an opaque counter.
const key = event.endsWith("_delta") ? `${entry.turnId}:${event}` : entry.id;
const group = progressGroups.get(key);
if (group === undefined) {
progressGroups.set(key, { turnId: entry.turnId, event, at: entry.at, count: 1 });
progressGroups.set(key, { id: entry.id, turnId: entry.turnId, event, at: entry.at, count: 1 });
} else {
group.count += 1;
}
}
for (const group of progressGroups.values()) {
const copy = PROGRESS_EVENTS[group.event];
const running = snapshot.activeTurnId === group.turnId;
const running = snapshot.activeTurnId === group.turnId && !group.event.endsWith("_completed");
turnFor(group.turnId, group.at).items.push({
kind: "progress_activity",
id: `item-progress-${group.turnId}-${group.event}`,
id: `item-progress-${group.id}`,
at: group.at,
activity: copy.activity,
status: running ? "running" : "complete",
@@ -203,6 +203,22 @@ describe("Capability evidence redaction", () => {
}
});
it("retains each known Codex tool lifecycle category without its payload", () => {
for (const [type, expected] of [
["commandExecution", "command_started"],
["fileChange", "file_change_started"],
["mcpToolCall", "tool_started"],
["dynamicToolCall", "tool_started"],
] as const) {
const redacted = redactCapabilityEvidenceData("provider_event", {
method: "item/started",
params: { item: { type, command: `echo ${CANARY}`, output: CANARY } },
});
expect(redacted).toEqual({ event: expected });
expect(JSON.stringify(redacted)).not.toContain(CANARY);
}
});
it("collapses an unknown provider method rather than echoing it", () => {
expect(redactCapabilityEvidenceData("provider_event", { method: `x/${CANARY}` })).toEqual({
event: "other",
@@ -70,7 +70,24 @@ const PROVIDER_EVENT_CATEGORIES: Readonly<Record<string, string>> = Object.freez
* progress events deserve an immediate browser frame without duplicating the
* protocol-method allowlist.
*/
export function capabilityProviderEventCategory(method: string): string {
const PROVIDER_ITEM_CATEGORIES: Readonly<Record<string, string>> = Object.freeze({
commandExecution: "command",
fileChange: "file_change",
reasoning: "reasoning",
plan: "plan",
mcpToolCall: "tool",
dynamicToolCall: "tool",
});
export function capabilityProviderEventCategory(
method: string,
params?: Record<string, unknown>,
): string {
if (method === "item/started" || method === "item/completed") {
const item = asRecord(params?.item);
const category = PROVIDER_ITEM_CATEGORIES[asString(item.type)];
if (category !== undefined) return `${category}_${method.endsWith("started") ? "started" : "completed"}`;
}
return PROVIDER_EVENT_CATEGORIES[method] ?? "other";
}
@@ -194,7 +211,9 @@ export function redactCapabilityEvidenceData(
): Record<string, CapabilityJsonValue> {
switch (kind) {
case "provider_event":
return { event: capabilityProviderEventCategory(asString(data.method)) };
return {
event: capabilityProviderEventCategory(asString(data.method), asRecord(data.params)),
};
case "diagnostic":
// A provider diagnostic is operator surface, not board surface. Only the
// fact that one was recorded survives.
@@ -1495,7 +1495,7 @@ export class CapabilityLiveSession {
const turn = record(params.turn);
const item = record(params.item);
const turnId = text(params.turnId, text(turn.id));
const providerEvent = capabilityProviderEventCategory(notification.method);
const providerEvent = capabilityProviderEventCategory(notification.method, params);
this.#appendEvidence("provider_event", turnId || null, {
method: notification.method,
params: jsonValue(params),
@@ -1508,7 +1508,11 @@ export class CapabilityLiveSession {
this.#turnWaiter.assistantText += text(params.delta);
this.#publishAssistantProgress(turnId || this.#activeTurnId);
}
} else if (providerEvent.endsWith("_delta")) {
} else if (
providerEvent.endsWith("_delta") ||
(providerEvent.endsWith("_started") && providerEvent !== "item_started") ||
(providerEvent.endsWith("_completed") && providerEvent !== "item_completed" && providerEvent !== "turn_completed")
) {
// Reasoning, planning, command-output, and file-change progress used to
// be retained only for diagnostics. Emitting an activity frame here lets
// the thread acknowledge that work immediately, while the redaction gate