diff --git a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts index 3c34ce895af..9ce216c7f87 100644 --- a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts +++ b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts @@ -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.", diff --git a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx index 230f98aa108..f5a861a842c 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx @@ -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(".pit-panel"); - const head = panel?.querySelector(".pit-panel-head"); + const head = panel?.querySelector(".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() {

) : null} + {streamingTurn ? ( +
+
+ ) : null} + {replay !== null ? (
@@ -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") { diff --git a/packages/paperclip-runner/devtools/issue-thread/src/DevtoolsInspector.tsx b/packages/paperclip-runner/devtools/issue-thread/src/DevtoolsInspector.tsx index ceb12c7ab39..2ad3a771cfb 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/DevtoolsInspector.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/DevtoolsInspector.tsx @@ -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("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 (
- + State monitor setQuery(event.target.value)} placeholder="operation, entity, turn…" /> - - - + + +
- {(["timeline", "state", "diff", "protocol", "runtime", "authority"] as const).map((id) => ( - + {TABS.map(({ id, glyph, label }) => ( + ))}
{tab === "timeline" ? ( @@ -102,7 +138,7 @@ export function DevtoolsInspector({ snapshot, onFork }: { snapshot: CapabilityDe
) : null} {tab === "state" ? ( -
+
setCompareRevision(Number(event.target.value))}> {snapshot.revisions.map((entry) => )} @@ -124,9 +160,25 @@ export function DevtoolsInspector({ snapshot, onFork }: { snapshot: CapabilityDe {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 ? ( +
+
{document.key}

{document.title}

+ {visibleDocumentRevision.changeSummary ?

{visibleDocumentRevision.changeSummary}

: null} +
{visibleDocumentRevision.body}
+
+ ) : null} +
+ ) + ) : null} {tab === "protocol" ?
{protocol.map((entry) =>
{entry.boundary} · {entry.event}
)}
: null} - {tab === "runtime" ? : null} - {tab === "authority" ? : null} + {tab === "runtime" ?
: null} + {tab === "authority" ?
: null} ); } diff --git a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx index a70937f9b77..7ce748f36de 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx @@ -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" > -
-
-

- Evidence -

- -
- - +
+

Developer tools

+ Paperclip DevTools +
{devtools !== undefined ? ( <> -

Company DevTools

{devtools === null ? (

Loading company state…

) : ( )} -

Evidence log

) : null} +
+ + +
+
= { + 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 ( + + ); +} diff --git a/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx b/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx index d7e5609ab7a..2b33fdacb70 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx +++ b/packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx @@ -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 (
-
- {snapshot.issue.identifier} -

{snapshot.issue.title}

- - - {snapshot.issue.assignee !== null ? ( - {snapshot.issue.assignee} - ) : null} +
+
+ {snapshot.issue.identifier} +

{snapshot.issue.title}

+ +
+
+ {chat ? ( + + ) : ( + <> + + + + + )} + + + +
-
+
+
{snapshot.identity.agentLabel} {snapshot.identity.replaySource !== null @@ -97,68 +115,10 @@ export function IssueHeader(props: IssueHeaderProps) { Clean room {cleanRoomToken} ) : null} -
- -
- {snapshot.issue.runState} - - {chat ? ( - - ) : ( - <> - - - - - - )} - - +
+ + {snapshot.issue.assignee !== null ? {snapshot.issue.assignee} : null} + {snapshot.issue.runState}
diff --git a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css index 85002580b2f..5a0cb152f3c 100644 --- a/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css +++ b/packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css @@ -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); } diff --git a/packages/paperclip-runner/docs/capability-clean-room-chat.md b/packages/paperclip-runner/docs/capability-clean-room-chat.md index dbf65a9b0df..981cdfd49ce 100644 --- a/packages/paperclip-runner/docs/capability-clean-room-chat.md +++ b/packages/paperclip-runner/docs/capability-clean-room-chat.md @@ -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 diff --git a/packages/paperclip-runner/docs/tutorials/capability-clean-room-chat.md b/packages/paperclip-runner/docs/tutorials/capability-clean-room-chat.md index 5eec78b9460..bd8cc6ffec7 100644 --- a/packages/paperclip-runner/docs/tutorials/capability-clean-room-chat.md +++ b/packages/paperclip-runner/docs/tutorials/capability-clean-room-chat.md @@ -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 diff --git a/packages/paperclip-runner/src/issue-thread/live-projection.ts b/packages/paperclip-runner/src/issue-thread/live-projection.ts index 46e4bcadc8d..75320734769 100644 --- a/packages/paperclip-runner/src/issue-thread/live-projection.ts +++ b/packages/paperclip-runner/src/issue-thread/live-projection.ts @@ -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", diff --git a/packages/paperclip-runner/src/issue-thread/public-view.test.ts b/packages/paperclip-runner/src/issue-thread/public-view.test.ts index d40cc6e3ee6..3683fc7a095 100644 --- a/packages/paperclip-runner/src/issue-thread/public-view.test.ts +++ b/packages/paperclip-runner/src/issue-thread/public-view.test.ts @@ -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", diff --git a/packages/paperclip-runner/src/live/evidence-redaction.ts b/packages/paperclip-runner/src/live/evidence-redaction.ts index 59188711b19..733d9aadd5c 100644 --- a/packages/paperclip-runner/src/live/evidence-redaction.ts +++ b/packages/paperclip-runner/src/live/evidence-redaction.ts @@ -70,7 +70,24 @@ const PROVIDER_EVENT_CATEGORIES: Readonly> = 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> = Object.freeze({ + commandExecution: "command", + fileChange: "file_change", + reasoning: "reasoning", + plan: "plan", + mcpToolCall: "tool", + dynamicToolCall: "tool", +}); + +export function capabilityProviderEventCategory( + method: string, + params?: Record, +): 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 { 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. diff --git a/packages/paperclip-runner/src/live/live-session.ts b/packages/paperclip-runner/src/live/live-session.ts index 449ac722895..c8bfabb36e3 100644 --- a/packages/paperclip-runner/src/live/live-session.ts +++ b/packages/paperclip-runner/src/live/live-session.ts @@ -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