Files
PaperClipAI/packages/paperclip-runner/devtools/issue-thread/src/App.tsx
T
Dotta 560e7e48b5 feat(runner): add SDK and developer tooling (#12608)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The runner package already provides the production protocol and
execution spine.
> - Contributors still need stable SDK surfaces, deterministic test
tools, and local inspection tools.
> - Those surfaces share generated contracts and must change as one
package boundary.
> - This pull request adds the package-local SDK, labs, examples, and
drift checks.
> - The benefit is a reviewable developer platform that does not change
application execution selection.

## Linked Issues or Issue Description

**Subsystem affected**

`packages/paperclip-runner` — runner SDK, conformance tools, and
developer tooling.

**Problem or motivation**

The production runner spine is present, but package consumers cannot
build deterministic integrations, inspect sessions, or verify
provider-neutral behavior through supported surfaces.

**Proposed solution**

Add browser, React, standalone, live-session, scenario, conformance, and
evaluation surfaces. Add generated contract inventories and
package-local verification scripts. Keep production application routing
unchanged.

**Alternatives considered**

We considered splitting each generated catalog, SDK surface, and demo
into separate pull requests. Those changes share exports, fixtures, and
drift gates. Splitting them would create intermediate package states
that do not build.

**Roadmap alignment**

No overlapping item appears in `ROADMAP.md`. This work extends the
runner package that is already on `master`.

## What Changed

- Add browser, React, standalone, live-session, and issue-thread SDK
surfaces.
- Add deterministic mock control-plane, scenario, conformance, replay,
and evaluation tools.
- Add bounded Codex, OpenCode, and ACPX development transports and
fixtures.
- Keep deferred managed-provider execution fail-closed. Persisted
compatibility data remains readable.
- Add generated capability inventories with their source files and drift
checks.
- Add examples, package documentation, browser checks, and
clean-consumer checks.
- Preserve the reviewed protocol bounds, replay compatibility aliases,
process environment isolation, and semantic redaction limits.
- Update the ACPX package patch that the existing workspace patch
registry already tracks.
- Do not change `pnpm-lock.yaml`, repository workflows, server runtime
selection, or the application UI.

## Verification

GitHub Actions is the verification authority for this pull request. The
repository CI, package TypeScript and Rust checks, package tests,
generated-output drift checks, browser checks, security scans, and
Greptile review must pass on the exact head.

Local test suites were not run because this series uses parallel GitHub
Actions for verification.

## Risks

This is a large greenfield package change. The main risks are public
export drift, generated-output drift, and optional React consumer
compatibility. Package boundary checks, clean-consumer checks, and
browser tests cover those risks. Production adapter selection and server
execution are outside this pull request.

## Stack

1. **This PR:** runner SDK and developer tooling.
2. [Codex production server
integration](https://github.com/paperclipai/paperclip/pull/12616).
3. [Provider-neutral task-thread
UI](https://github.com/paperclipai/paperclip/pull/12617).

## Model Used

OpenAI Codex, GPT-5, high-reasoning mode, with tool use and code
execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have described the issue in-PR following the feature request
template
- [x] I have not referenced internal/instance-local Paperclip issues or
links
- [x] My branch name describes the change and contains no internal
Paperclip ticket id
- [ ] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge
2026-08-31 21:33:11 -05:00

1590 lines
63 KiB
TypeScript

import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import {
CAPABILITY_DEFAULT_FIXTURE_PROFILE,
capabilityIssueThreadFixture,
} from "../../../src/issue-thread/fixtures";
import type {
CapabilityEvidenceSectionId,
CapabilityIssueThreadSnapshot,
} from "../../../src/issue-thread/types";
import type { CapabilityDevtoolsSnapshot } from "../../../src/devtools";
import { capabilityDenialCount } from "../../../src/issue-thread/types";
import { Composer } from "./Composer";
import { EvidencePanel } from "./EvidencePanel";
import { Icon } from "./Icons";
import { IssueHeader } from "./IssueHeader";
import { applyFakeInteractionResponse } from "./fake-store";
import type { CapabilityInteractionResponse } from "./InteractionCard";
import {
capabilityLiveClient,
recallSession,
rememberSession,
type CapabilityCleanRoomIdentity,
type CapabilityHarnessConfiguration,
} from "./live-client";
import { parseCapabilityRoute, capabilityRouteHref, type CapabilityRoute } from "./route";
import { SurfaceNav, type CapabilityChatHistoryItem } from "./SurfaceNav";
import { EvalAssertions, TurnGroup, type EvalAssertion } from "./ThreadItems";
const PANEL_OPEN_KEY = "paperclip-runner.capability.panel.open";
/**
* The clean room keeps its own panel preference. Evidence is collapsed by
* default there on purpose (revision 5: "the default view reads as plain
* chat"), and inheriting an explorer session's opened drawer would quietly
* break that on the very first visit.
*/
const CHAT_PANEL_OPEN_KEY = "paperclip-runner.capability.chat.panel.open";
const PANEL_WIDTH_KEY = "paperclip-runner.capability.panel.width";
const PANEL_MIN = 320;
const PANEL_MAX = 960;
/** Play-all cadence for the replay strip (§6); slow enough to read a turn. */
const REPLAY_STEP_MS = 800;
/**
* The pending-request guidance is a constant because the settle path has to
* recognise its own stale announcement: a screen reader must not still be told
* a request is waiting after it was answered (TASK-16978).
*/
const PENDING_ANNOUNCEMENT = "A request is waiting for your answer.";
const ANSWERED_ANNOUNCEMENT = "Your answer was recorded.";
const SETTLED_ANNOUNCEMENT = "The pending request is resolved.";
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts", "wc-workspace-changes", "fr-file-reference", "pe-structured-plan", "te-command-execution", "mp-mcp-progress", "rs-web-research", "da-subagent-delegation", "mr-model-routing", "cc-context-compaction", "ag-generated-artifact", "rv-review-mode", "hk-hook-lifecycle", "mc-memory-citations", "sr-safety-review", "ti-terminal-input", "wt-intentional-wait", "pn-provider-notices", "ax-acpx-lifecycle", "ax-acpx-permissions", "ax-acpx-tools", "ax-acpx-events", "ax-acpx-failures"];
const CHAT_HISTORY_KEY = "paperclip-runner.capability.chat.history.v1";
const CHAT_HARNESS_KEY = "paperclip-runner.capability.chat.harness.v1";
const MODEL_PRESETS = {
codex: ["gpt-5.4-mini", "gpt-5.4"],
opencode: ["openrouter/deepseek/deepseek-v4-flash-0731"],
acpx: ["openrouter/deepseek/deepseek-v4-flash-0731", "claude-sonnet-5", "gpt-5.6-sol"],
} as const;
const ACPX_AGENT_MODELS = {
claude: "claude-sonnet-5",
codex: "gpt-5.6-sol",
} as const;
function acpxLabel(configuration: CapabilityHarnessConfiguration | null | undefined): string {
if (configuration?.provider !== "acpx") return configuration?.provider ?? "starting";
const agent = configuration.acpxAgent ?? "codex";
return `Real ${agent === "claude" ? "Claude" : "Codex"} via ACPX`;
}
interface EmbeddedEvalCheck {
id: string;
title: string;
description: string;
passed: boolean;
detail: string;
definition: Record<string, unknown>;
anchor: { kind: "item" | "turn" | "run"; id: string };
}
interface EmbeddedEvalReport {
attemptId: string;
caseId: string;
disposition: string;
passed: boolean;
checks: EmbeddedEvalCheck[];
run: {
model: string;
provider: string;
driver: string;
providerVersion: string | null;
runnerProvider: string;
configuration: string;
sessionId: string;
providerSessionId: string | null;
agentVersion: string | null;
retainedSession: boolean | null;
retainedSessionStatus: string | null;
fixtureDigest: string;
runnerPackageDigest: string;
runnerdDigest: string;
runnerBuild: string;
startedAt: string;
finishedAt: string;
durationMs: number;
initialRevision: number;
finalRevision: number;
usage: {
agentTurns: number;
providerRequests: number | null;
inputTokens: number;
outputTokens: number;
cachedInputTokens: number;
reasoningTokens: number;
providerReportedCostNanodollars?: number;
estimatedCostNanodollars: number;
pricingVersion: string;
} | null;
};
view: CapabilityIssueThreadSnapshot;
devtools: CapabilityDevtoolsSnapshot;
navigation: { suiteHref: string; previous: { label: string; href: string } | null; next: { label: string; href: string } | null };
}
declare global {
interface Window { __PAPERCLIP_EVAL_REPORT__?: EmbeddedEvalReport }
}
interface StoredChatSession {
sessionId: string;
snapshot: CapabilityIssueThreadSnapshot;
identity: CapabilityCleanRoomIdentity | null;
updatedAt: string;
configuration?: CapabilityHarnessConfiguration;
runtime?: Awaited<ReturnType<typeof capabilityLiveClient.newCleanRoom>>["runtime"];
}
function defaultHarness(provider: CapabilityHarnessConfiguration["provider"] = "codex"): CapabilityHarnessConfiguration {
return {
provider,
model: MODEL_PRESETS[provider][0],
...(provider === "acpx" ? { acpxAgent: "codex" as const } : {}),
lifecyclePolicy: { mode: "warm", idleTimeoutMs: 300_000 },
};
}
function readHarnessConfiguration(): CapabilityHarnessConfiguration {
try {
const value = JSON.parse(window.localStorage.getItem(CHAT_HARNESS_KEY) ?? "null") as Partial<CapabilityHarnessConfiguration> | null;
if (value && (value.provider === "codex" || value.provider === "opencode" || value.provider === "acpx") && typeof value.model === "string") {
const lifecyclePolicy = value.lifecyclePolicy?.mode === "per_turn"
? { mode: "per_turn" as const, idleTimeoutMs: null }
: value.lifecyclePolicy?.mode === "warm" && Number.isSafeInteger(value.lifecyclePolicy.idleTimeoutMs) && Number(value.lifecyclePolicy.idleTimeoutMs) > 0
? { mode: "warm" as const, idleTimeoutMs: Number(value.lifecyclePolicy.idleTimeoutMs) }
: { mode: "warm" as const, idleTimeoutMs: 300_000 };
return {
provider: value.provider,
model: value.model,
...(value.provider === "acpx" ? {
acpxAgent: value.acpxAgent === "claude" ? value.acpxAgent : "codex",
} : {}),
lifecyclePolicy,
};
}
} catch {
// Invalid preferences fall back to the qualified defaults.
}
return defaultHarness();
}
function persistHarnessConfiguration(configuration: CapabilityHarnessConfiguration): void {
try {
window.localStorage.setItem(CHAT_HARNESS_KEY, JSON.stringify(configuration));
} catch {
// Provider/model preference is convenient but not authority-bearing.
}
}
function readChatHistory(): StoredChatSession[] {
try {
const parsed = JSON.parse(window.localStorage.getItem(CHAT_HISTORY_KEY) ?? "[]") as unknown;
if (!Array.isArray(parsed)) return [];
return parsed.filter((item): item is StoredChatSession =>
typeof item === "object" && item !== null &&
typeof (item as StoredChatSession).sessionId === "string" &&
typeof (item as StoredChatSession).updatedAt === "string" &&
typeof (item as StoredChatSession).snapshot === "object",
).slice(0, 12);
} catch {
return [];
}
}
function persistChatHistory(history: StoredChatSession[]): void {
try {
window.localStorage.setItem(CHAT_HISTORY_KEY, JSON.stringify(history.slice(0, 12)));
} catch {
// History is a convenience; a blocked or full storage quota must not stop chat.
}
}
function readStoredNumber(key: string, fallback: number): number {
try {
const raw = window.localStorage.getItem(key);
const parsed = raw === null ? Number.NaN : Number.parseInt(raw, 10);
return Number.isFinite(parsed) ? Math.min(PANEL_MAX, Math.max(PANEL_MIN, parsed)) : fallback;
} catch {
return fallback;
}
}
function readStoredFlag(key: string, fallback: boolean): boolean {
try {
const raw = window.localStorage.getItem(key);
return raw === null ? fallback : raw === "true";
} catch {
return fallback;
}
}
/**
* Scroll an Evidence target into the panel viewport. `start` alignment has to
* account for the sticky panel head, which would otherwise sit on top of the
* section header the deep link was supposed to reveal.
*/
function scrollEvidenceIntoView(target: Element, block: "start" | "center"): void {
target.scrollIntoView({ block });
if (block !== "start") return;
const panel = target.closest<HTMLElement>(".pit-panel");
const head = panel?.querySelector<HTMLElement>(".pit-panel-chrome");
if (panel == null || head == null) return;
panel.scrollTop = Math.max(0, panel.scrollTop - head.offsetHeight);
}
function describe(cause: unknown): string {
return String(cause instanceof Error ? cause.message : cause);
}
/** Resolution copy for a request that settled without this client answering it. */
function settledAnnouncement(snapshot: CapabilityIssueThreadSnapshot, interactionId: string): string {
for (const turn of snapshot.turns) {
for (const item of turn.items) {
if (item.kind === "interaction" && item.interactionId === interactionId) {
return `${SETTLED_ANNOUNCEMENT} ${item.stateLabel}.`;
}
}
}
return SETTLED_ANNOUNCEMENT;
}
function currentTurnActivity(snapshot: CapabilityIssueThreadSnapshot): string {
const harness = snapshot.identity.agentLabel.replace(/^Real /, "");
const turn = snapshot.turns.at(-1);
if (turn === undefined) return `Dispatching turn to ${harness}`;
const activity = [...turn.items].reverse().find((item) =>
item.kind === "tool_activity" || item.kind === "progress_activity" || item.kind === "provider_activity" ||
(item.kind === "agent_message" && item.streaming),
);
if (activity?.kind === "tool_activity") {
return activity.status === "running"
? `Paperclip tool · ${activity.operationId}`
: `Paperclip tool completed · ${activity.operationId}`;
}
if (activity?.kind === "progress_activity") return activity.summary;
if (activity?.kind === "agent_message") return `Receiving ${harness} response`;
return `Waiting for ${harness} activity`;
}
function useRoute(): CapabilityRoute {
const [route, setRoute] = useState(() => parseCapabilityRoute(window.location));
useEffect(() => {
const onChange = () => setRoute(parseCapabilityRoute(window.location));
window.addEventListener("hashchange", onChange);
window.addEventListener("popstate", onChange);
return () => {
window.removeEventListener("hashchange", onChange);
window.removeEventListener("popstate", onChange);
};
}, []);
return route;
}
function useLayout(): "side" | "overlay" | "segment" {
const [layout, setLayout] = useState<"side" | "overlay" | "segment">(() =>
window.innerWidth <= 767 ? "segment" : window.innerWidth <= 1100 ? "overlay" : "side",
);
useEffect(() => {
const onResize = () =>
setLayout(window.innerWidth <= 767 ? "segment" : window.innerWidth <= 1100 ? "overlay" : "side");
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return layout;
}
export function App() {
const embeddedEval = window.__PAPERCLIP_EVAL_REPORT__ ?? null;
const route = useRoute();
const chat = route.surface === "chat";
const layout = useLayout();
useEffect(() => {
if (embeddedEval !== null) {
document.title = `${embeddedEval.passed ? "✓" : "✕"} ${embeddedEval.caseId} · paperclip-runner eval`;
return;
}
document.title = chat
? "🫧 Mock Paperclip · Issue thread"
: "🧯 Mock Paperclip · Issue thread";
}, [chat, embeddedEval]);
const [snapshot, setSnapshot] = useState<CapabilityIssueThreadSnapshot | null>(null);
const [devtools, setDevtools] = useState<CapabilityDevtoolsSnapshot | null>(null);
const [identity, setIdentity] = useState<CapabilityCleanRoomIdentity | null>(null);
const initialHarnessRef = useRef<CapabilityHarnessConfiguration>(readHarnessConfiguration());
const [harness, setHarness] = useState<CapabilityHarnessConfiguration>(initialHarnessRef.current);
const [activeHarness, setActiveHarness] = useState<CapabilityHarnessConfiguration | null>(null);
const [providerRuntime, setProviderRuntime] = useState<Awaited<ReturnType<typeof capabilityLiveClient.newCleanRoom>>["runtime"]>(undefined);
const [chatHistory, setChatHistory] = useState<StoredChatSession[]>(readChatHistory);
const [historicSessionId, setHistoricSessionId] = useState<string | null>(null);
const liveRoomRef = useRef<StoredChatSession | null>(null);
/**
* Which surface produced `snapshot`. A hash change commits the new route in
* the same frame that still renders the old snapshot, so without this a
* capture or a test can catch one frame of "settled" that belongs to the
* surface it just navigated away from.
*/
const [snapshotSurface, setSnapshotSurface] = useState(route.surface);
const [error, setError] = useState<string | null>(null);
const [actionError, setActionError] = useState<string | null>(null);
const [loadNonce, setLoadNonce] = useState(0);
const [settled, setSettled] = useState(false);
const [panelOpen, setPanelOpen] = useState(() => embeddedEval !== null ||
readStoredFlag(route.surface === "chat" ? CHAT_PANEL_OPEN_KEY : PANEL_OPEN_KEY, false));
const [panelWidth, setPanelWidth] = useState(() => readStoredNumber(PANEL_WIDTH_KEY, 384));
const [segment, setSegment] = useState<"thread" | "evidence">(route.segment);
const [openSections, setOpenSections] = useState<CapabilityEvidenceSectionId[]>(["tools"]);
const [selectedTurnId, setSelectedTurnId] = useState<string | "all">("all");
const [highlightedRecordId, setHighlightedRecordId] = useState<string | null>(route.record);
const [focusInteractionId, setFocusInteractionId] = useState<string | null>(null);
const [confirmReset, setConfirmReset] = useState(false);
const [announcement, setAnnouncement] = useState("");
const [showJump, setShowJump] = useState(false);
const [reconnected, setReconnected] = useState(false);
const [playing, setPlaying] = useState(false);
/** True while a turn stream is open, so the surface never reads as settled. */
const [streamingTurn, setStreamingTurn] = useState(false);
const scrollRef = useRef<HTMLDivElement | null>(null);
const cancelResetRef = useRef<HTMLButtonElement | null>(null);
/** Last announced pending request, so the live region tracks transitions. */
const announcedPendingRef = useRef<string | null>(null);
/**
* Turn generation. Reset, `New chat`, and leaving the surface all bump it, so
* a frame from a turn that has since been abandoned is dropped instead of
* appended to a thread it no longer belongs to.
*/
const turnGenerationRef = useRef(0);
const turnAbortRef = useRef<AbortController | null>(null);
useEffect(() => {
if (embeddedEval !== null) setPanelOpen(true);
}, [embeddedEval]);
/** Abandons any open turn stream: the server sees the disconnect and stops. */
const abandonTurn = useCallback(() => {
turnGenerationRef.current += 1;
const controller = turnAbortRef.current;
turnAbortRef.current = null;
setStreamingTurn(false);
controller?.abort();
}, []);
/* --------------------------------------------------------------- loading */
useEffect(() => {
let cancelled = false;
setSettled(false);
setError(null);
if (embeddedEval !== null) {
setIdentity(null);
setSnapshot({
...embeddedEval.view,
composer: { state: "disabled", helper: null, reason: "Immutable eval recording", pendingInteractionId: null },
});
setDevtools(embeddedEval.devtools);
setSnapshotSurface("issue");
} else if (chat) {
// The clean room has no fixture fallback: if real Codex and real runnerd
// cannot start, the surface says so rather than rendering a canned thread.
void (async () => {
try {
const response = await capabilityLiveClient.loadCleanRoom(
recallSession("cleanroom"),
initialHarnessRef.current,
);
if (cancelled) return;
rememberSession(response.sessionId, "cleanroom");
setIdentity(response.identity ?? null);
const configuration = response.configuration ?? initialHarnessRef.current;
setActiveHarness(configuration);
// The server-returned immutable session configuration is authoritative
// for both the active status and the options initially shown for the
// next chat after a restore.
setHarness(configuration);
setProviderRuntime(response.runtime);
setHistoricSessionId(null);
setSnapshot(response.view);
setSnapshotSurface("chat");
} catch (cause) {
if (!cancelled) setError(String(cause instanceof Error ? cause.message : cause));
}
})();
} else if (route.mode === "live") {
void (async () => {
try {
const response = await capabilityLiveClient.load(recallSession());
if (cancelled) return;
rememberSession(response.sessionId);
setSnapshot(response.view);
setSnapshotSurface("issue");
} catch (cause) {
if (!cancelled) setError(String(cause instanceof Error ? cause.message : cause));
}
})();
} else {
setIdentity(null);
setSnapshot(capabilityIssueThreadFixture(route.shot ?? "thread-baseline", route.fixtureProfile));
setSnapshotSurface("issue");
}
return () => {
cancelled = true;
// Leaving the surface (or reloading it) must not leave a turn streaming
// into a thread that is no longer on screen.
abandonTurn();
};
}, [abandonTurn, chat, embeddedEval, route.mode, route.shot, route.fixtureProfile, loadNonce]);
/* ------------------------------------------------------- deep-link params */
useEffect(() => {
if (route.panel === null) return;
setPanelOpen(true);
setSegment("evidence");
setOpenSections((current) =>
current.includes(route.panel as CapabilityEvidenceSectionId)
? current
: [...current, route.panel as CapabilityEvidenceSectionId],
);
setHighlightedRecordId(route.record);
}, [route.panel, route.record]);
useLayoutEffect(() => {
// A deep link has to land on what it addressed. With `rec` that is the
// record; without it, the opened section's header — otherwise the link
// half-arrives with the section still below the fold. Wait for the bundled
// faces before measuring: a host fallback can otherwise move the retained
// scroll position by a pixel even after the final webfonts replace it.
if (route.panel === null || snapshot === null) return;
const panel = route.panel;
const record = route.record;
let cancelled = false;
void (async () => {
if (typeof document.fonts?.ready?.then === "function") {
await document.fonts.ready;
}
if (cancelled) return;
const target =
record === null
? document.querySelector(`[data-evidence-section="${CSS.escape(panel)}"]`)
: document.querySelector(`[data-record-id="${CSS.escape(record)}"]`);
if (target !== null) {
scrollEvidenceIntoView(target, record === null ? "start" : "center");
}
})();
return () => {
cancelled = true;
};
}, [route.panel, route.record, snapshot, panelOpen, segment, openSections]);
useEffect(() => {
setSegment(route.segment);
}, [route.segment]);
/* -------------------------------------------------- settle + auto-follow */
useEffect(() => {
if (snapshot === null) return;
let cancelled = false;
const scroller = scrollRef.current;
void (async () => {
if (typeof document.fonts?.ready?.then === "function") {
await document.fonts.ready;
}
if (cancelled) return;
if (scroller !== null) scroller.scrollTop = scroller.scrollHeight;
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
if (!cancelled) setSettled(true);
})();
return () => {
cancelled = true;
};
}, [snapshot]);
useEffect(() => {
if (!panelOpen || snapshot === null || route.mode !== "live") return;
if (historicSessionId !== null) return;
let cancelled = false;
void capabilityLiveClient.devtools(snapshot.sessionId)
.then((next) => { if (!cancelled) setDevtools(next); })
.catch((cause) => { if (!cancelled) setActionError(describe(cause)); });
return () => { cancelled = true; };
}, [historicSessionId, panelOpen, route.mode, snapshot?.renderedAt, snapshot?.sessionId]);
useEffect(() => {
if (!chat || snapshot === null || historicSessionId !== null) return;
const record: StoredChatSession = {
sessionId: snapshot.sessionId,
snapshot,
identity,
updatedAt: snapshot.renderedAt,
...(activeHarness === null ? {} : { configuration: activeHarness }),
...(providerRuntime === undefined ? {} : { runtime: providerRuntime }),
};
liveRoomRef.current = record;
setChatHistory((current) => {
const next = [record, ...current.filter((item) => item.sessionId !== record.sessionId)].slice(0, 12);
persistChatHistory(next);
return next;
});
}, [activeHarness, chat, historicSessionId, identity, providerRuntime, snapshot?.renderedAt, snapshot?.sessionId]);
useEffect(() => {
if (snapshot === null) return;
const pending = snapshot.composer.pendingInteractionId;
const announced = announcedPendingRef.current;
announcedPendingRef.current = pending;
if (pending !== null) {
// Announce a request once, when it arrives. Re-announcing on every
// snapshot would talk over the outcome the user just triggered: a live
// submit re-renders with the same request still pending.
if (pending !== announced) setAnnouncement(PENDING_ANNOUNCEMENT);
return;
}
if (snapshot.connection.state === "reconnecting") {
setAnnouncement(`Connection lost — retrying (attempt ${snapshot.connection.attempt}).`);
return;
}
if (announced === null) return;
// The request settled. Anything the resolution itself announced stands;
// only the now-false "still waiting" guidance is replaced, so a screen
// reader never carries pending-state copy into later turns (TASK-16978).
setAnnouncement((current) =>
current === PENDING_ANNOUNCEMENT ? settledAnnouncement(snapshot, announced) : current,
);
}, [snapshot]);
/* -------------------------------------------------------------- handlers */
const persistPanel = useCallback(
(open: boolean, width: number) => {
try {
window.localStorage.setItem(chat ? CHAT_PANEL_OPEN_KEY : PANEL_OPEN_KEY, String(open));
window.localStorage.setItem(PANEL_WIDTH_KEY, String(width));
} catch {
// Panel preference is a convenience, never a correctness requirement.
}
},
[chat],
);
const openEvidence = useCallback(
(section: CapabilityEvidenceSectionId, recordId: string) => {
setPanelOpen(true);
setSegment("evidence");
setSelectedTurnId("all");
setOpenSections((current) => (current.includes(section) ? current : [...current, section]));
setHighlightedRecordId(recordId);
persistPanel(true, panelWidth);
window.setTimeout(() => {
document
.querySelector(`[data-record-id="${CSS.escape(recordId)}"]`)
?.scrollIntoView({ block: "center" });
}, 0);
},
[panelWidth, persistPanel],
);
const closeEvidence = useCallback(() => {
setPanelOpen(false);
persistPanel(false, panelWidth);
if (layout === "segment") setSegment("thread");
// Focus came into the panel when it opened (§9.2), so hand it back to the
// visible control that owns the panel rather than dropping it on `body`.
window.setTimeout(() => {
const candidates = ['[data-testid="evidence-toggle"]', '[data-testid="segment-thread"]'];
for (const selector of candidates) {
const control = document.querySelector<HTMLElement>(selector);
if (control !== null && control.offsetParent !== null) {
control.focus();
return;
}
}
}, 0);
}, [layout, panelWidth, persistPanel]);
const jumpToThread = useCallback((anchorId: string) => {
setSegment("thread");
const anchor = document.getElementById(anchorId) ?? document.querySelector(`#${CSS.escape(anchorId)}`);
anchor?.scrollIntoView({ block: "center" });
}, []);
const respond = useCallback(
(response: CapabilityInteractionResponse) => {
if (snapshot === null) return;
if (route.mode !== "live") {
setSnapshot(applyFakeInteractionResponse(snapshot, response));
setAnnouncement(ANSWERED_ANNOUNCEMENT);
return;
}
// Network effects must never live inside a React state updater. Strict
// Mode intentionally invokes updater functions more than once in
// development, which previously submitted every interaction twice: the
// first request resolved it and the duplicate produced "not pending".
const sessionId = snapshot.sessionId;
setActionError(null);
setSnapshot((current) => current === null ? current : {
...current,
turns: current.turns.map((turn) => ({
...turn,
items: turn.items.map((item) =>
item.kind === "interaction" && item.interactionId === response.interactionId
? { ...item, state: "submitting" as const, stateLabel: "Submitting…" }
: item,
),
})),
});
void capabilityLiveClient
.respond(sessionId, response.interactionId, response.outcome, response.result)
.then((next) => {
setSnapshot(next.view);
setAnnouncement(ANSWERED_ANNOUNCEMENT);
})
.catch((cause) => {
setActionError(describe(cause));
setSnapshot((current) => current === null ? current : {
...current,
turns: current.turns.map((turn) => ({
...turn,
items: turn.items.map((item) =>
item.kind === "interaction" &&
item.interactionId === response.interactionId &&
item.state === "submitting"
? { ...item, state: "pending" as const, stateLabel: "Waiting for you" }
: item,
),
})),
});
});
},
[route.mode, snapshot],
);
/**
* Sends one message and renders the turn as it arrives.
*
* Each frame is a whole server projection, so the surface still never patches
* state locally — it just gets more than one projection per turn. The settled
* payload is applied last and stays the authority.
*/
const send = useCallback(
(message: string) => {
if (snapshot === null) return;
if (route.mode !== "live") return;
setActionError(null);
abandonTurn();
const generation = turnGenerationRef.current;
const controller = new AbortController();
turnAbortRef.current = controller;
setStreamingTurn(true);
setSnapshot((current) =>
current === null ? current : { ...current, composer: { ...current.composer, state: "sending" } },
);
void capabilityLiveClient
.send(snapshot.sessionId, message, {
signal: controller.signal,
onFrame: (view) => {
if (turnGenerationRef.current === generation) setSnapshot(view);
},
})
.then((next) => {
if (turnGenerationRef.current === generation) {
setSnapshot(next.view);
setProviderRuntime(next.runtime);
}
})
.catch((cause) => {
if (turnGenerationRef.current !== generation || controller.signal.aborted) return;
setActionError(describe(cause));
// A refused turn must not strand the composer in `sending`.
setSnapshot((stale) =>
stale === null ? stale : { ...stale, composer: { ...stale.composer, state: "ready" } },
);
})
.finally(() => {
if (turnGenerationRef.current !== generation) return;
turnAbortRef.current = null;
setStreamingTurn(false);
});
},
[abandonTurn, route.mode, snapshot],
);
const stop = useCallback(() => {
setSnapshot((current) => {
if (current === null) return current;
if (route.mode === "live") {
// Stop reaches the provider interrupt through its own request; the open
// turn stream keeps rendering and its terminal frame — not this
// response — decides what the stopped turn finally looks like.
const streaming = turnAbortRef.current !== null;
void capabilityLiveClient
.stop(current.sessionId)
.then((next) => {
if (!streaming && turnAbortRef.current === null) setSnapshot(next.view);
})
.catch((cause) => setActionError(describe(cause)));
return current;
}
const turns = current.turns.map((turn, index) =>
index === current.turns.length - 1 ? { ...turn, stoppedByUser: true } : turn,
);
return {
...current,
turns,
composer: { state: "ready", helper: null, reason: null, pendingInteractionId: null },
};
});
setAnnouncement("Turn stopped. Partial output is preserved.");
}, [route.mode]);
/**
* Adopts a rotated clean room. Reset and `New chat` both land here: the
* server always answers with a new session id and new mock identities, so the
* client's job is only to forget the old ones.
*/
const adoptCleanRoom = useCallback((next: Awaited<ReturnType<typeof capabilityLiveClient.newCleanRoom>>) => {
rememberSession(next.sessionId, "cleanroom");
setIdentity(next.identity ?? null);
const configuration = next.configuration ?? harness;
setActiveHarness(configuration);
setHarness(configuration);
setProviderRuntime(next.runtime);
setHistoricSessionId(null);
setSnapshot(next.view);
setDevtools(null);
setError(null);
setActionError(null);
setAnnouncement(
`New clean-room chat started on ${next.view.issue.identifier}. The previous session was closed.`,
);
}, [harness]);
const newChat = useCallback(() => {
const model = harness.model?.trim() ?? "";
if (!model) {
setActionError("Choose or enter a model before starting a new chat.");
return;
}
if (harness.provider === "opencode" && !model.includes("/")) {
setActionError("OpenCode models must use provider/model form.");
return;
}
if (harness.provider === "acpx") {
const agent = harness.acpxAgent ?? "codex";
if (model !== ACPX_AGENT_MODELS[agent]) {
setActionError(`The qualified ACPX ${agent} profile requires exact model ${ACPX_AGENT_MODELS[agent]}.`);
return;
}
}
const configuration = { ...harness, model };
persistHarnessConfiguration(configuration);
setConfirmReset(false);
// The room this turn belongs to is about to be retired, so the stream is
// dropped before the request that retires it.
abandonTurn();
setAnnouncement("Starting a new clean-room chat…");
void capabilityLiveClient
.newCleanRoom(liveRoomRef.current?.sessionId ?? snapshot?.sessionId ?? null, configuration)
.then(adoptCleanRoom)
.catch((cause) => setActionError(describe(cause)));
}, [abandonTurn, adoptCleanRoom, harness, snapshot]);
const selectChatHistory = useCallback((sessionId: string) => {
const live = liveRoomRef.current;
if (live?.sessionId === sessionId) {
setSnapshot(live.snapshot);
setIdentity(live.identity);
setActiveHarness(live.configuration ?? null);
if (live.configuration !== undefined) setHarness(live.configuration);
setProviderRuntime(live.runtime);
setDevtools(null);
setHistoricSessionId(null);
setActionError(null);
return;
}
const archived = chatHistory.find((item) => item.sessionId === sessionId);
if (archived === undefined) return;
abandonTurn();
const configuration = archived.configuration ?? defaultHarness();
void capabilityLiveClient.loadCleanRoom(sessionId, configuration)
.then((restored) => {
rememberSession(restored.sessionId, "cleanroom");
setSnapshot(restored.view);
setIdentity(restored.identity ?? archived.identity);
setActiveHarness(restored.configuration ?? configuration);
setHarness(restored.configuration ?? configuration);
setProviderRuntime(restored.runtime);
setDevtools(null);
setHistoricSessionId(null);
setActionError(null);
setAnnouncement("Archived chat restored. The runner will resume when you send a message.");
})
.catch((cause) => setActionError(describe(cause)));
}, [abandonTurn, chatHistory]);
const reset = useCallback(() => {
setConfirmReset(false);
abandonTurn();
if (chat && snapshot !== null) {
void capabilityLiveClient
.reset(snapshot.sessionId)
.then(adoptCleanRoom)
.catch((cause) => setActionError(describe(cause)));
return;
}
if (route.mode === "live" && snapshot !== null) {
void capabilityLiveClient
.reset(snapshot.sessionId)
.then((next) => {
rememberSession(next.sessionId);
setSnapshot(next.view);
})
.catch((cause) => setActionError(describe(cause)));
return;
}
setSnapshot(capabilityIssueThreadFixture("thread-baseline", route.fixtureProfile));
setAnnouncement("Scenario reset. The mock state is back to its clean seed.");
}, [abandonTurn, adoptCleanRoom, chat, route.fixtureProfile, route.mode, snapshot]);
const retry = useCallback(() => {
if (route.mode === "live" && snapshot !== null) {
void capabilityLiveClient
.reconnect(snapshot.sessionId)
.then((next) => setSnapshot(next.view))
.catch((cause) => setActionError(describe(cause)));
return;
}
setSnapshot((current) =>
current === null
? current
: {
...current,
connection: { state: "connected", attempt: 0 },
composer: { state: "ready", helper: null, reason: null, pendingInteractionId: null },
},
);
setReconnected(true);
window.setTimeout(() => setReconnected(false), 3_000);
}, [route.mode, snapshot]);
useEffect(() => {
if (!confirmReset) return;
cancelResetRef.current?.focus();
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setConfirmReset(false);
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [confirmReset]);
/* ----------------------------------------------------------- replay (§6) */
// `?at=<ordinal>` is the source of truth for where the recording is parked,
// so step / next-turn / play-all and the deep link all move the same value.
const replay = useMemo(() => {
if (snapshot === null || snapshot.replay === null) return null;
const { total } = snapshot.replay;
const ordinal =
route.at === null ? snapshot.replay.ordinal : Math.min(total, Math.max(0, route.at));
return { ordinal, total };
}, [snapshot, route.at]);
const seekReplay = useCallback(
(ordinal: number) => {
setPlaying(false);
window.location.hash = capabilityRouteHref(route, { at: ordinal });
},
[route],
);
useEffect(() => {
if (!playing || replay === null) return;
if (replay.ordinal >= replay.total) {
setPlaying(false);
return;
}
const timer = window.setTimeout(() => {
window.location.hash = capabilityRouteHref(route, { at: replay.ordinal + 1 });
}, REPLAY_STEP_MS);
return () => window.clearTimeout(timer);
}, [playing, replay, route]);
useEffect(() => {
if (replay === null) setPlaying(false);
}, [replay]);
/* ---------------------------------------------------------------- render */
const denialCount = useMemo(
() => (snapshot === null ? 0 : capabilityDenialCount(snapshot.evidence, null)),
[snapshot],
);
const historyItems: CapabilityChatHistoryItem[] = chatHistory.map((item) => ({
sessionId: item.sessionId,
identifier: item.snapshot.issue.identifier,
title: item.snapshot.issue.title,
updatedAt: item.updatedAt,
current: liveRoomRef.current?.sessionId === item.sessionId,
}));
const surfaceNav = (
<SurfaceNav
surface={route.surface}
history={historyItems}
activeSessionId={snapshot?.sessionId ?? null}
onNewChat={chat ? newChat : () => { window.location.hash = "#/chat"; }}
onSelectHistory={selectChatHistory}
/>
);
if (error !== null) {
return (
<div className="pit-app" data-thread-state="failed" data-surface={route.surface}>
{surfaceNav}
<main className="pit-app-error">
<p className="pit-composer-reason" role="alert" data-testid="surface-error">
{chat
? `The clean-room chat could not start the selected provider: ${error}`
: error}
</p>
{chat ? (
<>
<p className="pit-muted">
The clean room only runs against the selected real provider through real runnerd, so it does not fall
back to a fixture or a recording.
</p>
<section className="pit-harness-picker" aria-label="Chat provider recovery options">
<label>
<span>Provider</span>
<select
data-testid="chat-provider"
value={harness.provider}
onChange={(event) => {
const provider = event.target.value as CapabilityHarnessConfiguration["provider"];
setHarness((current) => ({
...defaultHarness(provider),
lifecyclePolicy: current.lifecyclePolicy,
}));
}}
>
<option value="codex">Codex</option>
<option value="opencode">OpenCode</option>
<option value="acpx">ACPX</option>
</select>
</label>
{harness.provider === "acpx" ? (
<label>
<span>ACP agent</span>
<select
data-testid="chat-acpx-agent"
value={harness.acpxAgent ?? "codex"}
onChange={(event) => {
const acpxAgent = event.target.value as NonNullable<CapabilityHarnessConfiguration["acpxAgent"]>;
setHarness((current) => ({
...current,
acpxAgent,
model: ACPX_AGENT_MODELS[acpxAgent],
}));
}}
>
<option value="claude">Claude</option>
<option value="codex">Codex (control)</option>
</select>
</label>
) : null}
<label>
<span>Execution</span>
<select
data-testid="chat-lifecycle-mode"
value={harness.lifecyclePolicy.mode}
onChange={(event) => setHarness((current) => ({
...current,
lifecyclePolicy: event.target.value === "per_turn"
? { mode: "per_turn", idleTimeoutMs: null }
: { mode: "warm", idleTimeoutMs: 300_000 },
}))}
>
<option value="warm">Warm session</option>
<option value="per_turn">Turn by turn</option>
</select>
</label>
{harness.lifecyclePolicy.mode === "warm" ? (
<label>
<span>Idle timeout (seconds)</span>
<input
data-testid="chat-idle-timeout"
type="number"
min={1}
value={Math.round((harness.lifecyclePolicy.idleTimeoutMs ?? 300_000) / 1_000)}
onChange={(event) => setHarness((current) => ({
...current,
lifecyclePolicy: {
mode: "warm",
idleTimeoutMs: Math.max(1, Number(event.target.value)) * 1_000,
},
}))}
/>
</label>
) : null}
<label className="pit-harness-model">
<span>Model</span>
<input
data-testid="chat-model"
list={`chat-recovery-models-${harness.provider}`}
value={harness.model ?? ""}
placeholder={harness.provider === "opencode" ? "provider/model" : "model name"}
onChange={(event) => setHarness((current) => ({ ...current, model: event.target.value }))}
/>
<datalist id={`chat-recovery-models-${harness.provider}`}>
{MODEL_PRESETS[harness.provider].map((model) => <option key={model} value={model} />)}
</datalist>
</label>
<button
type="button"
className="pit-button"
data-variant="primary"
data-testid="chat-apply-harness"
onClick={newChat}
>
Start new chat
</button>
</section>
</>
) : null}
<button
type="button"
className="pit-button"
data-variant="primary"
data-testid="surface-error-retry"
onClick={() => setLoadNonce((current) => current + 1)}
>
Try again
</button>
</main>
</div>
);
}
if (snapshot === null) {
return (
<div className="pit-app" data-thread-state="loading" data-surface={route.surface}>
{surfaceNav}
<main className="pit-app-error">
<p className="pit-muted" role="status" data-testid="surface-loading">
{chat
? "Starting real runnerd and the selected provider for a fresh mock tenant…"
: "Loading…"}
</p>
</main>
</div>
);
}
const showThread = layout !== "segment" || segment === "thread";
const showPanel = layout === "segment" ? segment === "evidence" : panelOpen;
return (
<div
className="pit-app"
data-thread-state={
streamingTurn
? "streaming"
: settled && snapshotSurface === route.surface
? "settled"
: "loading"
}
data-session-mode={snapshot.mode}
data-surface={route.surface}
data-connection-state={snapshot.connection.state}
data-eval-view={embeddedEval !== null}
>
{embeddedEval === null ? surfaceNav : null}
<IssueHeader
snapshot={snapshot}
readOnly={embeddedEval !== null}
scenarios={SCENARIOS}
surface={route.surface}
cleanRoomToken={identity?.token ?? null}
evidenceOpen={panelOpen}
denialCount={denialCount}
segment={segment}
onToggleEvidence={() => {
const next = !panelOpen;
setPanelOpen(next);
persistPanel(next, panelWidth);
if (layout === "segment") setSegment(next ? "evidence" : "thread");
}}
onSelectScenario={(scenario) => {
window.location.hash = capabilityRouteHref(route, { fixtureProfile: scenario });
}}
onReplay={() => {
window.location.hash = capabilityRouteHref(route, {
shot: "replay-mode",
mode: "replay",
at: 12,
});
}}
onReset={() => setConfirmReset(true)}
onStop={stop}
onSelectSegment={setSegment}
/>
{chat && embeddedEval === null ? (
<section className="pit-harness-picker" aria-label="Chat provider and model">
<label>
<span>Provider</span>
<select
data-testid="chat-provider"
value={harness.provider}
disabled={streamingTurn}
onChange={(event) => {
const provider = event.target.value as CapabilityHarnessConfiguration["provider"];
setHarness((current) => ({
...defaultHarness(provider),
lifecyclePolicy: current.lifecyclePolicy,
}));
}}
>
<option value="codex">Codex</option>
<option value="opencode">OpenCode</option>
<option value="acpx">ACPX</option>
</select>
</label>
{harness.provider === "acpx" ? (
<label>
<span>ACP agent</span>
<select
data-testid="chat-acpx-agent"
value={harness.acpxAgent ?? "codex"}
disabled={streamingTurn}
onChange={(event) => {
const acpxAgent = event.target.value as NonNullable<CapabilityHarnessConfiguration["acpxAgent"]>;
setHarness((current) => ({
...current,
acpxAgent,
model: ACPX_AGENT_MODELS[acpxAgent],
}));
}}
>
<option value="claude">Claude</option>
<option value="codex">Codex (control)</option>
</select>
</label>
) : null}
<label>
<span>Execution</span>
<select
data-testid="chat-lifecycle-mode"
value={harness.lifecyclePolicy.mode}
disabled={streamingTurn}
onChange={(event) => setHarness((current) => ({
...current,
lifecyclePolicy: event.target.value === "per_turn"
? { mode: "per_turn", idleTimeoutMs: null }
: { mode: "warm", idleTimeoutMs: 300_000 },
}))}
>
<option value="warm">Warm session</option>
<option value="per_turn">Turn by turn</option>
</select>
</label>
{harness.lifecyclePolicy.mode === "warm" ? (
<label>
<span>Idle timeout (seconds)</span>
<input
data-testid="chat-idle-timeout"
type="number"
min={1}
step={1}
value={Math.round(harness.lifecyclePolicy.idleTimeoutMs / 1_000)}
disabled={streamingTurn}
onChange={(event) => {
const seconds = Math.max(1, Number.parseInt(event.target.value || "1", 10));
setHarness((current) => ({
...current,
lifecyclePolicy: { mode: "warm", idleTimeoutMs: seconds * 1_000 },
}));
}}
/>
</label>
) : null}
<label className="pit-harness-model">
<span>Model</span>
<input
data-testid="chat-model"
list={`chat-models-${harness.provider}`}
value={harness.model ?? ""}
disabled={streamingTurn}
placeholder={harness.provider === "opencode" ? "provider/model" : "model name"}
onChange={(event) => setHarness((current) => ({ ...current, model: event.target.value }))}
/>
<datalist id={`chat-models-${harness.provider}`}>
{MODEL_PRESETS[harness.provider].map((model) => <option key={model} value={model} />)}
</datalist>
</label>
<button
type="button"
className="pit-button"
data-variant="primary"
data-testid="chat-apply-harness"
disabled={streamingTurn || historicSessionId !== null}
onClick={newChat}
>
Start new chat
</button>
<span className="pit-harness-active" data-testid="chat-active-harness">
Active: {acpxLabel(activeHarness)}
{activeHarness?.model ? ` · ${activeHarness.model}` : ""}
{activeHarness ? ` · ${activeHarness.lifecyclePolicy.mode === "warm" ? `warm ${Math.round(activeHarness.lifecyclePolicy.idleTimeoutMs / 1_000)}s` : "turn by turn"}` : ""}
{providerRuntime?.runnerPid ? ` · runner PID ${providerRuntime.runnerPid}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.sidecarPid ? ` · sidecar PID ${providerRuntime.sidecarPid}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.agentPid ? ` · agent PID ${providerRuntime.agentPid}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.driverSessionId ? ` · ACPX record ${providerRuntime.driverSessionId}` : ""}
{providerRuntime?.providerSessionId ? ` · session ${providerRuntime.providerSessionId}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.providerVersion ? ` · ACPX ${providerRuntime.providerVersion}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.agentServerVersion ? ` · agent ${providerRuntime.agentServerVersion}` : ""}
{activeHarness?.provider === "acpx" && providerRuntime?.acpProtocolVersion ? ` · ACP ${providerRuntime.acpProtocolVersion}` : ""}
{providerRuntime?.status ? ` · ${providerRuntime.status}` : ""}
</span>
</section>
) : null}
{embeddedEval !== null ? (
<nav className="pit-eval-nav" aria-label="Eval result navigation">
<a href={embeddedEval.navigation.suiteHref}>← All results</a>
{embeddedEval.navigation.previous ? <a href={embeddedEval.navigation.previous.href}>← {embeddedEval.navigation.previous.label}</a> : <span />}
<strong>{embeddedEval.passed ? "PASS" : "FAIL"} · {embeddedEval.attemptId}</strong>
{embeddedEval.navigation.next ? <a href={embeddedEval.navigation.next.href}>{embeddedEval.navigation.next.label} →</a> : <span />}
</nav>
) : null}
{actionError !== null ? (
<p className="pit-banner" data-tone="danger" role="alert" data-testid="action-error">
<span aria-hidden="true">⚠</span>
{actionError}
<button
type="button"
className="pit-link-button"
onClick={() => setActionError(null)}
>
Dismiss
</button>
</p>
) : null}
{snapshot.connection.state === "reconnecting" ? (
<p className="pit-banner" role="status" data-testid="reconnect-banner">
<span aria-hidden="true">⏳</span>
Connection lost — retrying (attempt {snapshot.connection.attempt})
</p>
) : reconnected ? (
<p className="pit-banner" data-tone="success" role="status">
<span aria-hidden="true">✓</span>
Reconnected
</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>
Replay {replay.ordinal}/{replay.total}
</span>
<div
className="pit-progress"
role="progressbar"
aria-valuemin={0}
aria-valuemax={replay.total}
aria-valuenow={replay.ordinal}
aria-label="Replay progress"
>
<div
className="pit-progress-fill"
style={{ width: `${(replay.ordinal / replay.total) * 100}%` }}
/>
</div>
<button
type="button"
className="pit-button"
disabled={replay.ordinal <= 0}
data-testid="replay-step-back"
onClick={() => seekReplay(replay.ordinal - 1)}
>
Step back
</button>
<button
type="button"
className="pit-button"
disabled={replay.ordinal >= replay.total}
data-testid="replay-next-turn"
onClick={() => seekReplay(replay.ordinal + 1)}
>
Next turn
</button>
<button
type="button"
className="pit-button"
data-variant={playing ? undefined : "primary"}
aria-pressed={playing}
disabled={!playing && replay.ordinal >= replay.total}
data-testid="replay-play-all"
onClick={() => setPlaying((current) => !current)}
>
<span aria-hidden="true">{playing ? "❚❚" : "▶"}</span>
{playing ? "Pause" : "Play all"}
</button>
</div>
) : null}
<div className="pit-body">
<main className="pit-main" hidden={!showThread}>
<div className="pit-main-inner">
<div
className="pit-thread-scroll"
ref={scrollRef}
tabIndex={0}
aria-label="Conversation"
onScroll={(event) => {
const element = event.currentTarget;
const distance =
element.scrollHeight - element.scrollTop - element.clientHeight;
setShowJump(distance > 300);
}}
>
<div className="pit-thread">
{embeddedEval !== null ? (
<div className="pit-eval-boundary" data-phase="execution"><strong>Eval execution</strong></div>
) : null}
{snapshot.turns.length === 0 ? (
<section className="pit-empty-thread" data-testid="clean-room-empty">
<h2>Start a clean-room chat</h2>
<p>
This is a blank thread on a brand-new mock tenant:{" "}
<strong>{snapshot.issue.identifier}</strong> in{" "}
<strong>Mock Paperclip (clean room)</strong>. Nothing has been said, called, or
recorded yet.
</p>
<p className="pit-muted">
Your first message starts a real {snapshot.identity.agentLabel.replace(/^Real /, "")} turn through real runnerd. The agent may
call the semantic tools this session exposes, and every record it creates lands
in the mock control plane only — never a real Paperclip API. Detailed tool,
policy, event, and state evidence stays in the Evidence drawer until you open
it.
</p>
</section>
) : (
snapshot.turns.map((turn) => (
<TurnGroup
key={turn.id}
turn={turn}
callbacks={{
onOpenEvidence: openEvidence,
onRespond: respond,
focusInteractionId,
}}
assertions={Object.fromEntries(embeddedEval?.checks
.filter((check) => check.anchor.kind === "item")
.map((check) => [check.anchor.id, [{ ...check } satisfies EvalAssertion]]) ?? [])}
terminalAssertions={embeddedEval?.checks
.filter((check) => check.anchor.kind === "turn" && check.anchor.id === turn.id)
.map((check) => ({ ...check })) ?? []}
/>
))
)}
{embeddedEval !== null ? (
<div className="pit-eval-boundary" data-phase="post-run">
<strong>Post-run state</strong>
<span>Final mock control-plane revision {embeddedEval.run.finalRevision}</span>
<EvalAssertions assertions={embeddedEval.checks.filter((check) => check.anchor.kind === "run")} />
</div>
) : null}
</div>
</div>
{showJump ? (
<button
type="button"
className="pit-button pit-jump-pill"
onClick={() => {
const element = scrollRef.current;
if (element !== null) element.scrollTop = element.scrollHeight;
setShowJump(false);
}}
>
Jump to latest
</button>
) : null}
</div>
<Composer
model={snapshot.composer}
sessionId={snapshot.sessionId}
onSend={send}
onStop={stop}
onRetry={retry}
onReset={() => (chat ? newChat() : setConfirmReset(true))}
resetLabel={chat ? "New chat" : "Reset scenario"}
onFocusPending={(interactionId) => {
setFocusInteractionId(interactionId);
document
.getElementById(`interaction-${interactionId}`)
?.scrollIntoView({ block: "center" });
}}
/>
</main>
{showPanel && layout === "side" ? (
<div
className="pit-splitter"
role="separator"
aria-orientation="vertical"
aria-label="Resize the evidence panel"
aria-valuemin={PANEL_MIN}
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") {
event.preventDefault();
setPanelWidth((current) => {
const next = Math.min(PANEL_MAX, current + step);
persistPanel(panelOpen, next);
return next;
});
} else if (event.key === "ArrowRight") {
event.preventDefault();
setPanelWidth((current) => {
const next = Math.max(PANEL_MIN, current - step);
persistPanel(panelOpen, next);
return next;
});
}
}}
/>
) : null}
{showPanel ? (
<EvidencePanel
snapshot={snapshot}
evalReport={embeddedEval}
{...(embeddedEval !== null || (route.mode === "live" && historicSessionId === null) ? { devtools } : {})}
onForkRevision={(revision) => {
abandonTurn();
setActionError(null);
void capabilityLiveClient.fork(snapshot.sessionId, revision)
.then((next) => {
rememberSession(next.sessionId, chat ? "cleanroom" : "issue");
setSnapshot(next.view);
setIdentity(next.identity ?? null);
setDevtools(null);
})
.catch((cause) => setActionError(describe(cause)));
}}
layout={layout}
width={panelWidth}
selectedTurnId={selectedTurnId}
openSections={openSections}
highlightedRecordId={highlightedRecordId}
onSelectTurn={setSelectedTurnId}
onToggleSection={(section) =>
setOpenSections((current) =>
current.includes(section)
? current.filter((entry) => entry !== section)
: [...current, section],
)
}
onClose={closeEvidence}
onJumpToThread={jumpToThread}
onInvokeTool={route.mode === "live" && historicSessionId === null ? async (operationId, input) => {
setActionError(null);
const next = await capabilityLiveClient.invokeTool(snapshot.sessionId, operationId, input);
setSnapshot(next.view);
setSelectedTurnId(next.toolTurnId);
return next.toolResult;
} : undefined}
/>
) : null}
</div>
{confirmReset ? (
<div className="pit-dialog-backdrop">
<div
className="pit-dialog"
role="dialog"
aria-modal="true"
aria-labelledby="reset-dialog-title"
data-testid="reset-dialog"
>
<h2 className="pit-dialog-title" id="reset-dialog-title">
{chat ? "Reset this chat?" : "Reset scenario?"}
</h2>
<p>
{chat
? "This stops any active turn, closes this session's authority, and opens a new mock tenant with new identities. The transcript and its mock records will be lost."
: "This clears the mock state and starts a clean session. The transcript will be lost."}
</p>
<div className="pit-button-row">
<button
type="button"
className="pit-button"
ref={cancelResetRef}
onClick={() => setConfirmReset(false)}
>
Cancel
</button>
<button
type="button"
className="pit-button"
data-variant="destructive"
onClick={reset}
data-testid="reset-confirm"
>
{chat ? "Reset chat" : "Reset scenario"}
</button>
</div>
</div>
</div>
) : null}
<p
className="pit-visually-hidden"
role="status"
aria-live="polite"
data-testid="thread-live-region"
>
{announcement}
</p>
</div>
);
}