+
{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