mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 11:20:42 +01:00
feat(runner): polish live devtools console
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user