feat(runner): add the Phase 7 Paperclip-style issue-thread UI

Render the mock Paperclip issue as a native issue thread over one view
contract with two producers: deterministic fake fixtures for the screenshot
matrix and a server-side projection of live runnerd/Codex sessions. The
browser holds no policy, diff, or parity authority.

Add the typed interaction cards for all five request_human_input kinds and
every resolved, rejected, stale, superseded, expired, withdrawn, and closed
state; the six-state composer with draft survival; the eight-section Evidence
panel with bidirectional deep links and a first-class list of the operations
withheld from the agent; desktop, overlay, and mobile-segment layouts; and
refresh, reconnect, stop, reset, and replay behaviours.

Add the loopback session server, the 12-slug x 2-viewport byte-stable
screenshot matrix, a focused browser suite with an axe gate on every
screenshot route, a live runnerd/Codex smoke over the browser's own HTTP
routes, and the browser design-token check the verify script already
referenced but which the restored baseline did not contain.

Record one deviation back into the Phase 7B contract: the expired-family 60%
opacity dim measured 3.16:1 and failed the blocking accessibility gate, so it
is now a recessed surface plus muted text at >= 4.5:1.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-10 07:25:53 +00:00
1 parent a74decea1f
commit 00c73cfff5
68 files changed
+7908 -6

No files matched your search

+1
View File
@@ -1,4 +1,5 @@
/dist-browser/
/dist-issue-thread/
/dist-phase5/
/playwright-report/
/test-results/
+14 -2
View File
@@ -36,8 +36,12 @@ invocation-time dispatcher checks, typed denials, redacted authorization
records, and one provider-neutral contract shared by fake and live Codex
bindings. Phase 7E starts a real package-local runnerd and Codex app-server,
routes allowed semantic calls through the mock `ControlPlanePort`, resumes the
same provider thread, and performs bounded process-group cleanup. The data plane
remains mock-only and package-local.
same provider thread, and performs bounded process-group cleanup. Phase 7G
renders that session as a native Paperclip issue thread: typed interaction
cards, a six-state composer, and an eight-section Evidence panel that shows the
calls, authorization records, control-plane decisions, state diffs, and the
operations deliberately withheld from the agent. The data plane remains
mock-only and package-local.
## Phase 0–5 quick start
@@ -127,6 +131,13 @@ and JSON content; see the protocol-server tutorial for direct `curl` examples.
| `generate:semantic-contracts` | Compile and regenerate the provider-neutral Phase 7D tool contract. |
| `check:semantic-contracts` | Fail when the checked-in Phase 7D tool contract is stale. |
| `test:phase7` | Prove the Phase 7 mock adapter, semantic policy, live session persistence, interaction resume, stop, and reset contracts. |
| `console:phase7` | Serve the Phase 7G issue-thread UI on loopback. |
| `test:browser:phase7` | Run the Phase 7G browser suite, including the axe gate on every screenshot route. |
| `record:phase7:ui` | Record the deterministic 12-slug × 2-viewport screenshot matrix. |
| `check:phase7:ui` | Fail when a recorded Phase 7G screenshot is not byte-stable. |
| `check:browser-tokens` | Fail when a browser surface paints with a raw color instead of a semantic token. |
| `smoke:phase7:ui` | Drive real runnerd and Codex through the issue-thread server's HTTP routes. |
| `record:phase7:ui:live` | Capture live-mode browser evidence (not byte-stable by construction). |
| `trace:phase7` | Run a real runnerd and Codex app-server semantic-tool smoke with process and network evidence. |
| `test:browser:phase5` | Exercise both consumers with the fake driver, keyboard/a11y checks, reconnect/replay, measurements, and screenshots. |
| `record:phase5:codex` | Run both public consumers against a safe real Codex session and capture live screenshots. |
@@ -156,6 +167,7 @@ and JSON content; see the protocol-server tutorial for direct `curl` examples.
- [Phase 5 component decision record](docs/design/phase-5-component-decisions.md)
- [Phase 7 semantic catalog and authorization](docs/phase-07-semantic-catalog.md)
- [Phase 7 live runnerd and Codex loop](docs/phase-07-live-runnerd-codex.md)
- [Phase 7 Paperclip-style issue-thread UI](docs/phase-07-issue-thread-ui.md)
- [PRP compatibility/versioning policy](docs/protocol-compatibility.md)
- [Cumulative end-to-end tutorial](docs/tutorials/end-to-end.md)
- [Journal guide](docs/journal.md)
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<title>Mock Paperclip · Issue thread</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
@@ -0,0 +1,343 @@
import { createRequire } from "node:module";
import { expect, test, type Page } from "@playwright/test";
import { PHASE7_UI_SHOT_SLUGS } from "../../src/issue-thread/fixtures";
const require = createRequire(import.meta.url);
const AXE_PATH = require.resolve("axe-core/axe.min.js");
const DESKTOP = { width: 1440, height: 900 };
const MOBILE = { width: 390, height: 844 };
function route(slug: string, params: Record<string, string> = {}): string {
const query = new URLSearchParams({ shot: slug, capture: "1", ...params });
return `/#/issue/hb-baseline?${query.toString()}`;
}
/** Every route settles on a data attribute, never on a timeout (§10.1). */
async function open(page: Page, slug: string, params: Record<string, string> = {}) {
await page.goto(route(slug, params));
await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
}
interface AxeViolation {
id: string;
impact: string | null;
nodes: unknown[];
}
async function seriousAxeViolations(page: Page): Promise<AxeViolation[]> {
await page.addScriptTag({ path: AXE_PATH });
const violations = await page.evaluate(async () => {
const axe = (window as unknown as { axe: { run: (context: unknown, options: unknown) => Promise<{ violations: AxeViolation[] }> } }).axe;
const results = await axe.run(document, {
resultTypes: ["violations"],
runOnly: { type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"] },
});
return results.violations.map((violation) => ({
id: violation.id,
impact: violation.impact ?? null,
nodes: violation.nodes.length,
}));
});
return (violations as unknown as AxeViolation[]).filter(
(violation) => violation.impact === "serious" || violation.impact === "critical",
);
}
test.describe("Phase 7G issue thread", () => {
test("baseline thread renders identity, turns, and the §3 item types", async ({ page }) => {
await open(page, "thread-baseline");
await expect(page.locator('[data-session-mode="fake"]').first()).toBeVisible();
const chips = page.getByTestId("identity-chips");
await expect(chips.getByTestId("agent-chip")).toHaveText("Fake agent");
await expect(chips.getByTestId("runner-chip")).toContainText("In-process runner");
await expect(chips.getByTestId("control-plane-chip")).toHaveText("Mock Paperclip");
await expect(page.locator("h1")).toHaveCount(1);
await expect(page.locator('[data-turn-id]')).toHaveCount(3);
for (const kind of ["user_message", "agent_message", "durable_comment", "tool_activity"]) {
await expect(page.locator(`[data-thread-item="${kind}"]`).first()).toBeVisible();
}
await expect(page.locator('[data-composer-state="ready"]')).toHaveCount(1);
});
test("a durable progress comment is visually distinct from model prose", async ({ page }) => {
await open(page, "thread-baseline");
await expect(
page.locator('[data-thread-item="durable_comment"]').getByText("Recorded to mock thread"),
).toBeVisible();
await expect(
page.locator('[data-thread-item="agent_message"]').first().getByText("Recorded to mock thread"),
).toHaveCount(0);
});
test("tool strips are collapsed disclosures that deep-link into Evidence", async ({ page }) => {
await open(page, "thread-baseline");
const strip = page.locator('[data-tool-strip="get_task_context"] button').first();
await expect(strip).toHaveAttribute("aria-expanded", "false");
await strip.click();
await expect(strip).toHaveAttribute("aria-expanded", "true");
await page.getByRole("button", { name: "View in Evidence" }).first().click();
const panel = page.getByTestId("evidence-panel");
await expect(panel).toBeVisible();
await expect(panel.locator('[data-record-id="call-1"][data-highlighted="true"]')).toBeVisible();
});
test("a pending question card resolves inline and releases the composer", async ({ page }) => {
await open(page, "interaction-question-pending");
await expect(page.locator('[data-composer-state="waiting"]')).toBeVisible();
const card = page.locator('[data-interaction-id="ix-questions-01"]');
await expect(card).toHaveAttribute("data-interaction-state", "pending");
await expect(card.getByTestId("interaction-state-chip")).toContainText("Waiting for you");
// The submit is rejected until every required question is answered.
await card.getByRole("button", { name: "Submit answers" }).click();
await expect(card.getByRole("alert")).toContainText("required");
await card.getByRole("radio", { name: "Yes — runner owns it" }).check();
await card.getByRole("combobox").selectOption("hb-baseline");
await card.getByRole("button", { name: "Submit answers" }).click();
await expect(card).toHaveAttribute("data-interaction-state", "answered");
await expect(card.getByTestId("interaction-state-chip")).toContainText("Answered");
await expect(page.locator('[data-composer-state="ready"]').first()).toBeVisible();
});
test("a revision-bound confirmation names its target and requires a reject reason", async ({
page,
}) => {
await open(page, "interaction-confirmation-pending");
const card = page.locator('[data-interaction-id="ix-confirmation-plan-01"]');
await expect(card.getByTestId("interaction-target")).toHaveText("plan · r4");
await card.getByRole("button", { name: "Request changes" }).click();
await expect(card.getByRole("alert")).toContainText("reason is required");
await expect(card).toHaveAttribute("data-interaction-state", "pending");
await card.getByRole("textbox").fill("Split the acceptance section first.");
await card.getByRole("button", { name: "Request changes" }).click();
await expect(card).toHaveAttribute("data-interaction-state", "rejected");
await expect(card).toContainText("Split the acceptance section first.");
});
test("resolved, rejected, stale, and superseded cards stay distinct in history", async ({
page,
}) => {
await open(page, "interaction-resolved-mixed");
for (const state of ["answered", "accepted", "rejected", "stale_target", "superseded_by_comment"]) {
await expect(
page.locator(`[data-interaction-state="${state}"]`),
`missing ${state}`,
).toHaveCount(1);
}
// Expired treatments keep their controls removed but stay reachable.
await expect(
page.locator('[data-interaction-state="stale_target"] button', { hasText: "Approve" }),
).toHaveCount(0);
await expect(
page.locator('[data-interaction-state="stale_target"]').getByRole("button", {
name: "View request evidence",
}),
).toBeVisible();
});
test("a denial quotes the authorization record verbatim and badges Evidence", async ({
page,
}) => {
await open(page, "denial-optional-tool");
await expect(page.getByTestId("denial-reason")).toHaveText(
"denied: missing grant su:read_or_write",
);
await expect(page.getByTestId("evidence-toggle")).toContainText("(1)");
await page.locator('[data-thread-item="denial"]').getByRole("button", { name: "View in Evidence" }).click();
const record = page.getByTestId("evidence-panel").locator('[data-record-id="authz-deny-1"]');
await expect(record).toHaveAttribute("data-allowed", "false");
await expect(record).toContainText("denied: missing grant su:read_or_write");
});
test("Evidence exposes eight sections, a turn selector, and the withheld control-plane list", async ({
page,
}) => {
await open(page, "debug-panel-open", { panel: "authorization" });
const panel = page.getByTestId("evidence-panel");
await expect(panel).toBeVisible();
await expect(panel.locator("[data-evidence-section]")).toHaveCount(8);
await expect(panel.locator("#evidence-turn-selector")).toBeVisible();
// The Tools section is open by default; assert its groups directly.
await expect(
panel.locator('[data-evidence-section="tools"] button').first(),
).toHaveAttribute("aria-expanded", "true");
await expect(panel.getByText("Control plane (not exposed to the agent)")).toBeVisible();
await expect(
panel.locator('[data-disposition="control_plane_owned"]', { hasText: "create_task" }),
).toBeVisible();
await expect(panel.getByText("Agent tool — always")).toBeVisible();
await expect(panel.getByText("Agent tool — granted")).toBeVisible();
});
test("Evidence records link back to their thread anchor", async ({ page }) => {
await open(page, "debug-panel-open", { panel: "calls" });
const panel = page.getByTestId("evidence-panel");
await panel.locator('[data-record-id="call-1"]').getByRole("button", { name: "Show in thread" }).click();
await expect(page.locator('[data-tool-strip="get_task_context"]')).toBeInViewport();
});
test("the splitter resizes the panel from the keyboard", async ({ page }) => {
await open(page, "debug-panel-open", { panel: "tools" });
const splitter = page.getByRole("separator", { name: "Resize the evidence panel" });
const before = await splitter.getAttribute("aria-valuenow");
await splitter.focus();
await page.keyboard.press("ArrowLeft");
await expect(splitter).not.toHaveAttribute("aria-valuenow", before ?? "");
});
test("reset is confirmed, escapable, and clears the thread", async ({ page }) => {
await open(page, "disposition-terminal");
await page.getByTestId("reset-button").click();
const dialog = page.getByTestId("reset-dialog");
await expect(dialog).toBeVisible();
await expect(dialog).toContainText("The transcript will be lost.");
await expect(page.getByRole("button", { name: "Cancel" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await page.getByTestId("reset-button").click();
await page
.getByTestId("reset-dialog")
.getByRole("button", { name: "Reset scenario", exact: true })
.click();
await expect(page.locator('[data-thread-item="disposition"]')).toHaveCount(0);
await expect(page.locator('[data-composer-state="ready"]').first()).toBeVisible();
});
test("stop keeps the partial turn and marks it", async ({ page }) => {
await open(page, "turn-streaming");
await expect(page.locator('[data-composer-state="streaming"]')).toBeVisible();
await expect(page.getByTestId("composer-stop")).toBeVisible();
await expect(page.locator('[data-composer-state="streaming"] textarea')).toBeEnabled();
await page.getByTestId("composer-stop").click();
await expect(page.getByTestId("stopped-marker")).toBeVisible();
await expect(page.locator('[data-thread-item="agent_message"]').last()).toContainText(
"Two dependent tasks are still open",
);
});
test("reconnect pins an amber banner and disables the composer", async ({ page }) => {
await open(page, "reconnect-banner");
await expect(page.getByTestId("reconnect-banner")).toContainText("attempt 2");
await expect(page.locator('[data-composer-state="reconnecting"]')).toBeVisible();
await expect(page.locator('[data-composer-state="reconnecting"] textarea')).toBeDisabled();
await page.getByRole("button", { name: "Retry now" }).click();
await expect(page.getByText("Reconnected")).toBeVisible();
});
test("replay is read-only and labelled as fake-derived", async ({ page }) => {
await open(page, "replay-mode");
await expect(page.getByTestId("agent-chip")).toContainText("Replay · fake source");
await expect(page.getByTestId("composer-reason")).toHaveText("Replay is read-only");
await expect(page.getByTestId("replay-strip")).toContainText("Replay 12/18");
await expect(page.locator('[data-composer-state="disabled"] textarea')).toBeDisabled();
});
test("a terminal disposition disables the composer with a reason", async ({ page }) => {
await open(page, "disposition-terminal");
await expect(page.locator('[data-thread-item="disposition"]')).toContainText("Done");
await expect(page.getByTestId("composer-reason")).toHaveText("Issue is done");
});
test("composer drafts survive a refresh", async ({ page }) => {
await open(page, "thread-baseline");
await page.locator("#composer-input").fill("Draft that must survive F5.");
await page.reload();
await expect(page.locator('[data-thread-state="settled"]')).toBeVisible();
await expect(page.locator("#composer-input")).toHaveValue("Draft that must survive F5.");
});
test("mobile keeps the thread usable with no horizontal page scroll", async ({ page }) => {
await page.setViewportSize(MOBILE);
await open(page, "turn-streaming");
const overflow = await page.evaluate(() => {
const element = document.scrollingElement as HTMLElement;
return element.scrollWidth - element.clientWidth;
});
expect(overflow).toBeLessThanOrEqual(0);
// Stop stays outside the overflow menu while a turn is active (§2.2).
await expect(page.getByTestId("stop-button")).toBeVisible();
await expect(page.getByTestId("stop-button")).toBeEnabled();
await expect(page.getByTestId("overflow-menu")).toHaveCount(0);
await page.getByTestId("segment-evidence").click();
await expect(page.getByTestId("evidence-panel")).toBeVisible();
await expect(page.getByTestId("evidence-panel")).toHaveAttribute("data-layout", "segment");
});
test("mobile surfaces the denial count on the Evidence segment", async ({ page }) => {
await page.setViewportSize(MOBILE);
await open(page, "denial-optional-tool");
await expect(page.getByTestId("segment-denial-badge")).toHaveText("1");
});
test("every actionable control meets the 44px touch target on mobile", async ({ page }) => {
await page.setViewportSize(MOBILE);
await open(page, "interaction-question-pending");
const undersized = await page.evaluate(() => {
const selectors = "button:not([hidden]), select, textarea, [role='radio']";
return [...document.querySelectorAll(selectors)]
.filter((node) => {
const element = node as HTMLElement;
if (element.offsetParent === null) return false;
const rect = element.getBoundingClientRect();
return rect.height < 44;
})
.map((node) => (node as HTMLElement).className || node.tagName);
});
expect(undersized).toEqual([]);
});
test("the mobile overflow menu closes with Escape", async ({ page }) => {
await page.setViewportSize(MOBILE);
await open(page, "thread-baseline");
await page.getByTestId("overflow-menu-button").click();
await expect(page.getByTestId("overflow-menu")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.getByTestId("overflow-menu")).toHaveCount(0);
});
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();
});
test("the waiting composer anchor focuses the pending card", async ({ page }) => {
await open(page, "interaction-question-pending");
await page.getByTestId("pending-anchor").click();
await expect(page.locator('[data-interaction-id="ix-questions-01"]')).toBeInViewport();
});
for (const slug of PHASE7_UI_SHOT_SLUGS) {
test(`axe reports no serious or critical violation on ${slug} (desktop)`, async ({ page }) => {
await page.setViewportSize(DESKTOP);
await open(page, slug, slug === "debug-panel-open" ? { panel: "authorization" } : {});
expect(await seriousAxeViolations(page)).toEqual([]);
});
test(`axe reports no serious or critical violation on ${slug} (mobile)`, async ({ page }) => {
await page.setViewportSize(MOBILE);
await open(page, slug, slug === "debug-panel-open" ? { panel: "authorization", seg: "evidence" } : {});
expect(await seriousAxeViolations(page)).toEqual([]);
});
}
});
@@ -0,0 +1,34 @@
import { defineConfig } from "@playwright/test";
/**
* Focused browser suite for the Phase 7G issue thread.
*
* The suite runs the built app through `vite preview` on loopback. Fake-mode
* routes render from package fixtures, so no provider, runnerd, or Codex
* process is started here.
*/
export default defineConfig({
testDir: ".",
testMatch: ["issue-thread.spec.ts"],
fullyParallel: false,
workers: 1,
reporter: "line",
use: {
baseURL: "http://127.0.0.1:4184",
viewport: { width: 1440, height: 900 },
launchOptions: {
// Hosts without the Playwright chromium system libraries can point at a
// preinstalled Chromium instead of running `pnpm verify:rootless`.
...(process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH === undefined
? {}
: { executablePath: process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH }),
},
},
webServer: {
command:
"pnpm exec vite preview --config vite.phase7.config.ts --host 127.0.0.1 --port 4184",
url: "http://127.0.0.1:4184",
reuseExistingServer: false,
timeout: 60_000,
},
});
@@ -0,0 +1,582 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
PHASE7_DEFAULT_FIXTURE_PROFILE,
phase7IssueThreadFixture,
} from "../../../src/issue-thread/fixtures";
import type {
Phase7EvidenceSectionId,
Phase7IssueThreadSnapshot,
} from "../../../src/issue-thread/types";
import { phase7DenialCount } from "../../../src/issue-thread/types";
import { Composer } from "./Composer";
import { EvidencePanel } from "./EvidencePanel";
import { IssueHeader } from "./IssueHeader";
import { applyFakeInteractionResponse } from "./fake-store";
import type { Phase7InteractionResponse } from "./InteractionCard";
import { phase7LiveClient, recallSession, rememberSession } from "./live-client";
import { parsePhase7Route, phase7RouteHref, type Phase7Route } from "./route";
import { TurnGroup } from "./ThreadItems";
const PANEL_OPEN_KEY = "paperclip-runner.phase7.panel.open";
const PANEL_WIDTH_KEY = "paperclip-runner.phase7.panel.width";
const PANEL_MIN = 320;
const PANEL_MAX = 640;
const SCENARIOS = ["hb-baseline", "dp-documents", "ix-interactions", "ar-artifacts"];
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;
}
}
function useRoute(): Phase7Route {
const [route, setRoute] = useState(() => parsePhase7Route(window.location));
useEffect(() => {
const onChange = () => setRoute(parsePhase7Route(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 route = useRoute();
const layout = useLayout();
const [snapshot, setSnapshot] = useState<Phase7IssueThreadSnapshot | null>(null);
const [error, setError] = useState<string | null>(null);
const [settled, setSettled] = useState(false);
const [panelOpen, setPanelOpen] = useState(() => readStoredFlag(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<Phase7EvidenceSectionId[]>(["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 scrollRef = useRef<HTMLDivElement | null>(null);
const cancelResetRef = useRef<HTMLButtonElement | null>(null);
/* --------------------------------------------------------------- loading */
useEffect(() => {
let cancelled = false;
setSettled(false);
if (route.mode === "live") {
void (async () => {
try {
const response = await phase7LiveClient.load(recallSession());
if (cancelled) return;
rememberSession(response.sessionId);
setSnapshot(response.view);
} catch (cause) {
if (!cancelled) setError(String(cause instanceof Error ? cause.message : cause));
}
})();
} else {
setSnapshot(phase7IssueThreadFixture(route.shot ?? "thread-baseline", route.fixtureProfile));
}
return () => {
cancelled = true;
};
}, [route.mode, route.shot, route.fixtureProfile]);
/* ------------------------------------------------------- deep-link params */
useEffect(() => {
if (route.panel === null) return;
setPanelOpen(true);
setSegment("evidence");
setOpenSections((current) =>
current.includes(route.panel as Phase7EvidenceSectionId)
? current
: [...current, route.panel as Phase7EvidenceSectionId],
);
setHighlightedRecordId(route.record);
}, [route.panel, route.record]);
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 (snapshot === null) return;
const pending = snapshot.composer.pendingInteractionId;
if (pending !== null) setAnnouncement("A request is waiting for your answer.");
else if (snapshot.connection.state === "reconnecting") {
setAnnouncement(`Connection lost — retrying (attempt ${snapshot.connection.attempt}).`);
}
}, [snapshot]);
/* -------------------------------------------------------------- handlers */
const persistPanel = useCallback((open: boolean, width: number) => {
try {
window.localStorage.setItem(PANEL_OPEN_KEY, String(open));
window.localStorage.setItem(PANEL_WIDTH_KEY, String(width));
} catch {
// Panel preference is a convenience, never a correctness requirement.
}
}, []);
const openEvidence = useCallback(
(section: Phase7EvidenceSectionId, 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 jumpToThread = useCallback((anchorId: string) => {
setSegment("thread");
const anchor = document.getElementById(anchorId) ?? document.querySelector(`#${CSS.escape(anchorId)}`);
anchor?.scrollIntoView({ block: "center" });
}, []);
const respond = useCallback(
(response: Phase7InteractionResponse) => {
setSnapshot((current) => {
if (current === null) return current;
if (route.mode === "live") {
void phase7LiveClient
.respond(current.sessionId, response.interactionId, response.outcome, response.result)
.then((next) => setSnapshot(next.view))
.catch((cause) => setError(String(cause)));
return {
...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,
),
})),
};
}
return applyFakeInteractionResponse(current, response);
});
setAnnouncement("Your answer was recorded.");
},
[route.mode],
);
const send = useCallback(
(message: string) => {
setSnapshot((current) => {
if (current === null) return current;
if (route.mode === "live") {
void phase7LiveClient
.send(current.sessionId, message)
.then((next) => setSnapshot(next.view))
.catch((cause) => setError(String(cause)));
return { ...current, composer: { ...current.composer, state: "sending" } };
}
return current;
});
},
[route.mode],
);
const stop = useCallback(() => {
setSnapshot((current) => {
if (current === null) return current;
if (route.mode === "live") {
void phase7LiveClient
.stop(current.sessionId)
.then((next) => setSnapshot(next.view))
.catch((cause) => setError(String(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]);
const reset = useCallback(() => {
setConfirmReset(false);
if (route.mode === "live" && snapshot !== null) {
void phase7LiveClient
.reset(snapshot.sessionId)
.then((next) => {
rememberSession(next.sessionId);
setSnapshot(next.view);
})
.catch((cause) => setError(String(cause)));
return;
}
setSnapshot(phase7IssueThreadFixture("thread-baseline", route.fixtureProfile));
setAnnouncement("Scenario reset. The mock state is back to its clean seed.");
}, [route.fixtureProfile, route.mode, snapshot]);
const retry = useCallback(() => {
if (route.mode === "live" && snapshot !== null) {
void phase7LiveClient
.reconnect(snapshot.sessionId)
.then((next) => setSnapshot(next.view))
.catch((cause) => setError(String(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]);
/* ---------------------------------------------------------------- render */
const denialCount = useMemo(
() => (snapshot === null ? 0 : phase7DenialCount(snapshot.evidence, null)),
[snapshot],
);
if (error !== null) {
return (
<main className="pit-app">
<p className="pit-composer-reason" role="alert">
{error}
</p>
</main>
);
}
if (snapshot === null) {
return <main className="pit-app" data-thread-state="loading" />;
}
const showThread = layout !== "segment" || segment === "thread";
const showPanel = layout === "segment" ? segment === "evidence" : panelOpen;
return (
<div
className="pit-app"
data-thread-state={settled ? "settled" : "loading"}
data-session-mode={snapshot.mode}
data-connection-state={snapshot.connection.state}
>
<IssueHeader
snapshot={snapshot}
scenarios={SCENARIOS}
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 = phase7RouteHref(route, { fixtureProfile: scenario });
}}
onReplay={() => {
window.location.hash = phase7RouteHref(route, {
shot: "replay-mode",
mode: "replay",
at: 12,
});
}}
onReset={() => setConfirmReset(true)}
onStop={stop}
onSelectSegment={setSegment}
/>
{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}
{snapshot.replay !== null ? (
<div className="pit-replay-strip" data-testid="replay-strip">
<span>
Replay {snapshot.replay.ordinal}/{snapshot.replay.total}
</span>
<div
className="pit-progress"
role="progressbar"
aria-valuemin={0}
aria-valuemax={snapshot.replay.total}
aria-valuenow={snapshot.replay.ordinal}
aria-label="Replay progress"
>
<div
className="pit-progress-fill"
style={{ width: `${(snapshot.replay.ordinal / snapshot.replay.total) * 100}%` }}
/>
</div>
<button
type="button"
className="pit-button"
onClick={() => {
window.location.hash = phase7RouteHref(route, {
at: Math.max(0, (route.at ?? snapshot.replay?.ordinal ?? 0) - 1),
});
}}
>
Step back
</button>
<button
type="button"
className="pit-button"
onClick={() => {
window.location.hash = phase7RouteHref(route, {
at: (route.at ?? snapshot.replay?.ordinal ?? 0) + 1,
});
}}
>
Next turn
</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}
onScroll={(event) => {
const element = event.currentTarget;
const distance =
element.scrollHeight - element.scrollTop - element.clientHeight;
setShowJump(distance > 300);
}}
>
<div className="pit-thread">
{snapshot.turns.map((turn) => (
<TurnGroup
key={turn.id}
turn={turn}
callbacks={{
onOpenEvidence: openEvidence,
onRespond: respond,
focusInteractionId,
}}
/>
))}
</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={() => setConfirmReset(true)}
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}
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}
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={() => {
setPanelOpen(false);
persistPanel(false, panelWidth);
if (layout === "segment") setSegment("thread");
}}
onJumpToThread={jumpToThread}
/>
) : 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">
Reset scenario?
</h2>
<p>
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}
>
Reset scenario
</button>
</div>
</div>
</div>
) : null}
<p className="pit-visually-hidden" role="status" aria-live="polite">
{announcement}
</p>
</div>
);
}
@@ -0,0 +1,150 @@
import { useEffect, useRef, useState, type FormEvent } from "react";
import type { Phase7ComposerModel } from "../../../src/issue-thread/types";
/**
* Composer (contract §4). `data-composer-state` is the single test and
* screenshot hook. Draft text survives refresh through localStorage keyed by
* session id; Stop never discards the transcript.
*/
export interface ComposerProps {
model: Phase7ComposerModel;
sessionId: string;
onSend: (message: string) => void;
onStop: () => void;
onRetry: () => void;
onReset: () => void;
onFocusPending: (interactionId: string) => void;
}
function draftKey(sessionId: string): string {
return `paperclip-runner.phase7.draft.${sessionId}`;
}
function readDraft(sessionId: string): string {
try {
return window.localStorage.getItem(draftKey(sessionId)) ?? "";
} catch {
return "";
}
}
export function Composer(props: ComposerProps) {
const { model, sessionId, onSend, onStop, onRetry, onReset, onFocusPending } = props;
const [value, setValue] = useState(() => readDraft(sessionId));
const inputRef = useRef<HTMLTextAreaElement | null>(null);
useEffect(() => {
setValue(readDraft(sessionId));
}, [sessionId]);
useEffect(() => {
try {
window.localStorage.setItem(draftKey(sessionId), value);
} catch {
// A blocked storage quota must not break the session.
}
}, [sessionId, value]);
const editable = model.state === "ready" || model.state === "streaming";
const canSend = editable && value.trim().length > 0;
function submit(event: FormEvent) {
event.preventDefault();
if (!canSend) return;
onSend(value.trim());
setValue("");
}
return (
<form
className="pit-composer"
data-composer-state={model.state}
onSubmit={submit}
aria-label="Send a message to the agent"
>
<div className="pit-composer-inner">
{model.helper !== null ? (
<p className="pit-composer-helper">
{model.helper}
{model.state === "waiting" && model.pendingInteractionId !== null ? (
<>
{" "}
<button
type="button"
className="pit-link-button"
data-testid="pending-anchor"
onClick={() => onFocusPending(model.pendingInteractionId as string)}
>
Go to the pending request
</button>
</>
) : null}
</p>
) : null}
{model.reason !== null ? (
<p className="pit-composer-reason" data-testid="composer-reason">
{model.reason}
</p>
) : null}
<div className="pit-composer-row">
<label className="pit-visually-hidden" htmlFor="composer-input">
Message
</label>
<textarea
className="pit-composer-input"
id="composer-input"
ref={inputRef}
rows={2}
value={value}
disabled={!editable}
placeholder={editable ? "Message the agent…" : ""}
onChange={(event) => setValue(event.target.value)}
onKeyDown={(event) => {
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") {
event.preventDefault();
if (canSend) {
onSend(value.trim());
setValue("");
}
}
}}
/>
{model.state === "streaming" ? (
<button
type="button"
className="pit-button"
data-variant="destructive"
onClick={onStop}
data-testid="composer-stop"
>
Stop
</button>
) : model.state === "reconnecting" ? (
<button type="button" className="pit-button" onClick={onRetry}>
Retry now
</button>
) : model.state === "disabled" ? (
<button type="button" className="pit-button" onClick={onReset}>
Reset scenario
</button>
) : null}
<button
type="submit"
className="pit-button"
data-variant="primary"
disabled={!canSend || model.state === "sending"}
data-testid="composer-send"
>
{model.state === "sending" ? "Sending…" : model.state === "streaming" ? "Steer" : "Send"}
</button>
</div>
</div>
</form>
);
}
@@ -0,0 +1,401 @@
import { useEffect, useRef, type ReactNode } from "react";
import type {
Phase7EvidenceModel,
Phase7EvidenceSectionId,
Phase7IssueThreadSnapshot,
Phase7ToolDisposition,
} from "../../../src/issue-thread/types";
import {
PHASE7_EVIDENCE_SECTIONS,
phase7DispositionLabel,
} from "../../../src/issue-thread/types";
/**
* Evidence panel (contract §7): eight accordion sections in fixed order with a
* turn selector. Every record is rendered from the snapshot; the panel derives
* no verdicts of its own.
*/
const DISPOSITION_ORDER: Phase7ToolDisposition[] = [
"always_agent_tool",
"optional_agent_tool",
"control_plane_owned",
];
export interface EvidencePanelProps {
snapshot: Phase7IssueThreadSnapshot;
layout: "side" | "overlay" | "segment";
width: number;
selectedTurnId: string | "all";
openSections: Phase7EvidenceSectionId[];
highlightedRecordId: string | null;
onSelectTurn: (turnId: string | "all") => void;
onToggleSection: (section: Phase7EvidenceSectionId) => void;
onClose: () => void;
onJumpToThread: (anchorId: string) => void;
}
function sectionCount(
evidence: Phase7EvidenceModel,
section: Phase7EvidenceSectionId,
turnId: string | "all",
): string {
if (section === "parity") {
const rows = filterByTurn(evidence.parity, turnId);
const passing = rows.filter((row) => row.verdict === "pass").length;
return `${passing}/${rows.length}`;
}
const rows = filterByTurn(
evidence[section] as ReadonlyArray<{ turnId: string }>,
turnId,
);
return String(rows.length);
}
function filterByTurn<T extends { turnId: string }>(
rows: ReadonlyArray<T>,
turnId: string | "all",
): T[] {
return turnId === "all" ? [...rows] : rows.filter((row) => row.turnId === turnId);
}
function Section({
id,
title,
count,
open,
onToggle,
children,
}: {
id: Phase7EvidenceSectionId;
title: string;
count: string;
open: boolean;
onToggle: () => void;
children: ReactNode;
}) {
return (
<section className="pit-section" data-evidence-section={id}>
<h3>
<button
type="button"
className="pit-section-button"
aria-expanded={open}
aria-controls={`evidence-section-${id}`}
onClick={onToggle}
>
<span aria-hidden="true">{open ? "⌄" : "›"}</span>
{title}
<span className="pit-section-count">{count}</span>
</button>
</h3>
<div className="pit-section-body" id={`evidence-section-${id}`} hidden={!open}>
{children}
</div>
</section>
);
}
export function EvidencePanel(props: EvidencePanelProps) {
const {
snapshot,
layout,
width,
selectedTurnId,
openSections,
highlightedRecordId,
onSelectTurn,
onToggleSection,
onClose,
onJumpToThread,
} = props;
const headingRef = useRef<HTMLHeadingElement | null>(null);
const evidence = snapshot.evidence;
useEffect(() => {
// Contract §9.2: opening Evidence moves focus to its heading.
headingRef.current?.focus();
}, []);
const isOpen = (section: Phase7EvidenceSectionId) => openSections.includes(section);
return (
<aside
className="pit-panel"
data-layout={layout}
data-testid="evidence-panel"
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>
<Section
id="tools"
title="Tools exposed"
count={sectionCount(evidence, "tools", selectedTurnId)}
open={isOpen("tools")}
onToggle={() => onToggleSection("tools")}
>
{filterByTurn(evidence.tools, selectedTurnId).map((record) => (
<div key={record.id}>
{DISPOSITION_ORDER.map((disposition) => {
const rows = record.rows.filter((row) => row.disposition === disposition);
if (rows.length === 0) return null;
return (
<div key={disposition}>
<p className="pit-tool-group-label" data-group={disposition}>
{disposition === "control_plane_owned"
? "Control plane (not exposed to the agent)"
: phase7DispositionLabel(disposition)}
</p>
{rows.map((row) => (
<p
className="pit-tool-row"
key={row.operationId}
data-disposition={disposition}
>
<span className="pit-record-title">{row.operationId}</span>
{row.grant !== null && disposition === "optional_agent_tool" ? (
<span className="pit-grant">{row.grant}</span>
) : null}
</p>
))}
</div>
);
})}
</div>
))}
</Section>
<Section
id="calls"
title="Calls & results"
count={sectionCount(evidence, "calls", selectedTurnId)}
open={isOpen("calls")}
onToggle={() => onToggleSection("calls")}
>
{filterByTurn(evidence.calls, selectedTurnId).map((record) => (
<div
className="pit-record"
key={record.id}
data-record-id={record.id}
data-highlighted={highlightedRecordId === record.id}
>
<span className="pit-record-title">
{record.operationId} · v{record.version} · {record.outcome}
</span>
<span className="pit-record-detail">{record.providerRequest}</span>
<span className="pit-record-detail">{record.dispatchedCommand}</span>
<span className="pit-record-detail">{JSON.stringify(record.result)}</span>
{record.redactions.length > 0 ? (
<span className="pit-record-detail">
••• redacted by {record.redactions.join(", ")}
</span>
) : null}
<button
type="button"
className="pit-link-button"
onClick={() => onJumpToThread(record.threadAnchorId)}
>
Show in thread
</button>
</div>
))}
</Section>
<Section
id="authorization"
title="Authorization"
count={sectionCount(evidence, "authorization", selectedTurnId)}
open={isOpen("authorization")}
onToggle={() => onToggleSection("authorization")}
>
{filterByTurn(evidence.authorization, selectedTurnId).map((record) => (
<div
className="pit-record"
key={record.id}
data-record-id={record.id}
data-allowed={record.allowed}
data-highlighted={highlightedRecordId === record.id}
>
<span className="pit-record-title">
<span aria-hidden="true">{record.allowed ? "✓" : "✕"}</span>{" "}
{record.operationId} · {record.phase} · {record.allowed ? "allowed" : "denied"}
</span>
<span className="pit-record-detail">{record.reason}</span>
<span className="pit-record-detail">
claims: {record.claimsConsidered.join(", ") || "none"}
</span>
{record.redactions.length > 0 ? (
<span className="pit-record-detail">
••• redacted by {record.redactions.join(", ")}
</span>
) : null}
{record.stateChangeRef !== null ? (
<span className="pit-record-detail">state: {record.stateChangeRef}</span>
) : null}
{record.threadAnchorId.length > 0 ? (
<button
type="button"
className="pit-link-button"
onClick={() => onJumpToThread(record.threadAnchorId)}
>
Show in thread
</button>
) : null}
</div>
))}
</Section>
<Section
id="control_plane"
title="Control plane"
count={sectionCount(evidence, "control_plane", selectedTurnId)}
open={isOpen("control_plane")}
onToggle={() => onToggleSection("control_plane")}
>
{filterByTurn(evidence.control_plane, selectedTurnId).map((record) => (
<div
className="pit-record"
key={record.id}
data-record-id={record.id}
data-highlighted={highlightedRecordId === record.id}
>
<span className="pit-record-title">
{record.category} · {record.outcome} · rev {record.stateRevision}
</span>
<span className="pit-record-detail">{record.reason}</span>
</div>
))}
</Section>
<Section
id="runner"
title="Runner & events"
count={sectionCount(evidence, "runner", selectedTurnId)}
open={isOpen("runner")}
onToggle={() => onToggleSection("runner")}
>
{filterByTurn(evidence.runner, selectedTurnId).map((record) => (
<div className="pit-record" key={record.id} data-record-id={record.id}>
<span className="pit-record-title">
#{record.ordinal} {record.kind}
</span>
<span className="pit-record-detail">{record.detail}</span>
</div>
))}
</Section>
<Section
id="state"
title="State diff"
count={sectionCount(evidence, "state", selectedTurnId)}
open={isOpen("state")}
onToggle={() => onToggleSection("state")}
>
{filterByTurn(evidence.state, selectedTurnId).map((record) => (
<div
className="pit-record"
key={record.id}
data-record-id={record.id}
data-highlighted={highlightedRecordId === record.id}
>
<span className="pit-record-title">
revision {record.fromRevision} → {record.toRevision}
</span>
{record.rows.map((row) => (
<div className="pit-diff-row" key={`${row.entityClass}-${row.entityRef}`}>
<span className="pit-record-detail">
{row.entityClass} · {row.entityRef}
</span>
<span className="pit-record-detail">
{row.before} → {row.after}
</span>
</div>
))}
</div>
))}
</Section>
<Section
id="traceability"
title="Traceability"
count={sectionCount(evidence, "traceability", selectedTurnId)}
open={isOpen("traceability")}
onToggle={() => onToggleSection("traceability")}
>
{filterByTurn(evidence.traceability, selectedTurnId).map((record) => (
<div className="pit-record" key={record.id} data-record-id={record.id}>
<span className="pit-record-title">
{record.caseId} · {record.group}
</span>
<span className="pit-record-detail">{record.sourceAnchor}</span>
<span className="pit-record-detail">{record.browserEvidenceRecipe}</span>
<span className="pit-record-detail">
expects: {record.expectedSemanticOperations.join(", ")}
</span>
<span className="pit-record-detail">
forbids: {record.forbiddenOperations.join(", ") || "none"}
</span>
<span className="pit-record-detail">
grants: {record.requiredCapabilityGrants.join(", ") || "none"}
</span>
</div>
))}
</Section>
<Section
id="parity"
title="Parity"
count={sectionCount(evidence, "parity", selectedTurnId)}
open={isOpen("parity")}
onToggle={() => onToggleSection("parity")}
>
{filterByTurn(evidence.parity, selectedTurnId).map((record) => (
<div className="pit-record" key={record.id} data-record-id={record.id}>
<span className="pit-verdict" data-verdict={record.verdict}>
<span aria-hidden="true">
{record.verdict === "pass" ? "✓" : record.verdict === "fail" ? "✕" : "—"}
</span>
{record.verdict === "intentional_gap" ? "intentional gap" : record.verdict}
</span>
<span className="pit-record-detail">{record.assertion}</span>
{record.note !== null ? (
<span className="pit-record-detail">{record.note}</span>
) : null}
</div>
))}
</Section>
</aside>
);
}
export const PHASE7_EVIDENCE_SECTION_IDS = PHASE7_EVIDENCE_SECTIONS.map(
(section) => section.id,
);
@@ -0,0 +1,509 @@
import { useId, useRef, useState } from "react";
import type {
Phase7ThreadInteractionCard,
Phase7ThreadInteractionState,
} from "../../../src/issue-thread/types";
import { Chip } from "./primitives";
/**
* Native interaction cards (contract §5). All five `request_human_input`
* kinds render typed controls; the card never asks the user to type an answer
* into the composer.
*
* The submit handler posts to the package server, which stores the response in
* the mock control plane before the runner is resumed. The card only leaves
* `submitting` when the server acknowledges — there is no optimistic
* resolution here.
*/
export interface Phase7InteractionResponse {
interactionId: string;
outcome: "answered" | "accepted" | "rejected";
result: Record<string, unknown>;
}
const STATE_GLYPH: Record<Phase7ThreadInteractionState, string> = {
pending: "⏳",
submitting: "⏳",
accepted: "✓",
answered: "✓",
rejected: "✕",
stale_target: "⌛",
superseded_by_comment: "⌛",
expired: "⌛",
withdrawn: "⌛",
issue_closed: "⌛",
};
const RESOLVED_STATES = new Set<Phase7ThreadInteractionState>([
"accepted",
"answered",
"rejected",
"stale_target",
"superseded_by_comment",
"expired",
"withdrawn",
"issue_closed",
]);
function stateTone(state: Phase7ThreadInteractionState) {
if (state === "accepted" || state === "answered") return "success" as const;
if (state === "rejected") return "danger" as const;
if (state === "pending" || state === "submitting") return "accent" as const;
return undefined;
}
export function InteractionCard({
card,
onRespond,
onOpenEvidence,
autoFocus,
}: {
card: Phase7ThreadInteractionCard;
onRespond?: (response: Phase7InteractionResponse) => void;
onOpenEvidence?: (section: string, recordId: string) => void;
autoFocus?: boolean;
}) {
const titleId = useId();
const [answers, setAnswers] = useState<Record<string, string>>({});
const [selected, setSelected] = useState<string[]>(() =>
card.payload.kind === "checkbox"
? card.payload.options.filter((option) => option.defaultSelected).map((option) => option.id)
: card.payload.kind === "suggest_tasks"
? card.payload.tasks.map((task) => task.id)
: [],
);
const [verdicts, setVerdicts] = useState<Record<string, string>>({});
const [reasons, setReasons] = useState<Record<string, string>>({});
const [rejectReason, setRejectReason] = useState("");
const [error, setError] = useState<string | null>(null);
const firstControl = useRef<HTMLElement | null>(null);
const resolved = RESOLVED_STATES.has(card.state);
const busy = card.state === "submitting";
function respond(response: Phase7InteractionResponse) {
setError(null);
onRespond?.(response);
}
function toggle(list: string[], id: string): string[] {
return list.includes(id) ? list.filter((entry) => entry !== id) : [...list, id];
}
return (
<section
className="pit-interaction"
data-interaction-id={card.interactionId}
data-interaction-kind={card.interactionKind}
data-interaction-state={card.state}
aria-labelledby={titleId}
id={`interaction-${card.interactionId}`}
>
<div className="pit-card-head">
<h3 className="pit-interaction-title" id={titleId}>
{card.title}
</h3>
<Chip tone={stateTone(card.state)} testId="interaction-state-chip">
<span aria-hidden="true">{STATE_GLYPH[card.state]}</span>
{card.stateLabel}
</Chip>
</div>
<p className="pit-card-body">{card.prompt}</p>
{card.target !== null ? (
<p className="pit-card-meta">
Target:{" "}
<a href={card.target.href} data-testid="interaction-target">
{card.target.label}
</a>
</p>
) : null}
{card.payload.kind === "confirmation" ? (
<p className="pit-quote">{card.payload.targetSummary}</p>
) : null}
{resolved ? (
<div className="pit-interaction-controls">
{card.resolvedSummary.length > 0 ? (
<ul className="pit-summary-list">
{card.resolvedSummary.map((entry) => (
<li key={entry}>{entry}</li>
))}
</ul>
) : null}
{card.reason !== null ? <blockquote className="pit-quote">{card.reason}</blockquote> : null}
{card.supersededBy !== null ? (
<p className="pit-card-meta">
<a href={card.supersededBy.href}>{card.supersededBy.label}</a>
</p>
) : null}
</div>
) : (
<div className="pit-interaction-controls">
{card.payload.kind === "questions"
? card.payload.questions.map((question, index) => (
<div className="pit-field" key={question.id}>
<span className="pit-field-label" id={`${titleId}-${question.id}`}>
{question.prompt}
</span>
{question.control === "text" ? (
<input
className="pit-input"
type="text"
aria-labelledby={`${titleId}-${question.id}`}
disabled={busy}
value={answers[question.id] ?? ""}
onChange={(event) =>
setAnswers((current) => ({ ...current, [question.id]: event.target.value }))
}
/>
) : question.control === "select" ? (
<select
className="pit-select"
aria-labelledby={`${titleId}-${question.id}`}
disabled={busy}
value={answers[question.id] ?? ""}
onChange={(event) =>
setAnswers((current) => ({ ...current, [question.id]: event.target.value }))
}
>
<option value="">Choose…</option>
{(question.options ?? []).map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
) : (
<div role="radiogroup" aria-labelledby={`${titleId}-${question.id}`}>
{(question.options ?? []).map((option, optionIndex) => (
<label className="pit-option-row" key={option}>
<input
type="radio"
name={`${card.interactionId}-${question.id}`}
value={option}
disabled={busy}
ref={
index === 0 && optionIndex === 0
? (node) => {
firstControl.current = node;
if (autoFocus === true && node !== null) node.focus();
}
: undefined
}
checked={answers[question.id] === option}
onChange={() =>
setAnswers((current) => ({ ...current, [question.id]: option }))
}
/>
<span>{option}</span>
</label>
))}
</div>
)}
</div>
))
: null}
{card.payload.kind === "checkbox"
? card.payload.options.map((option, index) => (
<label className="pit-option-row" key={option.id}>
<input
type="checkbox"
disabled={busy}
checked={selected.includes(option.id)}
ref={
index === 0
? (node) => {
if (autoFocus === true && node !== null) node.focus();
}
: undefined
}
onChange={() => setSelected((current) => toggle(current, option.id))}
/>
<span>{option.label}</span>
</label>
))
: null}
{card.payload.kind === "suggest_tasks"
? card.payload.tasks.map((task) => (
<label className="pit-option-row" key={task.id}>
<input
type="checkbox"
disabled={busy}
checked={selected.includes(task.id)}
onChange={() => setSelected((current) => toggle(current, task.id))}
/>
<span>
<strong>{task.title}</strong>
<br />
<span className="pit-card-meta">{task.description}</span>
</span>
</label>
))
: null}
{card.payload.kind === "item_verdicts"
? card.payload.items.map((item) => (
<div className="pit-field" key={item.id}>
<span className="pit-field-label" id={`${titleId}-${item.id}`}>
{item.title}
</span>
<div
className="pit-segmented-verdicts"
role="radiogroup"
aria-labelledby={`${titleId}-${item.id}`}
>
{(["approve", "reject", "defer"] as const).map((verdict) => (
<button
key={verdict}
type="button"
className="pit-button"
role="radio"
aria-checked={
(item.lockedVerdict ?? verdicts[item.id] ?? "") === verdict
}
disabled={busy || item.lockedVerdict != null}
onClick={() =>
setVerdicts((current) => ({ ...current, [item.id]: verdict }))
}
>
{verdict}
</button>
))}
</div>
{item.requireReason === true ? (
<textarea
className="pit-textarea"
aria-label={`Reason for ${item.title}`}
disabled={busy || item.lockedVerdict != null}
value={reasons[item.id] ?? ""}
onChange={(event) =>
setReasons((current) => ({ ...current, [item.id]: event.target.value }))
}
/>
) : null}
</div>
))
: null}
{card.payload.kind === "confirmation" && card.payload.requireRejectReason ? (
<div className="pit-field">
<label className="pit-field-label" htmlFor={`${titleId}-reject-reason`}>
Reason (required to request changes)
</label>
<textarea
className="pit-textarea"
id={`${titleId}-reject-reason`}
disabled={busy}
value={rejectReason}
onChange={(event) => setRejectReason(event.target.value)}
/>
</div>
) : null}
{error !== null ? (
<p className="pit-composer-reason" role="alert">
{error}
</p>
) : null}
<div className="pit-button-row">
{card.payload.kind === "confirmation" ? (
<>
<button
type="button"
className="pit-button"
data-variant="primary"
disabled={busy}
ref={(node) => {
if (autoFocus === true && node !== null && firstControl.current === null) {
firstControl.current = node;
node.focus();
}
}}
onClick={() =>
respond({
interactionId: card.interactionId,
outcome: "accepted",
result: { accepted: true },
})
}
>
{card.payload.acceptLabel}
</button>
<button
type="button"
className="pit-button"
data-variant="destructive"
disabled={busy}
onClick={() => {
if (
card.payload.kind === "confirmation" &&
card.payload.requireRejectReason &&
rejectReason.trim().length === 0
) {
setError("A reason is required to request changes.");
return;
}
respond({
interactionId: card.interactionId,
outcome: "rejected",
result: { accepted: false, reason: rejectReason },
});
}}
>
{card.payload.rejectLabel}
</button>
</>
) : null}
{card.payload.kind === "questions" ? (
<button
type="button"
className="pit-button"
data-variant="primary"
disabled={busy}
onClick={() => {
const missing =
card.payload.kind === "questions"
? card.payload.questions.filter(
(question) =>
question.required === true &&
(answers[question.id] ?? "").trim().length === 0,
)
: [];
if (missing.length > 0) {
setError(`Answer every required question (${missing.length} remaining).`);
return;
}
respond({
interactionId: card.interactionId,
outcome: "answered",
result: { answers },
});
}}
>
{card.payload.submitLabel}
</button>
) : null}
{card.payload.kind === "checkbox" ? (
<>
<button
type="button"
className="pit-button"
data-variant="primary"
disabled={busy}
onClick={() => {
if (card.payload.kind !== "checkbox") return;
if (selected.length < card.payload.minSelected) {
setError(`Select at least ${card.payload.minSelected}.`);
return;
}
if (selected.length > card.payload.maxSelected) {
setError(`Select at most ${card.payload.maxSelected}.`);
return;
}
respond({
interactionId: card.interactionId,
outcome: "accepted",
result: { selected },
});
}}
>
{card.payload.acceptLabel}
</button>
<button
type="button"
className="pit-button"
data-variant="destructive"
disabled={busy}
onClick={() =>
respond({
interactionId: card.interactionId,
outcome: "rejected",
result: { selected: [] },
})
}
>
{card.payload.rejectLabel}
</button>
</>
) : null}
{card.payload.kind === "suggest_tasks" ? (
<button
type="button"
className="pit-button"
data-variant="primary"
disabled={busy}
onClick={() =>
respond({
interactionId: card.interactionId,
outcome: "accepted",
result: { acceptedTaskIds: selected },
})
}
>
{card.payload.acceptLabel}
</button>
) : null}
{card.payload.kind === "item_verdicts" ? (
<button
type="button"
className="pit-button"
data-variant="primary"
disabled={busy}
onClick={() => {
if (card.payload.kind !== "item_verdicts") return;
const submitted = card.payload.items.filter(
(item) => item.lockedVerdict == null && verdicts[item.id] !== undefined,
);
const missingReason = submitted.filter(
(item) =>
item.requireReason === true && (reasons[item.id] ?? "").trim().length === 0,
);
if (missingReason.length > 0) {
setError(`A reason is required for ${missingReason.length} item(s).`);
return;
}
if (submitted.length === 0) {
setError("Choose a verdict for at least one item.");
return;
}
respond({
interactionId: card.interactionId,
outcome: "answered",
result: {
verdicts: submitted.map((item) => ({
id: item.id,
verdict: verdicts[item.id],
reason: reasons[item.id] ?? null,
})),
},
});
}}
>
{card.payload.submitLabel}
</button>
) : null}
</div>
</div>
)}
<button
type="button"
className="pit-link-button"
onClick={() => onOpenEvidence?.(card.evidenceRef.section, card.evidenceRef.recordId)}
>
View request evidence
</button>
</section>
);
}
@@ -0,0 +1,235 @@
import { useEffect, useRef, useState } from "react";
import type { Phase7IssueThreadSnapshot } from "../../../src/issue-thread/types";
import { Chip, PriorityIcon, StatusBadge } from "./primitives";
/**
* Sticky issue header (contract §1 and §2). The three identity chips are never
* hidden by scroll, mode is data rather than styling, and on mobile Stop stays
* outside the overflow menu whenever a turn is active.
*/
export interface IssueHeaderProps {
snapshot: Phase7IssueThreadSnapshot;
scenarios: string[];
evidenceOpen: boolean;
denialCount: number;
segment: "thread" | "evidence";
onToggleEvidence: () => void;
onSelectScenario: (scenario: string) => void;
onReplay: () => void;
onReset: () => void;
onStop: () => void;
onSelectSegment: (segment: "thread" | "evidence") => void;
}
export function IssueHeader(props: IssueHeaderProps) {
const {
snapshot,
scenarios,
evidenceOpen,
denialCount,
segment,
onToggleEvidence,
onSelectScenario,
onReplay,
onReset,
onStop,
onSelectSegment,
} = props;
const [menuOpen, setMenuOpen] = useState(false);
const menuRef = useRef<HTMLDivElement | null>(null);
const turnActive = snapshot.composer.state === "streaming" || snapshot.composer.state === "sending";
useEffect(() => {
if (!menuOpen) return;
function onKeyDown(event: KeyboardEvent) {
if (event.key === "Escape") setMenuOpen(false);
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [menuOpen]);
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>
<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
? ` · ${snapshot.identity.replaySource} source`
: ""}
</Chip>
<Chip testId="runner-chip">
<span
className="pit-process-dot"
data-attached={snapshot.identity.runnerAttached}
aria-hidden="true"
/>
{snapshot.identity.runnerLabel}
<span className="pit-visually-hidden">
{snapshot.identity.runnerAttached ? "attached" : "detached"}
</span>
</Chip>
<Chip tone="mock" title={snapshot.identity.controlPlaneTooltip} testId="control-plane-chip">
{snapshot.identity.controlPlaneLabel}
</Chip>
</div>
<div className="pit-header-controls">
<span className="pit-run-state">{snapshot.issue.runState}</span>
<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 className="pit-menu pit-mobile-only" ref={menuRef}>
<button
type="button"
className="pit-button"
aria-expanded={menuOpen}
aria-haspopup="menu"
onClick={() => setMenuOpen((current) => !current)}
data-testid="overflow-menu-button"
>
<span aria-hidden="true">⋯</span>
<span className="pit-visually-hidden">More actions</span>
</button>
{menuOpen ? (
<div className="pit-menu-list" role="menu" data-testid="overflow-menu">
<label className="pit-menu-item" htmlFor="scenario-picker-mobile">
Scenario
<select
className="pit-select"
id="scenario-picker-mobile"
value={snapshot.issue.fixtureProfile}
onChange={(event) => {
setMenuOpen(false);
onSelectScenario(event.target.value);
}}
>
{scenarios.map((scenario) => (
<option key={scenario} value={scenario}>
{scenario}
</option>
))}
</select>
</label>
<button
type="button"
role="menuitem"
className="pit-menu-item"
onClick={() => {
setMenuOpen(false);
onReplay();
}}
>
Replay
</button>
<button
type="button"
role="menuitem"
className="pit-menu-item"
onClick={() => {
setMenuOpen(false);
onReset();
}}
>
Reset scenario
</button>
</div>
) : 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">
<button
type="button"
role="tab"
className="pit-segment"
aria-selected={segment === "thread"}
onClick={() => onSelectSegment("thread")}
data-testid="segment-thread"
>
Thread
</button>
<button
type="button"
role="tab"
className="pit-segment"
aria-selected={segment === "evidence"}
onClick={() => onSelectSegment("evidence")}
data-testid="segment-evidence"
>
Evidence
{denialCount > 0 ? (
<span className="pit-segment-badge" data-testid="segment-denial-badge">
{denialCount}
</span>
) : null}
</button>
</div>
</header>
);
}
@@ -0,0 +1,341 @@
import { useState } from "react";
import type {
Phase7EvidenceSectionId,
Phase7ThreadItem,
Phase7ThreadTurn,
} from "../../../src/issue-thread/types";
import { InteractionCard, type Phase7InteractionResponse } from "./InteractionCard";
import { Chip, StatusBadge, Timestamp, formatBytes } from "./primitives";
/** Progressive disclosure budget from contract §3 (T4). */
const VISIBLE_STRIPS = 3;
const STATUS_GLYPH = { ok: "✓", denied: "✕", running: "⏳" } as const;
export interface ThreadCallbacks {
onOpenEvidence: (section: Phase7EvidenceSectionId, recordId: string) => void;
onRespond: (response: Phase7InteractionResponse) => void;
focusInteractionId: string | null;
}
function ToolStrip({
item,
callbacks,
}: {
item: Extract<Phase7ThreadItem, { kind: "tool_activity" }>;
callbacks: ThreadCallbacks;
}) {
const [expanded, setExpanded] = useState(false);
return (
<div
id={item.id}
className="pit-strip"
data-thread-item="tool_activity"
data-status={item.status}
data-tool-strip={item.operationId}
>
<button
type="button"
className="pit-strip-button"
aria-expanded={expanded}
onClick={() => setExpanded((current) => !current)}
>
<span className="pit-strip-glyph" aria-hidden="true">
{STATUS_GLYPH[item.status]}
</span>
<span className="pit-visually-hidden">{item.status}</span>
<span className="pit-strip-operation">{item.operationId}</span>
<span className="pit-strip-summary">{item.summary}</span>
<span className="pit-strip-caret" aria-hidden="true">
{expanded ? "⌄" : "›"}
</span>
</button>
{expanded ? (
<div className="pit-strip-detail">
<pre className="pit-code">{JSON.stringify(item.input, null, 2)}</pre>
<pre className="pit-code">{JSON.stringify(item.result, null, 2)}</pre>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View in Evidence
</button>
</div>
) : null}
</div>
);
}
function ThreadItemView({
item,
callbacks,
}: {
item: Phase7ThreadItem;
callbacks: ThreadCallbacks;
}) {
switch (item.kind) {
case "user_message":
return (
<article id={item.id} className="pit-card" data-thread-item="user_message">
<div className="pit-card-head">
<span className="pit-card-author">{item.author}</span>
<Timestamp value={item.at} />
</div>
<div className="pit-card-body">{item.body}</div>
</article>
);
case "agent_message":
return (
<article
id={item.id}
className="pit-card"
data-thread-item="agent_message"
data-streaming={item.streaming}
>
<div className="pit-card-head">
<span className="pit-card-author">{item.author}</span>
<Timestamp value={item.at} />
{item.streaming ? (
<Chip tone="accent" testId="streaming-indicator">
<span aria-hidden="true">⏳</span>
Streaming
</Chip>
) : null}
</div>
<div className="pit-card-body">{item.body}</div>
</article>
);
case "durable_comment":
return (
<article id={item.id} className="pit-card" data-thread-item="durable_comment">
<div className="pit-card-head">
<span className="pit-card-author">{item.author}</span>
<Timestamp value={item.at} />
<span
className="pit-durable-tag"
title={`Recorded by the ${item.operationId} semantic operation.`}
>
<span aria-hidden="true">◆</span>
Recorded to mock thread
</span>
</div>
<div className="pit-card-body">{item.body}</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View in Evidence
</button>
</article>
);
case "tool_activity":
return <ToolStrip item={item} callbacks={callbacks} />;
case "denial":
return (
<div id={item.id} className="pit-strip pit-denial" data-status="denied" data-thread-item="denial">
<div className="pit-strip-button">
<span className="pit-strip-glyph" aria-hidden="true">
✕
</span>
<span className="pit-strip-operation">{item.operationId}</span>
<span className="pit-strip-summary" data-testid="denial-reason">
{item.reason}
</span>
</div>
<div className="pit-strip-detail">
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View in Evidence
</button>
</div>
</div>
);
case "interaction":
return (
<InteractionCard
card={item}
autoFocus={callbacks.focusInteractionId === item.interactionId}
onRespond={callbacks.onRespond}
onOpenEvidence={(section, recordId) =>
callbacks.onOpenEvidence(section as Phase7EvidenceSectionId, recordId)
}
/>
);
case "document":
return (
<article id={item.id} className="pit-card" data-thread-item="document">
<div className="pit-card-head">
<span className="pit-card-author">{item.title}</span>
<span className="pit-card-meta">{item.documentKey}</span>
<Timestamp value={item.at} />
</div>
<div className="pit-card-body">
<span className="pit-card-meta" data-testid="revision-chain">
{item.revisionFrom === null ? `r${item.revisionTo}` : `r${item.revisionFrom} → r${item.revisionTo}`}
</span>
{" · "}
{item.author}
{item.staleBehind !== null ? (
<>
{" "}
<Chip>
<span aria-hidden="true">⌛</span>
Stale — {item.staleBehind} newer revision(s)
</Chip>
</>
) : null}
</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
View diff
</button>
</article>
);
case "deliverable":
return (
<article id={item.id} className="pit-card" data-thread-item="deliverable">
<div className="pit-card-head">
<span className="pit-card-author">{item.filename}</span>
<Timestamp value={item.at} />
</div>
<div className="pit-card-body">
<span className="pit-card-meta">
{item.deliverableKind} · {formatBytes(item.byteSize)} · registered by {item.registeredBy}
</span>
</div>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
Download {item.filename}
</button>
</article>
);
case "dependency":
return (
<article id={item.id} className="pit-card" data-thread-item="dependency">
<div className="pit-card-head">
<span className="pit-card-author">Delegation</span>
<Timestamp value={item.at} />
</div>
<ul className="pit-summary-list">
{item.createdTasks.map((task) => (
<li key={task.identifier}>
<span className="pit-record-title">{task.identifier}</span> {task.title}
</li>
))}
{item.blockerEdges.map((edge) => (
<li key={edge}>{edge}</li>
))}
</ul>
</article>
);
case "disposition":
return (
<article id={item.id} className="pit-card pit-terminal-card" data-thread-item="disposition">
<div className="pit-card-head">
<StatusBadge status={item.status} />
<span className="pit-card-meta">{item.operationId}</span>
<Timestamp value={item.at} />
</div>
<div className="pit-card-body">{item.body}</div>
{item.blockerOwner !== null ? (
<p className="pit-card-meta">Blocker owner: {item.blockerOwner}</p>
) : null}
</article>
);
case "system_notice":
return (
<p id={item.id} className="pit-notice" data-thread-item="system_notice">
<span aria-hidden="true">{item.glyph}</span>
<span className="pit-notice-text">{item.text}</span>
<button
type="button"
className="pit-link-button"
onClick={() =>
callbacks.onOpenEvidence(item.evidenceRef.section, item.evidenceRef.recordId)
}
>
Details
</button>
</p>
);
}
}
export function TurnGroup({
turn,
callbacks,
}: {
turn: Phase7ThreadTurn;
callbacks: ThreadCallbacks;
}) {
const [showAllStrips, setShowAllStrips] = useState(false);
const stripIndexes = turn.items
.map((item, index) => (item.kind === "tool_activity" ? index : -1))
.filter((index) => index >= 0);
const hiddenStripIndexes = new Set(
showAllStrips ? [] : stripIndexes.slice(VISIBLE_STRIPS),
);
return (
<section className="pit-turn" data-turn-id={turn.id} aria-label={`Turn ${turn.ordinal}`}>
<h2 className="pit-turn-header">
<span>
Turn {turn.ordinal} · {turn.mode} · {turn.toolCallCount} tool call
{turn.toolCallCount === 1 ? "" : "s"} ·{" "}
{new Date(turn.at).toISOString().slice(11, 19)}
</span>
{turn.stoppedByUser ? (
<span className="pit-stopped-marker" data-testid="stopped-marker">
Stopped by user
</span>
) : null}
</h2>
{turn.items.map((item, index) =>
hiddenStripIndexes.has(index) ? null : (
<ThreadItemView key={item.id} item={item} callbacks={callbacks} />
),
)}
{hiddenStripIndexes.size > 0 ? (
<button
type="button"
className="pit-more-button"
onClick={() => setShowAllStrips(true)}
>
{hiddenStripIndexes.size} more…
</button>
) : null}
</section>
);
}
@@ -0,0 +1,82 @@
import type {
Phase7IssueThreadSnapshot,
Phase7ThreadInteractionState,
Phase7ThreadItem,
} from "../../../src/issue-thread/types";
import type { Phase7InteractionResponse } from "./InteractionCard";
/**
* `fake`-mode interaction store.
*
* The screenshot matrix runs without a session server, so this module plays
* the mock control plane's role for the deterministic fixtures only: it stores
* the response and then hands back a new snapshot, mirroring the live authority
* path (store first, resume second) rather than resolving the card optimistically
* in the card component. Live mode never reaches this code — it posts to the
* package server instead.
*/
function resolvedState(
outcome: Phase7InteractionResponse["outcome"],
): { state: Phase7ThreadInteractionState; label: string } {
if (outcome === "accepted") return { state: "accepted", label: "Accepted" };
if (outcome === "rejected") return { state: "rejected", label: "Changes requested" };
return { state: "answered", label: "Answered" };
}
function summarise(response: Phase7InteractionResponse): string[] {
const result = response.result;
if (Array.isArray(result.selected)) return result.selected.map((entry) => String(entry));
if (Array.isArray(result.acceptedTaskIds)) {
return result.acceptedTaskIds.map((entry) => `Accepted ${String(entry)}`);
}
if (Array.isArray(result.verdicts)) {
return result.verdicts.map((entry) => {
const verdict = entry as { id?: unknown; verdict?: unknown };
return `${String(verdict.id)} — ${String(verdict.verdict)}`;
});
}
if (typeof result.answers === "object" && result.answers !== null) {
return Object.entries(result.answers as Record<string, unknown>).map(
([key, value]) => `${key} — ${String(value)}`,
);
}
if (result.accepted === true) return ["Accepted"];
return [];
}
function applyToItem(
item: Phase7ThreadItem,
response: Phase7InteractionResponse,
): Phase7ThreadItem {
if (item.kind !== "interaction" || item.interactionId !== response.interactionId) return item;
const view = resolvedState(response.outcome);
return {
...item,
state: view.state,
stateLabel: view.label,
resolvedSummary: summarise(response),
reason: typeof response.result.reason === "string" ? response.result.reason : null,
};
}
/** Store the typed response, then return the snapshot the card renders from. */
export function applyFakeInteractionResponse(
snapshot: Phase7IssueThreadSnapshot,
response: Phase7InteractionResponse,
): Phase7IssueThreadSnapshot {
const turns = snapshot.turns.map((turn) => ({
...turn,
items: turn.items.map((item) => applyToItem(item, response)),
}));
const stillPending = turns.some(
(turn) => turn.items.some((item) => item.kind === "interaction" && item.state === "pending"),
);
return {
...snapshot,
turns,
composer: stillPending
? snapshot.composer
: { state: "ready", helper: null, reason: null, pendingInteractionId: null },
};
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,79 @@
import type { Phase7IssueThreadSnapshot } from "../../../src/issue-thread/types";
/**
* Browser client for the package session server.
*
* Every response is a server-projected `Phase7IssueThreadSnapshot`. The client
* posts intents and renders what comes back; it never patches the snapshot
* locally, which is what keeps policy and state authority on the server
* (contract §11).
*/
const BASE = "/api/phase7/ui";
export interface Phase7LiveResponse {
sessionId: string;
view: Phase7IssueThreadSnapshot;
}
async function post(path: string, body: unknown): Promise<Phase7LiveResponse> {
const response = await fetch(`${BASE}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`${path} failed with ${response.status}`);
}
return (await response.json()) as Phase7LiveResponse;
}
export const phase7LiveClient = {
async load(sessionId: string | null): Promise<Phase7LiveResponse> {
const query = sessionId === null ? "" : `?sessionId=${encodeURIComponent(sessionId)}`;
const response = await fetch(`${BASE}/session${query}`);
if (!response.ok) throw new Error(`session load failed with ${response.status}`);
return (await response.json()) as Phase7LiveResponse;
},
create(scenario: string): Promise<Phase7LiveResponse> {
return post("/session", { scenario });
},
send(sessionId: string, message: string): Promise<Phase7LiveResponse> {
return post("/message", { sessionId, message });
},
stop(sessionId: string): Promise<Phase7LiveResponse> {
return post("/interrupt", { sessionId });
},
reset(sessionId: string): Promise<Phase7LiveResponse> {
return post("/reset", { sessionId });
},
reconnect(sessionId: string): Promise<Phase7LiveResponse> {
return post("/reconnect", { sessionId });
},
respond(
sessionId: string,
interactionId: string,
outcome: string,
result: unknown,
): Promise<Phase7LiveResponse> {
return post("/interaction", { sessionId, interactionId, outcome, result });
},
};
const SESSION_STORAGE_KEY = "paperclip-runner.phase7.session";
export function rememberSession(sessionId: string): void {
try {
window.localStorage.setItem(SESSION_STORAGE_KEY, sessionId);
} catch {
// Refresh restore falls back to a fresh session when storage is blocked.
}
}
export function recallSession(): string | null {
try {
return window.localStorage.getItem(SESSION_STORAGE_KEY);
} catch {
return null;
}
}
@@ -0,0 +1,24 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "./App";
import "./issue-thread.css";
// `?capture=1` freezes animation, caret, and smooth scrolling so the
// screenshot matrix is byte-stable across runs (contract §10.1).
const params = new URLSearchParams(window.location.search);
const hashQuery = window.location.hash.split("?")[1] ?? "";
if (params.get("capture") === "1" || new URLSearchParams(hashQuery).get("capture") === "1") {
document.documentElement.dataset.capture = "true";
}
const root = document.getElementById("root");
if (root === null) {
throw new Error("Phase 7 issue-thread root element is missing");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);
@@ -0,0 +1,82 @@
import type { ReactNode } from "react";
import type { Phase7TaskStatus } from "../../../src/mock-core/phase7-control-plane-types";
/**
* Package-local equivalents of the product StatusBadge/chip anatomy. Every
* state pairs color with a glyph and text so the surface stays readable
* without color (contract §9.5).
*/
const STATUS_COPY: Record<Phase7TaskStatus, { label: string; glyph: string }> = {
backlog: { label: "Backlog", glyph: "○" },
todo: { label: "Todo", glyph: "○" },
in_progress: { label: "In progress", glyph: "◐" },
in_review: { label: "In review", glyph: "◑" },
done: { label: "Done", glyph: "✓" },
blocked: { label: "Blocked", glyph: "✕" },
cancelled: { label: "Cancelled", glyph: "–" },
};
const PRIORITY_GLYPH: Record<string, string> = {
critical: "▲▲",
high: "▲",
medium: "▬",
low: "▼",
};
export function StatusBadge({ status }: { status: Phase7TaskStatus }) {
const copy = STATUS_COPY[status];
return (
<span className="pit-status-badge" data-status={status}>
<span aria-hidden="true">{copy.glyph}</span>
{copy.label}
</span>
);
}
export function PriorityIcon({ priority }: { priority: string }) {
return (
<span className="pit-priority">
<span aria-hidden="true">{PRIORITY_GLYPH[priority] ?? "▬"}</span>{" "}
{priority.charAt(0).toUpperCase() + priority.slice(1)} priority
</span>
);
}
export function Chip({
tone,
children,
title,
testId,
}: {
tone?: "live" | "mock" | "accent" | "success" | "danger";
children: ReactNode;
title?: string;
testId?: string;
}) {
return (
<span className="pit-chip" data-tone={tone} title={title} data-testid={testId}>
{children}
</span>
);
}
export function Timestamp({ value }: { value: string }) {
// Rendered from fixture data with a fixed locale so captures stay stable.
const stamp = new Date(value);
const hours = String(stamp.getUTCHours()).padStart(2, "0");
const minutes = String(stamp.getUTCMinutes()).padStart(2, "0");
const seconds = String(stamp.getUTCSeconds()).padStart(2, "0");
return (
<time className="pit-card-meta" dateTime={value}>
{hours}:{minutes}:{seconds}
</time>
);
}
export function formatBytes(byteSize: number): string {
if (byteSize < 1024) return `${byteSize} B`;
if (byteSize < 1024 * 1024) return `${(byteSize / 1024).toFixed(1)} kB`;
return `${(byteSize / (1024 * 1024)).toFixed(1)} MB`;
}
@@ -0,0 +1,78 @@
/**
* Deterministic route scheme from the Phase 7B UX contract §10.1.
*
* `#/issue/<fixtureProfile>?shot=<slug>&panel=<section>&rec=<id>&at=<ordinal>&seg=thread|evidence`
*
* Parameters are also accepted on the document query string so a capture tool
* can address a state without depending on hash-fragment escaping.
*/
import type { Phase7EvidenceSectionId } from "../../../src/issue-thread/types";
import { PHASE7_EVIDENCE_SECTIONS } from "../../../src/issue-thread/types";
import { PHASE7_DEFAULT_FIXTURE_PROFILE } from "../../../src/issue-thread/fixtures";
export interface Phase7Route {
fixtureProfile: string;
shot: string | null;
panel: Phase7EvidenceSectionId | null;
record: string | null;
at: number | null;
segment: "thread" | "evidence";
/** `live` opts into the package session server; otherwise fixtures render. */
mode: "fake" | "live" | "replay";
}
const SECTION_IDS = new Set<string>(PHASE7_EVIDENCE_SECTIONS.map((section) => section.id));
function mergeParams(search: string, hash: string): URLSearchParams {
const params = new URLSearchParams(search);
const queryIndex = hash.indexOf("?");
if (queryIndex >= 0) {
for (const [key, value] of new URLSearchParams(hash.slice(queryIndex + 1))) {
params.set(key, value);
}
}
return params;
}
export function parsePhase7Route(url: {
search: string;
hash: string;
}): Phase7Route {
const params = mergeParams(url.search, url.hash);
const path = url.hash.replace(/^#/, "").split("?")[0] ?? "";
const match = /^\/issue\/([^/?]+)/.exec(path);
const shot = params.get("shot");
const requestedMode = params.get("mode");
const panel = params.get("panel");
const at = params.get("at");
const segment = params.get("seg");
const mode =
requestedMode === "live" || requestedMode === "replay" || requestedMode === "fake"
? requestedMode
: shot === "replay-mode"
? "replay"
: "fake";
return {
fixtureProfile: match?.[1] ?? PHASE7_DEFAULT_FIXTURE_PROFILE,
shot,
panel: panel !== null && SECTION_IDS.has(panel) ? (panel as Phase7EvidenceSectionId) : null,
record: params.get("rec"),
at: at !== null && /^\d+$/.test(at) ? Number.parseInt(at, 10) : null,
segment: segment === "evidence" ? "evidence" : "thread",
mode,
};
}
export function phase7RouteHref(route: Phase7Route, overrides: Partial<Phase7Route>): string {
const next = { ...route, ...overrides };
const params = new URLSearchParams();
if (next.shot !== null) params.set("shot", next.shot);
if (next.mode !== "fake") params.set("mode", next.mode);
if (next.panel !== null) params.set("panel", next.panel);
if (next.record !== null) params.set("rec", next.record);
if (next.at !== null) params.set("at", String(next.at));
if (next.segment !== "thread") params.set("seg", next.segment);
const query = params.toString();
return `#/issue/${next.fixtureProfile}${query.length > 0 ? `?${query}` : ""}`;
}
@@ -194,7 +194,7 @@ from the page.
| `accepted` | status `accepted` | Green check chip `Accepted`, chosen values summarized inline, controls collapse to read-only summary. |
| `answered` | status `answered` | Same as accepted with `Answered` chip (questions / verdicts complete). |
| `rejected` | status `rejected` | Red chip `Changes requested`, reason quoted in the card. |
| `stale_target` | status `expired`, `result.outcome=stale_target` | Gray chip `Stale — plan moved to r5`, link to the superseding revision, controls removed, body dimmed (60% opacity). |
| `stale_target` | status `expired`, `result.outcome=stale_target` | Gray chip `Stale — plan moved to r5`, link to the superseding revision, controls removed, body dimmed (see revision 2). |
| `superseded_by_comment` | status `expired`, `result.outcome=superseded_by_comment` | Gray chip `Superseded by a later comment`, link to that comment. |
| `expired` | status `expired`, no outcome | Gray chip `Expired <relative time>`. |
| `withdrawn` | status `cancelled`, `result.outcome=withdrawn` | Gray chip `Withdrawn`, optional reason. |
@@ -389,3 +389,23 @@ Implementation acceptance (UXDesigner review) requires:
7. Screenshot review posted to the 7G issue for UXDesigner acceptance before 7G closes.
Questions or gaps → comment on PAP-16940.
---
## Revisions
### Revision 2 — expired-family dimming (2026-08-10, written back by 7G)
Revision 1 specified a literal 60% opacity on the body of `stale_target` and the
other expired-family cards (§5). Measured against the card surface in the dark
theme, that renders the card's text at ~3.16:1, which fails §9.5's 4.5:1 bar and
the blocking axe gate in §9.7 — the two rules cannot both hold.
§9 wins because it is the blocking gate. "Dimmed" is now specified as a
**recessed treatment**: the card drops to the sunken surface token and its
secondary text drops to the muted-foreground token, both of which clear 4.5:1.
The gray state chip, removed controls, and neutral left border are unchanged, so
the card still reads as history at a glance.
Implemented in `devtools/issue-thread/src/issue-thread.css` and covered by the
axe gate on the `interaction-resolved-mixed` slug at both viewports.
+2
View File
@@ -29,6 +29,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase
- [Phase 6 thin Paperclip adapter](phase-06-thin-paperclip-adapter.md)
- [Phase 7 semantic catalog and authorization](phase-07-semantic-catalog.md)
- [Phase 7 live runnerd and Codex loop](phase-07-live-runnerd-codex.md)
- [Phase 7 Paperclip-style issue-thread UI](phase-07-issue-thread-ui.md)
- [Engineering journal guide](journal.md)
- [Dated shadcn/ui and AI Elements compatibility note](research/2026-08-07-ui-library-compatibility.md)
- [Phase 4b live-console interaction map](design/phase-4b-interaction-map.md)
@@ -36,6 +37,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase
- [Phase 5 component and SDK surface plan](design/phase-5-component-plan.md)
- [Phase 5 SDK extraction decision record](design/phase-5-component-decisions.md)
- [Phase 6 thin Paperclip adapter boundary](design/phase-6-thin-paperclip-adapter.md)
- [Phase 7 issue-thread UX contract](design/phase-7-issue-thread-ux-contract.md)
- [Package README](../README.md)
Phase 6 is implemented as a default-off server integration at the public
@@ -0,0 +1,129 @@
# Phase 7 Paperclip-style issue-thread UI
Phase 7G renders the mock Paperclip issue as a native issue thread. A board user
reads the thread, answers typed interactions inline, and inspects the evidence
behind every mock mutation. The implementation follows the binding
[Phase 7B issue-thread UX contract](design/phase-7-issue-thread-ux-contract.md).
## Authority boundary
```text
browser -> package server -> Phase7LiveSession -> paperclip-runnerd -> codex
(projection) Phase7SemanticDispatcher -> ControlPlanePort mock
```
The browser holds no authority. It renders one shape,
`Phase7IssueThreadSnapshot` (`src/issue-thread/types.ts`), and computes no
claim, policy decision, state diff, or parity verdict. Two producers emit that
shape:
- `phase7IssueThreadFixture(slug)` — deterministic `fake` snapshots used by the
screenshot matrix and the browser suite.
- `projectPhase7IssueThread({ snapshot })` — the live projection. It runs in the
package server and rearranges durable records only: the live session's
transcript, evidence entries, semantic authorization records, and the
serialized mock state.
The one browser-initiated mock mutation is an interaction response. It posts to
`POST /api/phase7/ui/interaction`; `Phase7LiveSession.resolveInteraction` stores
the typed response in the mock control plane **before** resuming the same Codex
thread, so the card only leaves `submitting` on server acknowledgement.
No provider, runner, or control-plane credential reaches the page. Redacted
fields render as `••• redacted` with the redaction rule name, mock issues use
the reserved `MCK-` prefix, and no real Paperclip URL is ever rendered.
## Surfaces
- **Header** — three identity chips (`Real Codex` / `Fake agent` / `Replay`,
`Real runnerd` / `In-process runner`, and `Mock Paperclip` in every mode),
status, priority, run state, and the Scenario/Replay/Reset/Stop controls.
`data-session-mode` carries the mode as data, never as styling.
- **Thread** — turn groups binding the contract's T1–T11 item types: user
messages, model prose, durable progress comments marked
`Recorded to mock thread`, collapsed tool strips, interaction cards, document
revision cards, deliverables, delegation cards, terminal dispositions, typed
denials, and muted system notices.
- **Composer** — six mutually exclusive states behind `data-composer-state`:
`ready`, `sending`, `streaming` (input stays editable to steer; Stop is
primary), `waiting`, `reconnecting`, and `disabled`. Drafts survive refresh.
- **Evidence panel** — eight accordion sections in fixed order: Tools exposed,
Calls & results, Authorization, Control plane, Runner & events, State diff,
Traceability, Parity. The Tools section groups `Agent tool — always`,
`Agent tool — granted` (with its grant), and a separated
`Control plane (not exposed to the agent)` list, because what the model
*cannot* call is first-class evidence. Every strip, denial, and card deep-links
into the matching record, and each record links back to its thread anchor.
The panel is collapsed by default and resizable between 320px and 640px with a
keyboard-operable splitter. Below 1100px it becomes an overlay sheet; below
768px the page switches to a `Thread` / `Evidence` segmented control, with Stop
kept outside the `⋯` menu while a turn is active.
## Routes
```text
#/issue/<fixtureProfile>?shot=<slug>&panel=<section>&rec=<id>&at=<ordinal>&seg=thread|evidence&mode=live
```
- `shot` seeds one of the twelve deterministic `fake` states.
- `mode=live` opts into the package session server; the default is `fake`.
- `capture=1` freezes animation, caret, and smooth scrolling for screenshots.
- The root element sets `data-thread-state="settled"` once hydration, fixture
load, and auto-scroll finish. Tooling waits for that attribute, never a
timeout.
## Commands
```sh
# Deterministic fake-mode app (no provider process)
pnpm --filter @paperclipai/paperclip-runner console:phase7
# Focused browser suite, including the axe gate on all 12 slugs × 2 viewports
pnpm --filter @paperclipai/paperclip-runner test:browser:phase7
# View-model and live-projection unit tests
pnpm --filter @paperclipai/paperclip-runner exec vitest run src/issue-thread
# Screenshot matrix (12 slugs × 2 viewports) and its byte-stability check
pnpm --filter @paperclipai/paperclip-runner record:phase7:ui
pnpm --filter @paperclipai/paperclip-runner check:phase7:ui
# Real runnerd + real Codex through the same HTTP routes the browser uses
pnpm --filter @paperclipai/paperclip-runner smoke:phase7:ui
pnpm --filter @paperclipai/paperclip-runner record:phase7:ui:live
```
Hosts without the Playwright chromium system libraries can either run
`pnpm --filter @paperclipai/paperclip-runner verify:rootless` or set
`PAPERCLIP_RUNNER_CHROMIUM_PATH` to a preinstalled Chromium.
## Accessibility
The suite enforces the contract's blocking gate: axe reports zero serious or
critical WCAG 2.1 A/AA violations on every screenshot route at both viewports.
Structure is one `h1`, `header`/`main`/`complementary` landmarks, a `form`
composer, `section` interaction cards labelled by their prompt, and tool strips
as disclosure buttons with `aria-expanded`. Focus moves to the Evidence heading
when the panel opens and to the pending card's first control from the `waiting`
composer anchor. Every state chip pairs color with a glyph and text, all
actionable controls clear 44×44 CSS px on mobile, and `prefers-reduced-motion`
disables the pulse dot, banner slide, and smooth scrolling.
## Contract deviations
One deviation is recorded against the Phase 7B contract:
- **§5 expired-family dimming.** The contract asks for a 60% opacity body on
`stale_target` and the other expired outcomes. A literal opacity drops that
card's text to ~3.2:1 and fails the blocking axe gate in §9.7. The dim is
implemented as a recessed surface plus muted text that still clears 4.5:1.
## Determinism notes
Fake-mode fixtures render from authored data with a fixed clock, so two captures
of a slug from a clean checkout are byte-identical. Live sessions are not
byte-stable — a real model writes their prose — so live evidence lives in
`knowledge/evidence/phase-07/ui-live/` and is excluded from the determinism gate.
Durable comments in live mode carry the mock control plane's own deterministic
clock rather than wall time, because that is the timestamp on the mock record.
@@ -0,0 +1,108 @@
---
type: Verification Evidence
title: Phase 7G issue-thread UI verification
description: Commands, versions, and results for the package-local issue thread, its deterministic screenshot matrix, its accessibility gate, and its live runnerd/Codex smoke.
tags: [native-runner, phase-7, ui, screenshots, accessibility, live-codex]
status: stable
generated: { by: anthropic/claude-opus-5, at: 2026-08-10T04:05:00Z }
entry_kind: evidence
phase: "7"
---
# Environment
* Node 22.22.2, pnpm workspace package `@paperclipai/paperclip-runner`.
* Playwright 1.62.1, Chromium 151.0.7922.34, axe-core 4.13.0.
* Codex CLI 0.132.0 with a local authenticated installation.
* Linux aarch64. `PAPERCLIP_RUNNER_CHROMIUM_PATH` pointed at a preinstalled
Chromium because the Playwright download lacked system libraries on this host;
`pnpm verify:rootless` is the alternative.
# Static and unit results
| Command | Result |
|---------|--------|
| `pnpm typecheck:typescript` | passed |
| `pnpm typecheck:browser` | passed |
| `pnpm check:browser-tokens` | passed — browser surfaces reference semantic tokens only |
| `pnpm check:forbidden-imports` | passed |
| `pnpm docs:validate` | passed |
| `pnpm exec vitest run src/issue-thread/issue-thread.test.ts` | 16/16 passed |
| `pnpm test:phase7` | 23/23 passed |
The view-model suite asserts the contract directly: all twelve §10.2 slugs build
a schema-valid snapshot, each slug is byte-identical across two builds, the
matrix covers every §3 thread item type and the §5 interaction state matrix and
every §4 composer state, the denial strip quotes its authorization record
verbatim, replay is labelled fake-derived, the withheld control-plane list is
non-empty, and no fixture or projected view matches a credential pattern.
# Browser suite
`pnpm exec playwright test --config devtools/issue-thread/playwright.config.ts`
— **46/46 passed**.
Functional coverage: baseline item types and turn headers; durable comments
distinguished from model prose; collapsed tool strips with `aria-expanded` and
`View in Evidence` deep links; a pending question card refusing an incomplete
submit and then resolving inline; a revision-bound confirmation requiring a
reject reason; four distinct resolved/expired treatments in history; a verbatim
denial with its Evidence badge; the eight-section panel with turn selector and
the `Control plane (not exposed to the agent)` group; Evidence records linking
back to their thread anchor; keyboard splitter resize; the reset confirm dialog
with default-focused Cancel and Escape dismissal; Stop preserving partial output
with a `Stopped by user` marker; the reconnect banner and its `Reconnected`
confirmation; read-only replay; a terminal disposition disabling the composer;
draft survival across refresh; the 390px no-horizontal-scroll assertion; the
mobile denial badge; 44px touch targets; Escape closing the `⋯` menu; focus
moving to the Evidence heading; and the `waiting` composer anchor.
Accessibility gate: axe (WCAG 2.1 A/AA) on all **12 slugs × 2 viewports** —
zero serious or critical violations.
# Screenshot matrix
```sh
node scripts/record-phase7-ui-evidence.mjs # Recorded 24 Phase 7 UI screenshots
node scripts/record-phase7-ui-evidence.mjs --check # All 24 reproduce byte-for-byte
```
Output: `knowledge/evidence/phase-07/ui/<slug>--<desktop|mobile>.png`, twelve
slugs at 1440×900 and 390×844. Every mobile capture asserts
`scrollWidth - clientWidth <= 0` on the scrolling element before the shot. All
frames are deterministic `fake` mode rendered from package fixtures, so no
provider, runner process, or credential is involved.
# Live runnerd and Codex
```sh
node scripts/phase7-issue-thread-smoke.mjs --json
```
Result: session created, 2 turns, 2 tool calls, 30 authorization records, and
every assertion true — `liveIdentity` (`Real Codex` / `Real runnerd` /
`Mock Paperclip`), `mockIdentifier` (`MCK-`), `userMessageRendered`,
`toolActivityRendered`, `durableCommentRendered`, `authorizationRecorded`,
`callsRecorded`, `controlPlaneWithheld`, `multiTurnThread`, `composerReady`,
and `noCredentialInView`.
```sh
node scripts/record-phase7-ui-live-evidence.mjs
```
Captured `knowledge/evidence/phase-07/ui-live/` — a real browser driving a real
Codex turn through the package server, the same session with the Evidence panel
open, and the mobile frame. The recorder fails if the identity chips do not read
live or if the live thread scrolls horizontally at 390px. These frames are not
byte-stable by construction and are excluded from the determinism gate.
The supporting Phase 7E smoke was re-run on the same tree:
`pnpm trace:phase7 -- --json` passed all twelve assertions, including
`noRealPaperclipRequest`, `noPaperclipAuthorityInChild`, `authorityCleared`, and
`runnerExited`.
# Deviations
* Phase 7B contract §5 asks for 60% opacity on expired-family interaction cards.
Measured 3.16:1, which fails the blocking axe gate in §9.7. Implemented as a
recessed surface plus muted text at ≥4.5:1 and reported to the contract owner.
@@ -13,3 +13,8 @@
* [Phase 4b QA verification](2026-08-08-phase-04b-qa-verification.md) - Independent QA pass: clean-start tutorial in a real browser, deterministic demos, real Codex end-to-end session, full `verify` acceptance (exit 0), and screenshots (PAP-16837).
* [Phase 5 browser SDK verification](2026-08-08-phase-05-verification.md) - Targeted and package-acceptance evidence for the versioned SDK, both public consumers, fake/real drivers, accessibility, reconnect, replay, and screenshots.
* [Phase 6 thin Paperclip adapter verification](2026-08-09-phase-06-verification.md) - Mock and database-backed conformance, authoritative completion, restart recovery, migration repair, atomic liveness, internal canary, and deterministic post-kill-switch legacy evidence.
* [Phase 7G issue-thread UI verification](2026-08-10-phase-07g-issue-thread-ui-verification.md) - View-model tests, 46 browser tests, the axe gate on 12 slugs × 2 viewports, the byte-stable 24-PNG matrix, and the live runnerd/Codex smoke.
## Screenshot collections
* [Phase 7 screenshot collections](phase-07/) - Deterministic issue-thread UI matrix and live runnerd/Codex frames.
@@ -0,0 +1,4 @@
# Phase 7 evidence collections
* [Issue-thread UI matrix](ui/index.md) - Deterministic `fake`-mode captures for the 12 contract slugs at 1440×900 and 390×844; byte-stable across clean runs.
* [Issue-thread live evidence](ui-live/index.md) - Real `paperclip-runnerd` and Codex frames; intentionally not byte-stable.
@@ -0,0 +1,11 @@
# Phase 7 issue-thread live evidence
Recorded by `pnpm --filter @paperclipai/paperclip-runner record:phase7:ui:live`.
Real `paperclip-runnerd` and a real Codex app-server drive these frames against
the deterministic mock control plane. A real model writes the prose, so these
PNGs are **not** byte-stable and are excluded from the §10.2 determinism gate.
- `live-codex-turn--desktop.png`
- `live-codex-evidence--desktop.png`
- `live-codex-turn--mobile.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 99 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 70 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

@@ -0,0 +1,29 @@
# Phase 7 issue-thread UI evidence
Recorded by `pnpm --filter @paperclipai/paperclip-runner record:phase7:ui`.
Deterministic `fake` mode; no provider, runner, or credential is involved.
- `debug-panel-open--desktop.png`
- `debug-panel-open--mobile.png`
- `deliverable-registered--desktop.png`
- `deliverable-registered--mobile.png`
- `denial-optional-tool--desktop.png`
- `denial-optional-tool--mobile.png`
- `disposition-terminal--desktop.png`
- `disposition-terminal--mobile.png`
- `document-revision--desktop.png`
- `document-revision--mobile.png`
- `interaction-confirmation-pending--desktop.png`
- `interaction-confirmation-pending--mobile.png`
- `interaction-question-pending--desktop.png`
- `interaction-question-pending--mobile.png`
- `interaction-resolved-mixed--desktop.png`
- `interaction-resolved-mixed--mobile.png`
- `reconnect-banner--desktop.png`
- `reconnect-banner--mobile.png`
- `replay-mode--desktop.png`
- `replay-mode--mobile.png`
- `thread-baseline--desktop.png`
- `thread-baseline--mobile.png`
- `turn-streaming--desktop.png`
- `turn-streaming--mobile.png`
Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

@@ -0,0 +1,107 @@
---
type: Engineering Journal Entry
title: Phase 7G Paperclip-style issue-thread web UI
description: Implementation decisions, deviations, and evidence for the package-local issue thread, interaction cards, composer, and Evidence panel.
tags: [native-runner, phase-7, ui, accessibility, evidence, mock-control-plane]
status: stable
generated: { by: anthropic/claude-opus-5, at: 2026-08-10T04:05:00Z }
entry_kind: phase
phase: "7"
---
# Context
Track 7G implements the browser surface named by the binding Phase 7B
issue-thread UX contract. The demo it has to support is a real Codex session,
driven by a real `paperclip-runnerd`, rendered as a native Paperclip issue
thread over deterministic mock control-plane records. The contract's §12
handoff checklist is the acceptance list.
# Decisions
1. **One view contract, two producers.** `Phase7IssueThreadSnapshot`
(`src/issue-thread/types.ts`) is the only shape the browser renders. The
deterministic `fake` fixtures and the live projection both emit it. That is
what makes the screenshot matrix hermetic while the live path stays honest:
the page cannot render anything the projection did not put in the snapshot.
2. **Projection runs server-side and decides nothing.** `projectPhase7IssueThread`
reads the live snapshot's transcript, evidence entries, semantic
authorization records, and serialized mock state, then regroups them. Every
allow/deny, revision, and state change already exists in its input, so §11's
"UI-side state math is a defect" holds structurally rather than by review.
3. **Fake mode owns the acceptance matrix.** The twelve §10.2 slugs render from
authored records with a fixed clock and no server, so two captures from a
clean checkout are byte-identical. `record:phase7:ui --check` re-records into
scratch and compares bytes rather than trusting a re-run.
4. **Package-local tokens, no product `ui/` import.** A separate OKLCH dark
token set (`--pit-*`) implements the Paperclip visual language.
`check:browser-tokens` — which the `verify` script already referenced but
which did not exist in the restored baseline — now enforces "no raw color in
component source, and stylesheet rules paint with custom properties only".
5. **The Evidence badge counts invocation denials only.** Exposure-phase denials
are the normal path — they are exactly what the `Control plane — not exposed`
list renders — so badging them would cry wolf on every turn. A badge means
the model was actually refused.
# Evidence
Tool versions: Node 22.22.2, pnpm workspace package `@paperclipai/paperclip-runner`,
Playwright 1.62.1, axe-core 4.13.0, Codex CLI 0.132.0, Chromium 151.0.7922.34.
* `pnpm typecheck:typescript` — passed.
* `pnpm typecheck:browser` — passed.
* `pnpm exec vitest run src/issue-thread/issue-thread.test.ts` — 16/16 passed
(fixture schema, slug determinism, §3 item coverage, §5 state coverage, §4
composer coverage, verbatim deny text, replay labelling, live projection of
real `Phase7MockControlPlaneAdapter` records, credential scan).
* `pnpm exec playwright test --config devtools/issue-thread/playwright.config.ts`
— 46/46 passed, including the axe gate on 12 slugs × 2 viewports with zero
serious or critical violations.
* `node scripts/record-phase7-ui-evidence.mjs` then `--check` — 24 PNGs
recorded, all 24 reproduce byte-for-byte; each mobile capture asserts
`scrollWidth <= clientWidth` at 390×844.
* `node scripts/phase7-issue-thread-smoke.mjs --json` — real runnerd and real
Codex through the browser's own HTTP routes: 2 turns, 2 tool calls, 30
authorization records, all 13 assertions true including
`durableCommentRendered`, `controlPlaneWithheld`, and `noCredentialInView`.
* `node scripts/record-phase7-ui-live-evidence.mjs` — three live PNGs captured
from a real browser session; identity chips read `Real Codex` / `Real runnerd`
/ `Mock Paperclip`, and the mobile frame has no horizontal page scroll.
* `pnpm check:browser-tokens` — passed. `pnpm docs:validate` — passed.
# Failures
* **`html { font-size }` broke every touch target.** Setting the type scale on
the root element redefined `rem` as 14px, so the 2.75rem touch-target token
resolved to 38.5px and sixteen controls failed the 44px assertion. The scale
now lives on `body`.
* **Blanket opacity failed the accessibility gate.** The contract's 60% dim for
expired-family interaction cards measured 3.16:1 against the card surface.
Replaced with a recessed surface plus muted text; recorded as a deviation in
the reference doc and reported back to the contract owner.
* **`--pit-faint-foreground` measured 4.49:1.** One notch below the 4.5:1 bar on
the run-state line; lightness raised to `oklch(0.64 …)`.
* **Evidence records had no thread anchors.** `Show in thread` resolved nothing
until each rendered thread item carried its record `id`, which is what makes
the §7 cross-link bidirectional rather than one-way.
* **A duplicated `data-composer-state` on the app root** made the contract's
screenshot hook ambiguous; the attribute now lives only on the composer form.
# Known gaps
- Live mode resolves interactions and turns over request/response. A push
transport for token-level streaming is not implemented; the `streaming`
composer state and the streaming indicator render from fixtures and from the
live snapshot's active turn, not from a token stream.
- `?at=<ordinal>` steps the replay strip and deep-links, but replay renders the
recorded fake fixture rather than a per-ordinal reconstruction of canonical
events.
- Traceability and parity records are passed through the projection verbatim;
wiring them to the 7F conformance run belongs to that track.
# Follow-up questions
- Does the board want live evidence PNGs treated as acceptance artifacts, given
they cannot be byte-stable?
- Should `check:phase7:ui` join `verify`, or stay a separate gate so `verify`
does not depend on a browser recorder?
@@ -16,3 +16,4 @@
* [Phase 6 thin Paperclip adapter design](2026-08-09-phase-06-design.md) - Proposed public-port seam, feature flag, kill switch, company/auth/governance boundary, durable replay, conformance matrix, and tutorial contract.
* [Phase 6 feature-flagged Paperclip adapter implementation](2026-08-09-phase-06-implementation.md) - Persisted selection, production port, public-session task, canonical event allocation, server-owned finalization, cancellation, and kill-switch evidence.
* [Phase 6 thin Paperclip adapter implementation](2026-08-09-phase-06-implementation.md) - Default-off native selection, package-owned sessions, canonical persistence/replay, server-owned finalization, recovery, and compatibility evidence.
* [Phase 7G Paperclip-style issue-thread web UI](2026-08-10-phase-07g-issue-thread-ui.md) - One view contract with two producers, the server-side projection, the deterministic screenshot matrix, the token gate, and the accessibility failures found on the way.
@@ -48,3 +48,9 @@
audit, native cancellation, migration coverage, a runnable tutorial, and
deterministic mock/real-port evidence while retaining legacy as the kill-
switch path.
* **Phase 7G issue-thread UI**: Added the package-local Paperclip-style issue
thread, its typed interaction cards, six-state composer, eight-section Evidence
panel, the single `Phase7IssueThreadSnapshot` view contract with deterministic
fixture and live-projection producers, the loopback session server, the
byte-stable 12-slug screenshot matrix, the axe accessibility gate, and the
restored browser design-token check.
+11 -3
View File
@@ -26,7 +26,7 @@
"README.md"
],
"scripts": {
"build": "pnpm run build:typescript && pnpm run build:rust && pnpm run build:browser && pnpm run build:phase5",
"build": "pnpm run build:typescript && pnpm run build:rust && pnpm run build:browser && pnpm run build:phase5 && pnpm run build:issue-thread",
"build:typescript": "pnpm run generate:protocol-types && tsc -p tsconfig.json",
"build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace",
"build:browser": "vite build --config vite.config.ts",
@@ -77,8 +77,15 @@
"console:phase5": "pnpm run build:typescript && vite --config vite.phase5.config.ts --host 127.0.0.1 --port 4181",
"browser:dev": "pnpm run build:typescript && pnpm run build:phase2-binaries && vite --config vite.config.ts",
"browser:preview": "vite preview --config vite.config.ts",
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run test:phase5 && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:phase5 && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1 && pnpm run trace:phase2 -- --quiet && pnpm run trace:phase3",
"verify:rootless": "bash scripts/verify-rootless-linux.sh"
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run test:phase5 && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:phase5 && pnpm run test:browser:phase7 && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1 && pnpm run trace:phase2 -- --quiet && pnpm run trace:phase3",
"verify:rootless": "bash scripts/verify-rootless-linux.sh",
"build:issue-thread": "vite build --config vite.phase7.config.ts",
"console:phase7": "pnpm run build:typescript && vite --config vite.phase7.config.ts --host 127.0.0.1 --port 4183",
"test:browser:phase7": "pnpm run build:typescript && playwright test --config devtools/issue-thread/playwright.config.ts",
"record:phase7:ui": "pnpm run build:typescript && pnpm run build:issue-thread && node scripts/record-phase7-ui-evidence.mjs",
"check:phase7:ui": "pnpm run build:typescript && pnpm run build:issue-thread && node scripts/record-phase7-ui-evidence.mjs --check",
"smoke:phase7:ui": "pnpm run build:typescript && cargo build --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/phase7-issue-thread-smoke.mjs",
"record:phase7:ui:live": "pnpm run build:typescript && pnpm run build:issue-thread && cargo build --manifest-path runner/Cargo.toml --locked -p paperclip-runner-core --bin paperclip-runnerd && node scripts/record-phase7-ui-live-evidence.mjs"
},
"peerDependencies": {
"react": ">=18",
@@ -91,6 +98,7 @@
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^4.7.0",
"ajv": "^8.20.0",
"axe-core": "^4.12.1",
"json-schema-to-ts": "^3.1.1",
"react": "^19.2.7",
"react-dom": "^19.2.7",
@@ -0,0 +1,237 @@
import { mkdtemp } from "node:fs/promises";
import { tmpdir } from "node:os";
import { resolve } from "node:path";
/**
* Package server for the Phase 7G issue-thread UI.
*
* The browser posts intents here; this process owns the real runnerd and Codex
* app-server pair, the mock ControlPlanePort, and every policy decision. Each
* response is a server-projected issue-thread view, so the page never holds
* state or policy authority (Phase 7B UX contract §11).
*
* An interaction response is stored in the mock control plane before the
* runner is resumed — that ordering is enforced inside `Phase7LiveSession`
* (§5 response authority path).
*/
const ROUTE_PREFIX = "/api/phase7/ui";
async function loadRunner() {
return import(new URL("../dist/index.js", import.meta.url).href);
}
async function createWorkingDirectory() {
const scratchRoot =
process.env.PAPERCLIP_RUN_SCRATCH_DIR ?? process.env.PAPERCLIP_SCRATCH_DIR ?? tmpdir();
return mkdtemp(resolve(scratchRoot, "phase7-issue-thread-"));
}
/** Mock-only fixture seed. Identifiers use the reserved `MCK-` prefix (§1.3). */
function issueThreadSeed(runner, scenario) {
return runner.createPhase7FixtureState({
epochMs: Date.UTC(2026, 7, 9, 9, 0, 0),
company: { id: "company-1", name: "Mock Paperclip Company", issuePrefix: "MCK" },
actors: [
{
id: "actor-1",
companyId: "company-1",
name: "Mock Engineer",
role: "engineer",
status: "active",
budgetId: "budget-actor-1",
capabilityGrants: [],
},
],
tasks: [
{
id: "task-31",
companyId: "company-1",
identifier: "MCK-31",
title: "Wire the runner spike to the mock control plane",
description: `Scenario ${scenario}. All records in this thread are mock records.`,
status: "todo",
priority: "high",
workMode: "standard",
parentId: null,
assigneeActorId: "actor-1",
checkoutRunId: null,
executionRunId: null,
startedAt: null,
completedAt: null,
},
],
});
}
export function createPhase7IssueThreadMiddleware(options = {}) {
const load = options.loadRunner ?? loadRunner;
let bootstrap = null;
let bindHost = options.bindHost ?? "127.0.0.1";
/** @type {Map<string, { session: unknown, scenario: string, connection: { state: string, attempt: number } }>} */
const sessions = new Map();
async function ready(requestedBindHost = bindHost) {
bindHost = requestedBindHost;
if (bootstrap !== null) return bootstrap;
bootstrap = (async () => {
const runner = await load();
runner.assertPhase4bLoopbackBindHost(bindHost);
const workingDirectory = options.workingDirectory ?? (await createWorkingDirectory());
const service = new runner.Phase7LiveSessionService({
store: new runner.InMemoryPhase7LiveSessionStore(),
...(options.transportFactory === undefined
? {}
: { transportFactory: options.transportFactory }),
});
return { runner, service, workingDirectory };
})();
return bootstrap;
}
function view(runner, entry, sessionId) {
return runner.projectPhase7IssueThread({
snapshot: entry.session.snapshot(),
connection: entry.connection,
mode: "live",
fixtureProfile: entry.scenario,
});
}
async function readBody(request) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
if (chunks.length === 0) return {};
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8"));
} catch {
return {};
}
}
function send(response, status, payload) {
response.statusCode = status;
response.setHeader("content-type", "application/json; charset=utf-8");
response.end(JSON.stringify(payload));
}
async function createSession(runner, service, scenario, workingDirectory) {
const session = await service.create({
seed: issueThreadSeed(runner, scenario),
workingDirectory,
scenario: { id: scenario },
taskId: "task-31",
actorId: "actor-1",
companyId: "company-1",
});
const entry = { session, scenario, connection: { state: "connected", attempt: 0 } };
sessions.set(session.id, entry);
return entry;
}
const middleware = async function phase7IssueThreadMiddleware(request, response, next) {
const url = new URL(request.url ?? "/", "http://phase7.local");
if (!url.pathname.startsWith(`${ROUTE_PREFIX}/`)) {
next();
return;
}
try {
const { runner, service, workingDirectory } = await ready();
const route = url.pathname.slice(ROUTE_PREFIX.length + 1);
const body = request.method === "POST" ? await readBody(request) : {};
const requestedId =
typeof body.sessionId === "string" ? body.sessionId : url.searchParams.get("sessionId");
const scenario =
typeof body.scenario === "string" ? body.scenario : url.searchParams.get("scenario") ?? "hb-baseline";
if (route === "session" && request.method === "GET") {
const existing = requestedId === null ? undefined : sessions.get(requestedId);
const entry = existing ?? (await createSession(runner, service, scenario, workingDirectory));
send(response, 200, { sessionId: entry.session.id, view: view(runner, entry) });
return;
}
if (route === "session" && request.method === "POST") {
const entry = await createSession(runner, service, scenario, workingDirectory);
send(response, 201, { sessionId: entry.session.id, view: view(runner, entry) });
return;
}
const entry = requestedId === null ? undefined : sessions.get(requestedId);
if (entry === undefined) {
send(response, 404, { error: "unknown_session" });
return;
}
if (route === "message") {
await entry.session.sendMessage(String(body.message ?? ""));
} else if (route === "interrupt") {
await entry.session.interrupt("operator stopped the turn");
} else if (route === "reconnect") {
entry.connection = { state: "reconnecting", attempt: entry.connection.attempt + 1 };
await entry.session.reconnect();
entry.connection = { state: "connected", attempt: 0 };
} else if (route === "reset") {
const next = await service.reset(entry.session.id);
sessions.delete(entry.session.id);
const resetEntry = {
session: next,
scenario: entry.scenario,
connection: { state: "connected", attempt: 0 },
};
sessions.set(next.id, resetEntry);
send(response, 200, { sessionId: next.id, view: view(runner, resetEntry) });
return;
} else if (route === "interaction") {
// The session stores the typed response in the mock control plane
// before resuming the same provider thread.
await entry.session.resolveInteraction({
interactionId: String(body.interactionId ?? ""),
outcome: String(body.outcome ?? "answered"),
result: body.result ?? null,
});
} else {
send(response, 404, { error: "unknown_route" });
return;
}
send(response, 200, { sessionId: entry.session.id, view: view(runner, entry) });
} catch (error) {
send(response, 500, {
error: "phase7_issue_thread_unavailable",
message: String(error instanceof Error ? error.message : error),
});
}
};
middleware.close = async () => {
if (bootstrap === null) return;
const { service } = await bootstrap;
for (const sessionId of [...sessions.keys()]) {
await service.shutdown(sessionId, "server shutdown").catch(() => undefined);
sessions.delete(sessionId);
}
};
middleware.prepare = ready;
return middleware;
}
export function phase7IssueThreadServerPlugin(options = {}) {
async function mount(server, host) {
const middleware = createPhase7IssueThreadMiddleware({ ...options, bindHost: host });
await middleware.prepare(host);
server.middlewares.use(middleware);
server.httpServer?.once("close", () => void middleware.close());
}
return {
name: "paperclip-runner-phase7-issue-thread-server",
async configureServer(server) {
const host = server.config.server.host;
await mount(server, typeof host === "string" ? host : host === true ? "0.0.0.0" : "127.0.0.1");
},
async configurePreviewServer(server) {
const host = server.config.preview.host;
await mount(server, typeof host === "string" ? host : host === true ? "0.0.0.0" : "127.0.0.1");
},
};
}
@@ -0,0 +1,123 @@
#!/usr/bin/env node
/**
* Live smoke for the Phase 7G issue-thread server.
*
* Starts the real package server on loopback, drives a real runnerd + Codex
* app-server session through the same HTTP routes the browser uses, and checks
* that the projected view carries the live identity, the multi-turn thread, the
* durable mock records, and no credential. Requires an authenticated local
* Codex installation.
*
* Usage: node scripts/phase7-issue-thread-smoke.mjs [--json]
*/
import { createServer } from "node:http";
import { once } from "node:events";
import { createPhase7IssueThreadMiddleware } from "./phase7-issue-thread-server.mjs";
const CREDENTIAL_PATTERNS = [
/bearer\s+[a-z0-9._-]+/i,
/sk-[a-z0-9]{8,}/i,
/"api[_-]?key"\s*:/i,
/PAPERCLIP_API_KEY/,
/OPENAI_API_KEY/,
];
function credentialLeaks(value) {
const serialized = JSON.stringify(value);
return CREDENTIAL_PATTERNS.filter((pattern) => pattern.test(serialized)).map(String);
}
function assert(condition, message) {
if (!condition) throw new Error(`assertion failed: ${message}`);
}
async function main() {
const asJson = process.argv.includes("--json");
const middleware = createPhase7IssueThreadMiddleware({ bindHost: "127.0.0.1" });
const server = createServer((request, response) => {
middleware(request, response, () => {
response.statusCode = 404;
response.end("not found");
});
});
server.listen(0, "127.0.0.1");
await once(server, "listening");
const { port } = server.address();
const origin = `http://127.0.0.1:${port}`;
const assertions = {};
let sessionId = null;
try {
const created = await (await fetch(`${origin}/api/phase7/ui/session`)).json();
sessionId = created.sessionId;
assertions.sessionCreated = typeof sessionId === "string" && sessionId.length > 0;
assertions.liveIdentity =
created.view.identity.agentLabel === "Real Codex" &&
created.view.identity.runnerLabel === "Real runnerd" &&
created.view.identity.controlPlaneLabel === "Mock Paperclip";
assertions.mockIdentifier = created.view.issue.identifier.startsWith("MCK-");
assertions.toolsProjected = created.view.evidence.tools.length >= 0;
const first = await (
await fetch(`${origin}/api/phase7/ui/message`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sessionId,
message:
"Call get_task_context, then call report_progress with a one-sentence status. Do not ask me anything.",
}),
})
).json();
const firstItems = first.view.turns.flatMap((turn) => turn.items);
assertions.userMessageRendered = firstItems.some((item) => item.kind === "user_message");
assertions.toolActivityRendered = firstItems.some((item) => item.kind === "tool_activity");
assertions.durableCommentRendered = firstItems.some(
(item) => item.kind === "durable_comment",
);
assertions.authorizationRecorded = first.view.evidence.authorization.length > 0;
assertions.callsRecorded = first.view.evidence.calls.length > 0;
assertions.controlPlaneWithheld = (first.view.evidence.tools[0]?.rows ?? []).some(
(row) => row.disposition === "control_plane_owned",
);
const second = await (
await fetch(`${origin}/api/phase7/ui/message`, {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionId, message: "Summarise the mock task in one line." }),
})
).json();
assertions.multiTurnThread = second.view.turns.length > first.view.turns.length;
assertions.composerReady = second.view.composer.state === "ready";
assertions.noCredentialInView = credentialLeaks(second.view).length === 0;
const failures = Object.entries(assertions).filter(([, ok]) => ok !== true);
if (asJson) {
process.stdout.write(
`${JSON.stringify(
{
schema: "paperclip.phase7.issue-thread-smoke.v1",
sessionId,
turns: second.view.turns.length,
toolCalls: second.view.evidence.calls.length,
authorizationRecords: second.view.evidence.authorization.length,
assertions,
},
null,
2,
)}\n`,
);
}
assert(failures.length === 0, `${failures.map(([name]) => name).join(", ")}`);
if (!asJson) process.stdout.write("Phase 7 issue-thread live smoke passed.\n");
} finally {
await middleware.close();
server.close();
}
}
await main();
@@ -0,0 +1,192 @@
#!/usr/bin/env node
/**
* Records the Phase 7B §10.2 screenshot matrix: 12 slugs × 2 viewports.
*
* Every route is deterministic `fake` mode served from package fixtures — no
* provider, runnerd, or credential is involved, so the PNGs are safe evidence
* and reproduce byte-for-byte from a clean checkout. `--check` re-records into
* a scratch directory and compares bytes instead of overwriting.
*/
import { spawn } from "node:child_process";
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "@playwright/test";
import { PHASE7_UI_SHOT_SLUGS } from "../dist/issue-thread/fixtures.js";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const OUTPUT_DIR = resolve(packageRoot, "knowledge/evidence/phase-07/ui");
const PORT = Number.parseInt(process.env.PAPERCLIP_PHASE7_UI_PORT ?? "4185", 10);
const ORIGIN = `http://127.0.0.1:${PORT}`;
const VIEWPORTS = [
{ name: "desktop", width: 1440, height: 900 },
{ name: "mobile", width: 390, height: 844 },
];
/** Slugs whose evidence needs an extra deep-link parameter. */
const EXTRA_PARAMS = {
"debug-panel-open": { desktop: { panel: "authorization" }, mobile: { panel: "authorization", seg: "evidence" } },
"replay-mode": { desktop: { at: "12" }, mobile: { at: "12" } },
};
function routeFor(slug, viewport) {
const params = new URLSearchParams({
shot: slug,
capture: "1",
...(EXTRA_PARAMS[slug]?.[viewport] ?? {}),
});
return `${ORIGIN}/#/issue/hb-baseline?${params.toString()}`;
}
async function waitForServer(url, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch {
// The preview server is still binding.
}
if (Date.now() > deadline) throw new Error(`preview server never answered on ${url}`);
await new Promise((done) => setTimeout(done, 250));
}
}
async function startPreview() {
const child = spawn(
"pnpm",
[
"exec",
"vite",
"preview",
"--config",
"vite.phase7.config.ts",
"--host",
"127.0.0.1",
"--port",
String(PORT),
],
{ cwd: packageRoot, stdio: "ignore", env: { ...process.env, NODE_ENV: "" } },
);
await waitForServer(ORIGIN);
return async () => {
child.kill("SIGTERM");
};
}
async function record(targetDir) {
await mkdir(targetDir, { recursive: true });
const browser = await chromium.launch({
...(process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH === undefined
? {}
: { executablePath: process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH }),
});
const recorded = [];
try {
for (const viewport of VIEWPORTS) {
for (const slug of PHASE7_UI_SHOT_SLUGS) {
const context = await browser.newContext({
viewport: { width: viewport.width, height: viewport.height },
deviceScaleFactor: 1,
reducedMotion: "reduce",
colorScheme: "dark",
locale: "en-US",
timezoneId: "UTC",
});
const page = await context.newPage();
await page.goto(routeFor(slug, viewport.name));
// Settle on the contract's data attribute, never on a timeout.
await page.waitForSelector('[data-thread-state="settled"]', { timeout: 30_000 });
await page.evaluate(() => document.fonts.ready);
const overflow = await page.evaluate(() => {
const element = document.scrollingElement;
return element.scrollWidth - element.clientWidth;
});
if (viewport.name === "mobile" && overflow > 0) {
throw new Error(`${slug} scrolls horizontally by ${overflow}px at 390px`);
}
const file = resolve(targetDir, `${slug}--${viewport.name}.png`);
await page.screenshot({ path: file, animations: "disabled", caret: "hide" });
recorded.push({ slug, viewport: viewport.name, file, overflow });
await context.close();
}
}
} finally {
await browser.close();
}
return recorded;
}
async function main() {
const check = process.argv.includes("--check");
const stop = await startPreview();
try {
if (!check) {
await rm(OUTPUT_DIR, { recursive: true, force: true });
const recorded = await record(OUTPUT_DIR);
const manifest = recorded.map(({ slug, viewport }) => `${slug}--${viewport}.png`).sort();
await writeFile(
resolve(OUTPUT_DIR, "index.md"),
[
"# Phase 7 issue-thread UI evidence",
"",
"Recorded by `pnpm --filter @paperclipai/paperclip-runner record:phase7:ui`.",
"Deterministic `fake` mode; no provider, runner, or credential is involved.",
"",
...manifest.map((name) => `- \`${name}\``),
"",
].join("\n"),
"utf8",
);
process.stdout.write(`Recorded ${recorded.length} Phase 7 UI screenshots to ${OUTPUT_DIR}\n`);
return;
}
const scratchRoot =
process.env.PAPERCLIP_RUN_SCRATCH_DIR ?? process.env.PAPERCLIP_SCRATCH_DIR ?? tmpdir();
const scratch = await mkdtemp(resolve(scratchRoot, "phase7-ui-check-"));
try {
const recorded = await record(scratch);
const drift = [];
for (const entry of recorded) {
const committed = resolve(OUTPUT_DIR, `${entry.slug}--${entry.viewport}.png`);
const [expected, actual] = await Promise.all([
readFile(committed).catch(() => null),
readFile(entry.file),
]);
if (expected === null || !expected.equals(actual)) {
drift.push(`${entry.slug}--${entry.viewport}.png`);
}
}
const committedNames = (await readdir(OUTPUT_DIR).catch(() => [])).filter((name) =>
name.endsWith(".png"),
);
if (committedNames.length !== recorded.length) {
drift.push(
`expected ${recorded.length} committed PNGs, found ${committedNames.length}`,
);
}
if (drift.length > 0) {
process.stderr.write(`Phase 7 UI evidence is not byte-stable:\n${drift.join("\n")}\n`);
process.exitCode = 1;
return;
}
process.stdout.write(
`All ${recorded.length} Phase 7 UI screenshots reproduce byte-for-byte.\n`,
);
} finally {
await rm(scratch, { recursive: true, force: true });
}
} finally {
await stop();
}
}
await main();
@@ -0,0 +1,132 @@
#!/usr/bin/env node
/**
* Captures live-mode evidence: the browser driving a real runnerd + Codex pair
* against the mock control plane.
*
* These PNGs are deliberately outside the deterministic §10.2 matrix — a real
* model writes their prose, so they are not byte-stable. They exist to show
* that the same surface renders a live session and that the identity chips make
* live and fake sessions impossible to confuse.
*
* Requires an authenticated local Codex installation and `cargo` on PATH.
*/
import { spawn } from "node:child_process";
import { mkdir, rm, writeFile } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { chromium } from "@playwright/test";
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const OUTPUT_DIR = resolve(packageRoot, "knowledge/evidence/phase-07/ui-live");
const PORT = Number.parseInt(process.env.PAPERCLIP_PHASE7_UI_LIVE_PORT ?? "4186", 10);
const ORIGIN = `http://127.0.0.1:${PORT}`;
const PROMPT =
"Call get_task_context, then call report_progress with a one-sentence status. Do not ask me anything.";
async function waitForServer(url, timeoutMs = 90_000) {
const deadline = Date.now() + timeoutMs;
for (;;) {
try {
if ((await fetch(url)).ok) return;
} catch {
// Still binding.
}
if (Date.now() > deadline) throw new Error(`preview server never answered on ${url}`);
await new Promise((done) => setTimeout(done, 250));
}
}
async function main() {
const child = spawn(
"pnpm",
["exec", "vite", "preview", "--config", "vite.phase7.config.ts", "--host", "127.0.0.1", "--port", String(PORT)],
{ cwd: packageRoot, stdio: "ignore", env: { ...process.env, NODE_ENV: "" } },
);
await waitForServer(ORIGIN);
const browser = await chromium.launch({
...(process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH === undefined
? {}
: { executablePath: process.env.PAPERCLIP_RUNNER_CHROMIUM_PATH }),
});
try {
await rm(OUTPUT_DIR, { recursive: true, force: true });
await mkdir(OUTPUT_DIR, { recursive: true });
const context = await browser.newContext({
viewport: { width: 1440, height: 900 },
colorScheme: "dark",
locale: "en-US",
timezoneId: "UTC",
});
const page = await context.newPage();
await page.goto(`${ORIGIN}/#/issue/hb-baseline?mode=live`);
await page.waitForSelector('[data-thread-state="settled"]', { timeout: 120_000 });
const agentChip = await page.getByTestId("agent-chip").innerText();
const runnerChip = await page.getByTestId("runner-chip").innerText();
if (!agentChip.includes("Real Codex") || !runnerChip.includes("Real runnerd")) {
throw new Error(`live identity chips are wrong: ${agentChip} / ${runnerChip}`);
}
await page.fill("#composer-input", PROMPT);
await page.getByTestId("composer-send").click();
await page.waitForSelector('[data-thread-item="durable_comment"]', { timeout: 300_000 });
await page.waitForSelector('[data-composer-state="ready"]', { timeout: 60_000 });
await page.screenshot({
path: resolve(OUTPUT_DIR, "live-codex-turn--desktop.png"),
animations: "disabled",
caret: "hide",
});
await page.getByTestId("evidence-toggle").click();
await page.screenshot({
path: resolve(OUTPUT_DIR, "live-codex-evidence--desktop.png"),
animations: "disabled",
caret: "hide",
});
await page.setViewportSize({ width: 390, height: 844 });
await page.getByTestId("segment-thread").click();
const overflow = await page.evaluate(() => {
const element = document.scrollingElement;
return element.scrollWidth - element.clientWidth;
});
if (overflow > 0) throw new Error(`live thread scrolls horizontally by ${overflow}px at 390px`);
await page.screenshot({
path: resolve(OUTPUT_DIR, "live-codex-turn--mobile.png"),
animations: "disabled",
caret: "hide",
});
await writeFile(
resolve(OUTPUT_DIR, "index.md"),
[
"# Phase 7 issue-thread live evidence",
"",
"Recorded by `pnpm --filter @paperclipai/paperclip-runner record:phase7:ui:live`.",
"",
"Real `paperclip-runnerd` and a real Codex app-server drive these frames against",
"the deterministic mock control plane. A real model writes the prose, so these",
"PNGs are **not** byte-stable and are excluded from the §10.2 determinism gate.",
"",
"- `live-codex-turn--desktop.png`",
"- `live-codex-evidence--desktop.png`",
"- `live-codex-turn--mobile.png`",
"",
].join("\n"),
"utf8",
);
process.stdout.write(`Recorded live Phase 7 UI evidence to ${OUTPUT_DIR}\n`);
await context.close();
} finally {
await browser.close();
child.kill("SIGTERM");
}
}
await main();
+1
View File
@@ -31,3 +31,4 @@ export * from "./tracer/phase1-replay.js";
export * from "./tracer/phase2-live.js";
export * from "./semantic-tools/index.js";
export * from "./phase7/index.js";
export * from "./issue-thread/index.js";
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,3 @@
export * from "./types.js";
export * from "./fixtures.js";
export * from "./live-projection.js";
@@ -0,0 +1,397 @@
import { describe, expect, it } from "vitest";
import { Phase7MockControlPlaneAdapter } from "../mock-core/phase7-mock-control-plane-adapter.js";
import type { Phase7LiveSessionSnapshot } from "../phase7/live-session.js";
import {
PHASE7_UI_SHOT_SLUGS,
phase7IssueThreadFixture,
} from "./fixtures.js";
import { projectPhase7IssueThread } from "./live-projection.js";
import {
PHASE7_EVIDENCE_SECTIONS,
PHASE7_ISSUE_THREAD_VIEW_SCHEMA,
phase7DenialCount,
type Phase7IssueThreadSnapshot,
type Phase7ThreadInteractionState,
type Phase7ThreadItem,
} from "./types.js";
/** Contract §3 item taxonomy T1–T11. */
const REQUIRED_ITEM_KINDS: Array<Phase7ThreadItem["kind"]> = [
"user_message",
"agent_message",
"durable_comment",
"tool_activity",
"interaction",
"document",
"deliverable",
"disposition",
"denial",
"system_notice",
];
/** Contract §5 state matrix. */
const REQUIRED_INTERACTION_STATES: Phase7ThreadInteractionState[] = [
"pending",
"accepted",
"answered",
"rejected",
"stale_target",
"superseded_by_comment",
];
function allItems(snapshot: Phase7IssueThreadSnapshot): Phase7ThreadItem[] {
return snapshot.turns.flatMap((turn) => turn.items);
}
function credentialLike(value: unknown): string[] {
const serialized = JSON.stringify(value);
const patterns = [
/"authorization"\s*:\s*"/i,
/bearer\s+[a-z0-9._-]+/i,
/sk-[a-z0-9]{8,}/i,
/"api[_-]?key"\s*:/i,
/PAPERCLIP_API_KEY/,
];
return patterns.filter((pattern) => pattern.test(serialized)).map(String);
}
describe("Phase 7 issue-thread fixtures", () => {
it("exposes exactly the twelve screenshot slugs from the UX contract §10.2", () => {
expect(PHASE7_UI_SHOT_SLUGS).toHaveLength(12);
expect(new Set(PHASE7_UI_SHOT_SLUGS).size).toBe(12);
});
it("builds a schema-valid snapshot for every slug", () => {
for (const slug of PHASE7_UI_SHOT_SLUGS) {
const snapshot = phase7IssueThreadFixture(slug);
expect(snapshot.schema).toBe(PHASE7_ISSUE_THREAD_VIEW_SCHEMA);
expect(snapshot.turns.length).toBeGreaterThan(0);
expect(snapshot.identity.controlPlaneLabel).toBe("Mock Paperclip");
expect(snapshot.issue.identifier.startsWith("MCK-")).toBe(true);
for (const section of PHASE7_EVIDENCE_SECTIONS) {
expect(Array.isArray(snapshot.evidence[section.id])).toBe(true);
}
}
});
it("is deterministic: two builds of a slug are byte-identical", () => {
for (const slug of PHASE7_UI_SHOT_SLUGS) {
expect(JSON.stringify(phase7IssueThreadFixture(slug))).toBe(
JSON.stringify(phase7IssueThreadFixture(slug)),
);
}
});
it("covers every §3 thread item type across the matrix", () => {
const seen = new Set<Phase7ThreadItem["kind"]>();
for (const slug of PHASE7_UI_SHOT_SLUGS) {
for (const item of allItems(phase7IssueThreadFixture(slug))) seen.add(item.kind);
}
for (const kind of REQUIRED_ITEM_KINDS) {
expect(seen, `missing thread item ${kind}`).toContain(kind);
}
});
it("covers the §5 interaction state matrix", () => {
const seen = new Set<Phase7ThreadInteractionState>();
for (const slug of PHASE7_UI_SHOT_SLUGS) {
for (const item of allItems(phase7IssueThreadFixture(slug))) {
if (item.kind === "interaction") seen.add(item.state);
}
}
for (const state of REQUIRED_INTERACTION_STATES) {
expect(seen, `missing interaction state ${state}`).toContain(state);
}
});
it("covers every §4 composer state", () => {
const seen = new Set(
PHASE7_UI_SHOT_SLUGS.map((slug) => phase7IssueThreadFixture(slug).composer.state),
);
for (const state of ["ready", "streaming", "waiting", "reconnecting", "disabled"]) {
expect(seen, `missing composer state ${state}`).toContain(state);
}
});
it("binds the confirmation card to an explicit revision", () => {
const snapshot = phase7IssueThreadFixture("interaction-confirmation-pending");
const card = allItems(snapshot).find(
(item) => item.kind === "interaction" && item.interactionKind === "confirmation",
);
expect(card?.kind).toBe("interaction");
if (card?.kind !== "interaction") return;
expect(card.target?.label).toBe("plan · r4");
expect(card.state).toBe("pending");
expect(snapshot.composer.state).toBe("waiting");
expect(snapshot.composer.pendingInteractionId).toBe(card.interactionId);
});
it("quotes the deny reason verbatim and counts it for the evidence badge", () => {
const snapshot = phase7IssueThreadFixture("denial-optional-tool");
const denial = allItems(snapshot).find((item) => item.kind === "denial");
expect(denial?.kind).toBe("denial");
if (denial?.kind !== "denial") return;
const record = snapshot.evidence.authorization.find(
(entry) => entry.id === denial.evidenceRef.recordId,
);
expect(record?.allowed).toBe(false);
expect(denial.reason).toBe(record?.reason);
expect(phase7DenialCount(snapshot.evidence, null)).toBe(1);
});
it("labels replay as fake-derived so replay never satisfies a live criterion", () => {
const snapshot = phase7IssueThreadFixture("replay-mode");
expect(snapshot.mode).toBe("replay");
expect(snapshot.identity.replaySource).toBe("fake");
expect(snapshot.composer.state).toBe("disabled");
expect(snapshot.composer.reason).toBe("Replay is read-only");
});
it("names the control-plane operations that are withheld from the agent", () => {
const snapshot = phase7IssueThreadFixture("thread-baseline");
const withheld = snapshot.evidence.tools[0]?.rows.filter(
(row) => row.disposition === "control_plane_owned",
);
expect(withheld?.length ?? 0).toBeGreaterThan(0);
expect(withheld?.map((row) => row.operationId)).toContain("create_task");
});
it("keeps credentials out of every fixture", () => {
for (const slug of PHASE7_UI_SHOT_SLUGS) {
expect(credentialLike(phase7IssueThreadFixture(slug)), slug).toEqual([]);
}
});
});
describe("Phase 7 live projection", () => {
async function liveSnapshot(): Promise<Phase7LiveSessionSnapshot> {
const adapter = new Phase7MockControlPlaneAdapter({
epochMs: Date.UTC(2026, 7, 9, 9, 0, 0),
tasks: [
{
id: "task-31",
companyId: "company-1",
identifier: "MCK-31",
title: "Wire the runner spike to the mock control plane",
description: null,
status: "todo",
priority: "high",
workMode: "standard",
parentId: null,
assigneeActorId: "actor-1",
checkoutRunId: null,
executionRunId: null,
startedAt: null,
completedAt: null,
},
],
});
await adapter.start();
await adapter.openFixtureRun({
identity: {
runId: "run-7g-1",
sessionId: "session-7g-1",
companyId: "company-1",
issueId: "task-31",
agentId: "actor-1",
},
backendKind: "mock",
sourceInstanceId: "phase7-ui",
});
const applied = await adapter.applyCommand({
runId: "run-7g-1",
idempotencyKey: "progress-1",
command: { kind: "report_progress", taskId: "task-31", body: "Read the mock task." },
});
return {
schema: "paperclip.phase7.live-session.v1",
revision: 1,
sessionId: "session-7g-1",
providerThreadId: "thr-mock",
providerSessionId: "provider-1",
status: "idle",
activeTurnId: null,
createdAt: "2026-08-09T09:00:00.000Z",
updatedAt: "2026-08-09T09:01:00.000Z",
authority: {
active: true,
runId: "run-7g-1",
companyId: "company-1",
actorId: "actor-1",
taskId: "task-31",
sessionId: "session-7g-1",
scenarioId: "hb-baseline",
capabilities: [],
explicitClaims: [],
},
config: {
workingDirectory: "/mock/workspace",
scenario: { id: "hb-baseline" },
capabilities: [],
explicitClaims: [],
seedState: adapter.serialize(),
turnTimeoutMs: 60_000,
},
mockState: adapter.serialize(),
transcript: [
{
id: "t1",
role: "user",
text: "Pick up MCK-31.",
turnId: "turn-1",
at: "2026-08-09T09:00:05.000Z",
},
{
id: "t2",
role: "assistant",
text: "Reading the checked-out task first.",
turnId: "turn-1",
at: "2026-08-09T09:00:18.000Z",
},
{
id: "t3",
role: "user",
text: JSON.stringify({
schema: "paperclip.semantic-interaction-result.v1",
interactionId: "ix-1",
}),
turnId: "turn-1",
at: "2026-08-09T09:00:40.000Z",
},
],
evidence: [
{
id: "evidence-000001",
kind: "tool_exposure",
at: "2026-08-09T09:00:00.000Z",
turnId: null,
data: {
operationIds: ["get_task_context", "report_progress", "search_tasks"],
scenarioId: "hb-baseline",
},
},
{
id: "evidence-000002",
kind: "tool_call",
at: "2026-08-09T09:00:30.000Z",
turnId: "turn-1",
data: {
callId: "call-1",
operationId: "report_progress",
input: { body: "Read the mock task." },
beforeRevision: 1,
},
},
{
id: "evidence-000003",
kind: "tool_result",
at: "2026-08-09T09:00:31.000Z",
turnId: "turn-1",
data: {
callId: "call-1",
operationId: "report_progress",
result: {
ok: true,
operationId: "report_progress",
callId: "call-1",
result: applied as unknown as Record<string, never>,
stateRevision: applied.stateRevision,
},
beforeRevision: 1,
afterRevision: applied.stateRevision,
},
},
],
authorizationRecords: [
{
schema: "paperclip.semantic-authorization-record.v1",
id: "authz-1",
runId: "run-7g-1",
scenarioId: "hb-baseline",
actorId: "actor-1",
taskId: "task-31",
callId: "call-1",
input: null,
result: null,
allowed: true,
phase: "invocation",
operationId: "report_progress",
code: "allowed",
reason: "Assignee actor owns the checked-out task.",
effectiveClaims: ["task:read"],
},
],
process: null,
networkEvidence: { realPaperclipRequests: 0, childPaperclipEnvironmentKeys: [] },
} as Phase7LiveSessionSnapshot;
}
it("projects mock records into the same view contract the fixtures use", async () => {
const view = projectPhase7IssueThread({ snapshot: await liveSnapshot() });
expect(view.schema).toBe(PHASE7_ISSUE_THREAD_VIEW_SCHEMA);
expect(view.mode).toBe("live");
expect(view.identity.agentLabel).toBe("Real Codex");
expect(view.identity.runnerLabel).toBe("Real runnerd");
expect(view.issue.identifier).toBe("MCK-31");
const items = allItems(view);
expect(items.filter((item) => item.kind === "user_message")).toHaveLength(1);
expect(items.some((item) => item.kind === "agent_message")).toBe(true);
expect(items.some((item) => item.kind === "tool_activity")).toBe(true);
// The durable comment comes from the mock record the command created,
// not from the model's prose.
const durable = items.find((item) => item.kind === "durable_comment");
expect(durable?.kind).toBe("durable_comment");
if (durable?.kind === "durable_comment") {
expect(durable.body).toBe("Read the mock task.");
expect(durable.operationId).toBe("report_progress");
}
});
it("hides the synthetic interaction-result message from the thread", async () => {
const view = projectPhase7IssueThread({ snapshot: await liveSnapshot() });
const bodies = allItems(view)
.filter((item) => item.kind === "user_message")
.map((item) => item.body);
expect(bodies.some((body) => body.includes("semantic-interaction-result"))).toBe(false);
});
it("separates exposed agent tools from withheld control-plane operations", async () => {
const view = projectPhase7IssueThread({ snapshot: await liveSnapshot() });
const rows = view.evidence.tools[0]?.rows ?? [];
const always = rows.filter((row) => row.disposition === "always_agent_tool");
const granted = rows.filter((row) => row.disposition === "optional_agent_tool");
const withheld = rows.filter((row) => row.disposition === "control_plane_owned");
expect(always.map((row) => row.operationId)).toContain("report_progress");
expect(granted.map((row) => row.operationId)).toContain("search_tasks");
expect(withheld.map((row) => row.operationId)).toContain("create_task");
});
it("derives composer state from server records rather than browser guesses", async () => {
const snapshot = await liveSnapshot();
expect(projectPhase7IssueThread({ snapshot }).composer.state).toBe("ready");
expect(
projectPhase7IssueThread({
snapshot: { ...snapshot, activeTurnId: "turn-1" },
}).composer.state,
).toBe("streaming");
expect(
projectPhase7IssueThread({
snapshot,
connection: { state: "reconnecting", attempt: 2 },
}).composer.state,
).toBe("reconnecting");
expect(
projectPhase7IssueThread({ snapshot, mode: "replay" }).composer.state,
).toBe("disabled");
});
it("keeps credentials out of the projected view", async () => {
const view = projectPhase7IssueThread({ snapshot: await liveSnapshot() });
expect(credentialLike(view)).toEqual([]);
});
});
@@ -0,0 +1,729 @@
/**
* Projects a live Phase 7 session into the issue-thread view contract.
*
* This runs in the package server, never in the browser. It reads only durable
* records — the live snapshot's transcript, evidence entries, semantic
* authorization records, and the serialized mock state — and rearranges them
* for rendering. It decides nothing: every allow/deny, revision, and state
* change already exists in the input.
*/
import type {
Phase7FixtureInteraction,
Phase7FixtureState,
Phase7JsonValue,
} from "../mock-core/phase7-control-plane-types.js";
import type {
Phase7LiveEvidenceEntry,
Phase7LiveSessionSnapshot,
} from "../phase7/live-session.js";
import { PHASE7_SEMANTIC_TOOL_CATALOG } from "../semantic-tools/catalog.js";
import type { Phase7SemanticOperationId } from "../semantic-tools/types.js";
import type {
Phase7ComposerModel,
Phase7EvidenceModel,
Phase7EvidenceParityRecord,
Phase7EvidenceToolRow,
Phase7EvidenceTraceabilityRecord,
Phase7IssueThreadSnapshot,
Phase7ThreadConnection,
Phase7ThreadInteractionCard,
Phase7ThreadInteractionState,
Phase7ThreadItem,
Phase7ThreadMode,
Phase7ThreadTurn,
Phase7ToolDisposition,
} from "./types.js";
import { PHASE7_ISSUE_THREAD_VIEW_SCHEMA } from "./types.js";
const INTERACTION_RESULT_SCHEMA = "paperclip.semantic-interaction-result.v1";
const DURABLE_COMMENT_OPERATIONS = new Set<Phase7SemanticOperationId>([
"report_progress",
"answer_status_question",
]);
const DISPOSITION_OPERATIONS = new Set<Phase7SemanticOperationId>([
"finish_task",
"block_task",
"request_review",
]);
export interface Phase7LiveProjectionInput {
snapshot: Phase7LiveSessionSnapshot;
/** Transport state owned by the server, not inferred in the browser. */
connection?: Phase7ThreadConnection;
mode?: Phase7ThreadMode;
replaySource?: "fake" | "live" | null;
fixtureProfile?: string;
/** 7A traceability rows for the active fixture, passed through verbatim. */
traceability?: Phase7EvidenceTraceabilityRecord[];
/** 7F parity verdicts, passed through verbatim. */
parity?: Phase7EvidenceParityRecord[];
}
function readRecord(value: Phase7JsonValue | undefined): Record<string, Phase7JsonValue> {
return typeof value === "object" && value !== null && !Array.isArray(value)
? value
: {};
}
function readString(value: Phase7JsonValue | undefined, fallback = ""): string {
return typeof value === "string" ? value : fallback;
}
function readNumber(value: Phase7JsonValue | undefined, fallback = 0): number {
return typeof value === "number" ? value : fallback;
}
function parseMockState(serialized: string): Phase7FixtureState | null {
try {
return JSON.parse(serialized) as Phase7FixtureState;
} catch {
return null;
}
}
function isInteractionResultMessage(text: string): boolean {
if (!text.startsWith("{")) return false;
try {
return readString(readRecord(JSON.parse(text)).schema) === INTERACTION_RESULT_SCHEMA;
} catch {
return false;
}
}
function toolSummary(operationId: string, result: Phase7JsonValue): string {
const payload = readRecord(result);
if (payload.ok === false) {
return readString(readRecord(payload.denial).message, "denied");
}
// The strip already prints the operation id; the summary adds what changed.
const inner = readRecord(payload.result);
const revision = readNumber(payload.stateRevision, readNumber(inner.stateRevision, 0));
return revision > 0 ? `state revision ${revision}` : "completed";
}
function dispositionFor(operationId: string, exposed: Set<string>): Phase7ToolDisposition {
const descriptor = PHASE7_SEMANTIC_TOOL_CATALOG.find(
(candidate) => candidate.operationId === operationId,
);
if (descriptor === undefined || !exposed.has(operationId)) return "control_plane_owned";
return descriptor.exposure === "always" ? "always_agent_tool" : "optional_agent_tool";
}
function interactionState(
interaction: Phase7FixtureInteraction,
): { state: Phase7ThreadInteractionState; label: string } {
const outcome = readString(readRecord(interaction.result ?? undefined).outcome);
if (interaction.status === "pending") return { state: "pending", label: "Waiting for you" };
if (interaction.status === "accepted") return { state: "accepted", label: "Accepted" };
if (interaction.status === "answered") return { state: "answered", label: "Answered" };
if (interaction.status === "rejected") return { state: "rejected", label: "Changes requested" };
if (outcome === "stale_target") return { state: "stale_target", label: "Stale — target moved" };
if (outcome === "superseded_by_comment") {
return { state: "superseded_by_comment", label: "Superseded by a later comment" };
}
if (outcome === "withdrawn") return { state: "withdrawn", label: "Withdrawn" };
if (outcome === "issue_closed") return { state: "issue_closed", label: "Issue closed" };
return { state: "expired", label: "Expired" };
}
function interactionPayload(
interaction: Phase7FixtureInteraction,
): Phase7ThreadInteractionCard["payload"] {
const payload = readRecord(interaction.payload ?? undefined);
switch (interaction.kind) {
case "questions":
return {
kind: "questions",
submitLabel: readString(payload.submitLabel, "Submit answers"),
questions: (Array.isArray(payload.questions) ? payload.questions : []).map(
(entry, index) => {
const question = readRecord(entry);
const options = Array.isArray(question.options)
? question.options.map((option) => readString(option))
: undefined;
return {
id: readString(question.id, `q${index + 1}`),
prompt: readString(question.prompt),
control:
options === undefined || options.length === 0
? ("text" as const)
: options.length > 3
? ("select" as const)
: ("radio" as const),
options,
required: question.required === true,
};
},
),
};
case "confirmation":
return {
kind: "confirmation",
targetSummary: readString(payload.targetSummary, interaction.prompt),
acceptLabel: readString(payload.acceptLabel, "Accept"),
rejectLabel: readString(payload.rejectLabel, "Reject"),
requireRejectReason: payload.requireRejectReason !== false,
};
case "checkbox":
return {
kind: "checkbox",
options: (Array.isArray(payload.options) ? payload.options : []).map((entry, index) => {
const option = readRecord(entry);
return {
id: readString(option.id, `o${index + 1}`),
label: readString(option.label),
defaultSelected: option.defaultSelected === true,
};
}),
minSelected: readNumber(payload.minSelected, 0),
maxSelected: readNumber(
payload.maxSelected,
Array.isArray(payload.options) ? payload.options.length : 0,
),
acceptLabel: readString(payload.acceptLabel, "Accept"),
rejectLabel: readString(payload.rejectLabel, "Reject"),
};
case "suggest_tasks":
return {
kind: "suggest_tasks",
acceptLabel: readString(payload.acceptLabel, "Create selected tasks"),
tasks: (Array.isArray(payload.tasks) ? payload.tasks : []).map((entry, index) => {
const task = readRecord(entry);
return {
id: readString(task.id, `t${index + 1}`),
title: readString(task.title),
description: readString(task.description),
};
}),
};
case "item_verdicts":
return {
kind: "item_verdicts",
submitLabel: readString(payload.submitLabel, "Submit verdicts"),
items: (Array.isArray(payload.items) ? payload.items : []).map((entry, index) => {
const item = readRecord(entry);
return {
id: readString(item.id, `i${index + 1}`),
title: readString(item.title),
requireReason: item.requireReason === true,
lockedVerdict: null,
};
}),
};
}
}
function interactionCard(
interaction: Phase7FixtureInteraction,
state: Phase7FixtureState | null,
): Phase7ThreadInteractionCard {
const view = interactionState(interaction);
const result = readRecord(interaction.result ?? undefined);
const document = state?.documents.find((candidate) =>
candidate.revisions.some((revision) => revision.id === interaction.targetRevisionId),
);
const revision = document?.revisions.find(
(candidate) => candidate.id === interaction.targetRevisionId,
);
return {
interactionId: interaction.id,
interactionKind: interaction.kind,
title: interaction.title,
prompt: interaction.prompt,
payload: interactionPayload(interaction),
state: view.state,
stateLabel: view.label,
target:
document !== undefined && revision !== undefined
? {
label: `${document.key} · r${revision.revision}`,
href: `?panel=state&rec=${document.id}`,
}
: null,
resolvedSummary: Array.isArray(result.summary)
? result.summary.map((entry) => readString(entry))
: [],
reason: typeof result.reason === "string" ? result.reason : null,
supersededBy:
typeof result.supersededBy === "string"
? { label: result.supersededBy, href: `?panel=control_plane&rec=${result.supersededBy}` }
: null,
evidenceRef: { section: "authorization", recordId: `interaction:${interaction.id}` },
};
}
interface CallPair {
callId: string;
turnId: string | null;
operationId: string;
input: Phase7JsonValue;
result: Phase7JsonValue | null;
at: string;
beforeRevision: number;
afterRevision: number | null;
}
function collectCalls(evidence: Phase7LiveEvidenceEntry[]): CallPair[] {
const pairs = new Map<string, CallPair>();
const order: string[] = [];
for (const entry of evidence) {
if (entry.kind !== "tool_call" && entry.kind !== "tool_result") continue;
const callId = readString(entry.data.callId);
if (callId.length === 0) continue;
let pair = pairs.get(callId);
if (pair === undefined) {
pair = {
callId,
turnId: entry.turnId,
operationId: readString(entry.data.operationId),
input: entry.data.input ?? null,
result: null,
at: entry.at,
beforeRevision: readNumber(entry.data.beforeRevision),
afterRevision: null,
};
pairs.set(callId, pair);
order.push(callId);
}
if (entry.kind === "tool_result") {
pair.result = entry.data.result ?? null;
pair.afterRevision = readNumber(entry.data.afterRevision, pair.beforeRevision);
}
}
return order.map((callId) => pairs.get(callId)!);
}
function toolActivityItem(pair: CallPair): Phase7ThreadItem {
const payload = readRecord(pair.result ?? undefined);
if (pair.result === null) {
return {
kind: "tool_activity",
id: `item-call-${pair.callId}`,
at: pair.at,
status: "running",
operationId: pair.operationId,
summary: `${pair.operationId} · running`,
input: pair.input,
result: null,
evidenceRef: { section: "calls", recordId: pair.callId },
};
}
if (payload.ok === false) {
return {
kind: "denial",
id: `item-call-${pair.callId}`,
at: pair.at,
operationId: pair.operationId,
reason: readString(readRecord(payload.denial).message, "denied"),
evidenceRef: { section: "authorization", recordId: `call:${pair.callId}` },
};
}
return {
kind: "tool_activity",
id: `item-call-${pair.callId}`,
at: pair.at,
status: "ok",
operationId: pair.operationId,
summary: toolSummary(pair.operationId, pair.result),
input: pair.input,
result: pair.result,
evidenceRef: { section: "calls", recordId: pair.callId },
};
}
function derivedRecordItems(
pair: CallPair,
state: Phase7FixtureState | null,
): Phase7ThreadItem[] {
const payload = readRecord(pair.result ?? undefined);
if (payload.ok !== true || state === null) return [];
const command = readRecord(payload.result);
const entityRefs = Array.isArray(command.entityRefs)
? command.entityRefs.map((entry) => readString(entry))
: [];
// Mock entity refs are `<kind>:<id>` pairs, e.g. `comment:comment-1`.
const refsOf = (kind: string): string[] =>
entityRefs
.filter((ref) => ref.startsWith(`${kind}:`))
.map((ref) => ref.slice(kind.length + 1));
const operationId = pair.operationId as Phase7SemanticOperationId;
const items: Phase7ThreadItem[] = [];
if (DURABLE_COMMENT_OPERATIONS.has(operationId)) {
for (const ref of refsOf("comment")) {
const comment = state.comments.find((candidate) => candidate.id === ref);
if (comment === undefined) continue;
items.push({
kind: "durable_comment",
id: `item-comment-${comment.id}`,
at: comment.createdAt,
author:
state.actors.find((actor) => actor.id === comment.authorActorId)?.name ?? "Agent",
body: comment.body,
operationId,
evidenceRef: { section: "calls", recordId: pair.callId },
});
}
}
if (operationId === "write_document") {
for (const ref of refsOf("document")) {
const document = state.documents.find((candidate) => candidate.id === ref);
if (document === undefined) continue;
const latest = document.revisions.at(-1);
const previous = document.revisions.at(-2);
if (latest === undefined) continue;
items.push({
kind: "document",
id: `item-document-${document.id}-${latest.id}`,
at: latest.createdAt,
documentKey: document.key,
title: document.title,
author: state.actors[0]?.name ?? "Agent",
revisionFrom: previous?.revision ?? null,
revisionTo: latest.revision,
staleBehind: null,
evidenceRef: { section: "state", recordId: `document:${document.id}` },
});
}
}
if (operationId === "register_deliverable") {
for (const ref of refsOf("artifact")) {
const artifact = state.artifacts.find((candidate) => candidate.id === ref);
if (artifact === undefined) continue;
items.push({
kind: "deliverable",
id: `item-artifact-${artifact.id}`,
at: artifact.createdAt,
filename: artifact.filename,
deliverableKind: "attachment bytes",
byteSize: artifact.byteSize,
registeredBy: state.actors[0]?.name ?? "Agent",
contentRef: artifact.contentRef,
evidenceRef: { section: "calls", recordId: pair.callId },
});
}
}
if (operationId === "create_task" || operationId === "set_dependencies") {
const created = refsOf("task")
.filter((id) => id !== state.tasks[0]?.id)
.map((ref) => state.tasks.find((candidate) => candidate.id === ref))
.filter((task): task is NonNullable<typeof task> => task !== undefined)
.map((task) => ({ identifier: task.identifier, title: task.title }));
const edges = state.blockers.map((blocker) => {
const blocking = state.tasks.find((task) => task.id === blocker.blockedByTaskId);
const blocked = state.tasks.find((task) => task.id === blocker.taskId);
return `${blocking?.identifier ?? blocker.blockedByTaskId} blocks ${blocked?.identifier ?? blocker.taskId}`;
});
if (created.length > 0 || edges.length > 0) {
items.push({
kind: "dependency",
id: `item-dependency-${pair.callId}`,
at: pair.at,
createdTasks: created,
blockerEdges: edges,
evidenceRef: { section: "calls", recordId: pair.callId },
});
}
}
if (DISPOSITION_OPERATIONS.has(operationId)) {
const task = state.tasks.find((candidate) => candidate.id === refsOf("task")[0]);
items.push({
kind: "disposition",
id: `item-disposition-${pair.callId}`,
at: pair.at,
operationId,
status: task?.status ?? "in_review",
body: readString(readRecord(pair.input).summary, readString(readRecord(pair.input).reason)),
blockerOwner: null,
evidenceRef: { section: "calls", recordId: pair.callId },
});
}
for (const ref of refsOf("interaction")) {
const interaction = state.interactions.find((candidate) => candidate.id === ref);
if (interaction === undefined) continue;
items.push({
kind: "interaction",
id: `item-interaction-${interaction.id}`,
at: interaction.createdAt,
...interactionCard(interaction, state),
});
}
return items;
}
function composerModel(
snapshot: Phase7LiveSessionSnapshot,
connection: Phase7ThreadConnection,
mode: Phase7ThreadMode,
pendingInteractionId: string | null,
terminal: boolean,
): Phase7ComposerModel {
if (mode === "replay") {
return { state: "disabled", helper: null, reason: "Replay is read-only", pendingInteractionId: null };
}
if (connection.state === "reconnecting") {
return {
state: "reconnecting",
helper: "Reconnecting… your session is preserved.",
reason: null,
pendingInteractionId,
};
}
if (terminal || snapshot.status === "closed" || snapshot.status === "failed") {
return {
state: "disabled",
helper: null,
reason: terminal ? "Issue is done" : "Session is closed",
pendingInteractionId: null,
};
}
if (pendingInteractionId !== null) {
return {
state: "waiting",
helper: "Answer the pending request above to continue.",
reason: null,
pendingInteractionId,
};
}
if (snapshot.activeTurnId !== null) {
return {
state: "streaming",
helper: "Codex is working — send to steer, or stop the turn.",
reason: null,
pendingInteractionId: null,
};
}
return { state: "ready", helper: null, reason: null, pendingInteractionId: null };
}
function evidenceModel(
snapshot: Phase7LiveSessionSnapshot,
state: Phase7FixtureState | null,
calls: CallPair[],
input: Phase7LiveProjectionInput,
): Phase7EvidenceModel {
const exposureEntry = [...snapshot.evidence]
.reverse()
.find((entry) => entry.kind === "tool_exposure");
const exposedIds = new Set(
Array.isArray(exposureEntry?.data.operationIds)
? exposureEntry.data.operationIds.map((entry) => readString(entry))
: [],
);
const rows: Phase7EvidenceToolRow[] = PHASE7_SEMANTIC_TOOL_CATALOG.map((descriptor) => ({
operationId: descriptor.operationId,
disposition: dispositionFor(descriptor.operationId, exposedIds),
grant: descriptor.requiredClaims[0] ?? null,
description: descriptor.description,
}));
const turnIds = [...new Set(snapshot.transcript.map((entry) => entry.turnId ?? "turn-0"))];
return {
tools: turnIds.map((turnId) => ({ id: `tools-${turnId}`, turnId, rows })),
calls: calls.map((pair) => {
const payload = readRecord(pair.result ?? undefined);
return {
id: pair.callId,
turnId: pair.turnId ?? "turn-0",
operationId: pair.operationId,
version: 1,
providerRequest: `tools/call ${pair.operationId} ${JSON.stringify(pair.input)}`,
dispatchedCommand:
payload.ok === false ? "rejected before dispatch" : `control_plane.dispatch(${pair.operationId})`,
outcome: payload.ok === false ? ("denied" as const) : ("ok" as const),
result: pair.result ?? null,
redactions: [],
threadAnchorId: `item-call-${pair.callId}`,
};
}),
authorization: snapshot.authorizationRecords.map((record) => ({
id: record.id,
turnId: record.callId === null ? "turn-0" : (calls.find((pair) => pair.callId === record.callId)?.turnId ?? "turn-0"),
operationId: record.operationId,
phase: record.phase,
allowed: record.allowed,
code: record.code,
reason: record.reason,
claimsConsidered: [...record.effectiveClaims],
redactions: [],
stateChangeRef: null,
threadAnchorId: record.callId === null ? "" : `item-call-${record.callId}`,
})),
control_plane: (state?.decisions ?? []).map((decision) => ({
id: decision.id,
turnId: "turn-0",
category:
decision.operation === "open_run"
? ("checkout" as const)
: decision.operation === "schedule_wake"
? ("wake" as const)
: decision.outcome === "duplicate"
? ("idempotency" as const)
: decision.outcome === "reconciled"
? ("reconciliation" as const)
: ("session" as const),
outcome: decision.outcome,
reason: decision.reason,
stateRevision: decision.stateRevision,
threadAnchorId: null,
})),
runner: snapshot.evidence.map((entry, index) => ({
id: entry.id,
turnId: entry.turnId ?? "turn-0",
kind: entry.kind,
ordinal: index + 1,
detail: JSON.stringify(entry.data),
})),
state: calls
.filter((pair) => pair.afterRevision !== null && pair.afterRevision !== pair.beforeRevision)
.map((pair) => ({
id: `diff-${pair.callId}`,
turnId: pair.turnId ?? "turn-0",
fromRevision: pair.beforeRevision,
toRevision: pair.afterRevision ?? pair.beforeRevision,
rows: (Array.isArray(readRecord(readRecord(pair.result ?? undefined).result).entityRefs)
? (readRecord(readRecord(pair.result ?? undefined).result).entityRefs as Phase7JsonValue[])
: []
).map((ref) => ({
entityClass: pair.operationId,
entityRef: readString(ref),
before: `revision ${pair.beforeRevision}`,
after: `revision ${pair.afterRevision ?? pair.beforeRevision}`,
})),
})),
traceability: input.traceability ?? [],
parity: input.parity ?? [],
};
}
export function projectPhase7IssueThread(
input: Phase7LiveProjectionInput,
): Phase7IssueThreadSnapshot {
const { snapshot } = input;
const mode = input.mode ?? "live";
const connection = input.connection ?? { state: "connected", attempt: 0 };
const state = parseMockState(snapshot.mockState);
const calls = collectCalls(snapshot.evidence);
const task =
state?.tasks.find((candidate) => candidate.id === snapshot.authority.taskId) ??
state?.tasks[0] ??
null;
const turns = new Map<string, Phase7ThreadTurn>();
const turnOrder: string[] = [];
function turnFor(turnId: string | null, at: string): Phase7ThreadTurn {
const key = turnId ?? "turn-0";
let turn = turns.get(key);
if (turn === undefined) {
turnOrder.push(key);
turn = {
id: key,
ordinal: turnOrder.length,
mode,
toolCallCount: 0,
at,
stoppedByUser: false,
items: [],
};
turns.set(key, turn);
}
return turn;
}
for (const entry of snapshot.transcript) {
if (entry.role === "user" && isInteractionResultMessage(entry.text)) continue;
const turn = turnFor(entry.turnId, entry.at);
if (entry.role === "user") {
turn.items.push({
kind: "user_message",
id: entry.id,
at: entry.at,
author: "You (board user)",
body: entry.text,
});
} else if (entry.role === "assistant") {
turn.items.push({
kind: "agent_message",
id: entry.id,
at: entry.at,
author: mode === "live" ? "Real Codex" : "Fake agent",
body: entry.text,
streaming: snapshot.activeTurnId !== null && entry.turnId === snapshot.activeTurnId,
});
} else {
turn.items.push({
kind: "system_notice",
id: entry.id,
at: entry.at,
glyph: "⚙",
text: entry.text,
evidenceRef: { section: "control_plane", recordId: entry.id },
});
}
}
for (const pair of calls) {
const turn = turnFor(pair.turnId, pair.at);
turn.toolCallCount += 1;
turn.items.push(toolActivityItem(pair));
turn.items.push(...derivedRecordItems(pair, state));
}
const stopRequested = snapshot.evidence.some(
(entry) => entry.kind === "session" && readString(entry.data.action) === "stop_requested",
);
if (stopRequested) {
const turn = turns.get(snapshot.activeTurnId ?? turnOrder.at(-1) ?? "turn-0");
if (turn !== undefined) turn.stoppedByUser = true;
}
const pendingInteraction =
state?.interactions.find((interaction) => interaction.status === "pending") ?? null;
const terminal = task?.status === "done" || task?.status === "cancelled";
return {
schema: PHASE7_ISSUE_THREAD_VIEW_SCHEMA,
sessionId: snapshot.sessionId,
mode,
identity: {
agentLabel: mode === "live" ? "Real Codex" : mode === "replay" ? "Replay" : "Fake agent",
runnerLabel: mode === "live" ? "Real runnerd" : "In-process runner",
runnerAttached: snapshot.status === "idle" || snapshot.status === "running",
controlPlaneLabel: "Mock Paperclip",
controlPlaneTooltip: "All issue records are mock. No real Paperclip API is reachable.",
replaySource: input.replaySource ?? null,
},
issue: {
identifier: task?.identifier ?? "MCK-1",
title: task?.title ?? "Mock task",
status: task?.status ?? "in_progress",
priority: task?.priority ?? "medium",
assignee:
state?.actors.find((actor) => actor.id === task?.assigneeActorId)?.name ?? null,
runState: `${snapshot.authority.runId} · ${snapshot.status}`,
scenarioId: snapshot.authority.scenarioId,
fixtureProfile: input.fixtureProfile ?? snapshot.authority.scenarioId,
},
turns: turnOrder.map((id) => turns.get(id)!),
composer: composerModel(
snapshot,
connection,
mode,
pendingInteraction?.id ?? null,
terminal,
),
evidence: evidenceModel(snapshot, state, calls, input),
connection,
replay: null,
renderedAt: snapshot.updatedAt,
};
}
@@ -0,0 +1,449 @@
/**
* Phase 7G issue-thread view contract.
*
* The browser renders exactly this shape and nothing else. Every field is a
* projection of a mock-core record, a canonical event, or a semantic
* authorization record produced by the package server (Phase 7B UX contract
* §11: the browser holds no policy, diff, or parity authority). Two producers
* exist — the deterministic `fake` fixtures used by the screenshot matrix and
* the live session projection — and they emit the same schema.
*/
import type {
Phase7InteractionKind,
Phase7JsonValue,
Phase7TaskStatus,
} from "../mock-core/phase7-control-plane-types.js";
import type { Phase7SemanticOperationId } from "../semantic-tools/types.js";
export const PHASE7_ISSUE_THREAD_VIEW_SCHEMA = "paperclip.phase7.issue-thread-view.v1" as const;
/** Contract §0: a surface never shows an unlabeled mode. */
export type Phase7ThreadMode = "live" | "fake" | "replay";
/** Contract §0: closed disposition enum; UI copy is fixed per disposition. */
export type Phase7ToolDisposition =
| "control_plane_owned"
| "always_agent_tool"
| "optional_agent_tool";
export const PHASE7_DISPOSITION_LABELS: Readonly<Record<Phase7ToolDisposition, string>> = {
always_agent_tool: "Agent tool — always",
optional_agent_tool: "Agent tool — granted",
control_plane_owned: "Control plane",
};
export interface Phase7ThreadIdentity {
/** `Real Codex` in live mode, `Fake agent` in fake mode, `Replay` in replay mode. */
agentLabel: string;
/** `Real runnerd` in live mode, `In-process runner` otherwise. */
runnerLabel: string;
/** Pulse while a runnerd session is attached; static when detached. */
runnerAttached: boolean;
/** Always `Mock Paperclip`. */
controlPlaneLabel: string;
controlPlaneTooltip: string;
/** Present in replay mode so replay evidence can never satisfy a live criterion. */
replaySource: "fake" | "live" | null;
}
export interface Phase7ThreadIssue {
identifier: string;
title: string;
status: Phase7TaskStatus;
priority: "critical" | "high" | "medium" | "low";
assignee: string | null;
runState: string;
scenarioId: string;
fixtureProfile: string;
}
/** Contract §4. `data-composer-state` is the test and screenshot hook. */
export type Phase7ComposerState =
| "ready"
| "sending"
| "streaming"
| "waiting"
| "reconnecting"
| "disabled";
export interface Phase7ComposerModel {
state: Phase7ComposerState;
helper: string | null;
/** Reason line rendered by the `disabled` state. */
reason: string | null;
/** Anchor target for the `waiting` helper link. */
pendingInteractionId: string | null;
}
export type Phase7ThreadInteractionState =
| "pending"
| "submitting"
| "accepted"
| "answered"
| "rejected"
| "stale_target"
| "superseded_by_comment"
| "expired"
| "withdrawn"
| "issue_closed";
export interface Phase7ThreadLink {
label: string;
/** In-explorer route only. Real Paperclip URLs never appear (§11). */
href: string;
}
export interface Phase7ThreadQuestion {
id: string;
prompt: string;
control: "radio" | "select" | "text";
options?: string[];
required?: boolean;
}
export interface Phase7ThreadCheckboxOption {
id: string;
label: string;
defaultSelected?: boolean;
}
export interface Phase7ThreadProposedTask {
id: string;
title: string;
description: string;
}
export interface Phase7ThreadVerdictItem {
id: string;
title: string;
requireReason?: boolean;
/** Set once the item has been submitted in a partial submit. */
lockedVerdict?: "approve" | "reject" | "defer" | null;
}
export type Phase7ThreadInteractionPayload =
| { kind: "questions"; questions: Phase7ThreadQuestion[]; submitLabel: string }
| {
kind: "confirmation";
targetSummary: string;
acceptLabel: string;
rejectLabel: string;
requireRejectReason: boolean;
}
| {
kind: "checkbox";
options: Phase7ThreadCheckboxOption[];
minSelected: number;
maxSelected: number;
acceptLabel: string;
rejectLabel: string;
}
| { kind: "suggest_tasks"; tasks: Phase7ThreadProposedTask[]; acceptLabel: string }
| { kind: "item_verdicts"; items: Phase7ThreadVerdictItem[]; submitLabel: string };
export interface Phase7ThreadInteractionCard {
interactionId: string;
interactionKind: Phase7InteractionKind;
title: string;
prompt: string;
payload: Phase7ThreadInteractionPayload;
state: Phase7ThreadInteractionState;
/** Revision-bound confirmation target, e.g. `plan · r4`. */
target: Phase7ThreadLink | null;
/** Chip copy, always paired with a glyph in the renderer (§9.5). */
stateLabel: string;
/** Chosen values summarised inline once resolved. */
resolvedSummary: string[];
/** Verbatim reject reason. */
reason: string | null;
/** Superseding revision or comment for the expired outcomes. */
supersededBy: Phase7ThreadLink | null;
evidenceRef: Phase7EvidenceRef;
}
export interface Phase7EvidenceRef {
section: Phase7EvidenceSectionId;
recordId: string;
}
export type Phase7ThreadItem =
| { kind: "user_message"; id: string; at: string; author: string; body: string }
| {
kind: "agent_message";
id: string;
at: string;
author: string;
body: string;
streaming: boolean;
}
| {
kind: "durable_comment";
id: string;
at: string;
author: string;
body: string;
operationId: Phase7SemanticOperationId;
evidenceRef: Phase7EvidenceRef;
}
| {
kind: "tool_activity";
id: string;
at: string;
status: "ok" | "denied" | "running";
operationId: string;
summary: string;
input: Phase7JsonValue;
result: Phase7JsonValue;
evidenceRef: Phase7EvidenceRef;
}
| ({ kind: "interaction"; id: string; at: string } & Phase7ThreadInteractionCard)
| {
kind: "document";
id: string;
at: string;
documentKey: string;
title: string;
author: string;
revisionFrom: number | null;
revisionTo: number;
staleBehind: number | null;
evidenceRef: Phase7EvidenceRef;
}
| {
kind: "deliverable";
id: string;
at: string;
filename: string;
deliverableKind: "attachment bytes" | "external ref" | "workspace file";
byteSize: number;
registeredBy: string;
contentRef: string;
evidenceRef: Phase7EvidenceRef;
}
| {
kind: "dependency";
id: string;
at: string;
createdTasks: Array<{ identifier: string; title: string }>;
blockerEdges: string[];
evidenceRef: Phase7EvidenceRef;
}
| {
kind: "disposition";
id: string;
at: string;
operationId: Phase7SemanticOperationId;
status: Phase7TaskStatus;
body: string;
blockerOwner: string | null;
evidenceRef: Phase7EvidenceRef;
}
| {
kind: "denial";
id: string;
at: string;
operationId: string;
/** Verbatim from the authorization record; never paraphrased. */
reason: string;
evidenceRef: Phase7EvidenceRef;
}
| {
kind: "system_notice";
id: string;
at: string;
glyph: string;
text: string;
evidenceRef: Phase7EvidenceRef;
};
export interface Phase7ThreadTurn {
id: string;
ordinal: number;
mode: Phase7ThreadMode;
toolCallCount: number;
at: string;
stoppedByUser: boolean;
items: Phase7ThreadItem[];
}
export type Phase7EvidenceSectionId =
| "tools"
| "calls"
| "authorization"
| "control_plane"
| "runner"
| "state"
| "traceability"
| "parity";
export const PHASE7_EVIDENCE_SECTIONS: ReadonlyArray<{
id: Phase7EvidenceSectionId;
title: string;
}> = [
{ id: "tools", title: "Tools exposed" },
{ id: "calls", title: "Calls & results" },
{ id: "authorization", title: "Authorization" },
{ id: "control_plane", title: "Control plane" },
{ id: "runner", title: "Runner & events" },
{ id: "state", title: "State diff" },
{ id: "traceability", title: "Traceability" },
{ id: "parity", title: "Parity" },
];
export interface Phase7EvidenceToolRow {
operationId: string;
disposition: Phase7ToolDisposition;
/** 7A grant string rendered verbatim, e.g. `rf:read_or_write`. */
grant: string | null;
description: string;
}
export interface Phase7EvidenceToolsRecord {
id: string;
turnId: string;
rows: Phase7EvidenceToolRow[];
}
export interface Phase7EvidenceCallRecord {
id: string;
turnId: string;
operationId: string;
version: number;
providerRequest: string;
dispatchedCommand: string;
outcome: "ok" | "denied";
result: Phase7JsonValue;
redactions: string[];
threadAnchorId: string;
}
export interface Phase7EvidenceAuthorizationRecord {
id: string;
turnId: string;
operationId: string;
phase: "exposure" | "invocation";
allowed: boolean;
code: string;
reason: string;
claimsConsidered: string[];
redactions: string[];
stateChangeRef: string | null;
threadAnchorId: string;
}
export interface Phase7EvidenceControlPlaneRecord {
id: string;
turnId: string;
category: "checkout" | "wake" | "budget" | "idempotency" | "reconciliation" | "session";
outcome: string;
reason: string;
stateRevision: number;
threadAnchorId: string | null;
}
export interface Phase7EvidenceRunnerRecord {
id: string;
turnId: string;
kind: string;
ordinal: number;
detail: string;
}
export interface Phase7EvidenceStateDiffRow {
entityClass: string;
entityRef: string;
before: string;
after: string;
}
export interface Phase7EvidenceStateDiffRecord {
id: string;
turnId: string;
fromRevision: number;
toRevision: number;
rows: Phase7EvidenceStateDiffRow[];
}
export interface Phase7EvidenceTraceabilityRecord {
id: string;
turnId: string;
capabilityId: string;
sourceAnchor: string;
caseId: string;
group: string;
browserEvidenceRecipe: string;
expectedSemanticOperations: string[];
forbiddenOperations: string[];
requiredCapabilityGrants: string[];
}
export interface Phase7EvidenceParityRecord {
id: string;
turnId: string;
assertion: string;
verdict: "pass" | "fail" | "intentional_gap";
note: string | null;
}
export interface Phase7EvidenceModel {
tools: Phase7EvidenceToolsRecord[];
calls: Phase7EvidenceCallRecord[];
authorization: Phase7EvidenceAuthorizationRecord[];
control_plane: Phase7EvidenceControlPlaneRecord[];
runner: Phase7EvidenceRunnerRecord[];
state: Phase7EvidenceStateDiffRecord[];
traceability: Phase7EvidenceTraceabilityRecord[];
parity: Phase7EvidenceParityRecord[];
}
export interface Phase7ThreadConnection {
state: "connected" | "reconnecting" | "closed";
attempt: number;
}
export interface Phase7ReplayModel {
ordinal: number;
total: number;
}
export interface Phase7IssueThreadSnapshot {
schema: typeof PHASE7_ISSUE_THREAD_VIEW_SCHEMA;
sessionId: string;
mode: Phase7ThreadMode;
identity: Phase7ThreadIdentity;
issue: Phase7ThreadIssue;
turns: Phase7ThreadTurn[];
composer: Phase7ComposerModel;
evidence: Phase7EvidenceModel;
connection: Phase7ThreadConnection;
replay: Phase7ReplayModel | null;
/** Fixture clock; every rendered timestamp derives from snapshot data. */
renderedAt: string;
}
export function phase7DispositionLabel(disposition: Phase7ToolDisposition): string {
return PHASE7_DISPOSITION_LABELS[disposition];
}
/**
* Authorization denials that the agent actually hit. Drives the `Evidence`
* badge (§2.2) and reads straight off authorization records — the browser never
* decides whether something was denied.
*
* Exposure-phase denials are excluded on purpose: withholding an ungranted
* operation from the tool list is the normal, expected path (it is what §7's
* `Control plane — not exposed` list renders), so badging it would cry wolf on
* every turn. Only invocation-phase denials mean the model was refused.
*/
export function phase7DenialCount(
evidence: Phase7EvidenceModel,
turnId: string | null,
): number {
return evidence.authorization.filter(
(record) =>
!record.allowed &&
record.phase === "invocation" &&
(turnId === null || record.turnId === turnId),
).length;
}
@@ -16,6 +16,8 @@
},
"include": [
"devtools/browser/src/**/*.ts",
"devtools/issue-thread/src/**/*.ts",
"devtools/issue-thread/src/**/*.tsx",
"devtools/browser/src/**/*.tsx",
"examples/*/src/**/*.ts",
"examples/*/src/**/*.tsx"
@@ -0,0 +1,22 @@
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import react from "@vitejs/plugin-react";
import { defineConfig } from "vite";
import { phase7IssueThreadServerPlugin } from "./scripts/phase7-issue-thread-server.mjs";
const packageRoot = fileURLToPath(new URL(".", import.meta.url));
export default defineConfig({
root: resolve(packageRoot, "devtools/issue-thread"),
plugins: [react(), phase7IssueThreadServerPlugin()],
server: { host: "127.0.0.1" },
preview: { host: "127.0.0.1" },
optimizeDeps: { esbuildOptions: { target: "esnext" } },
build: {
outDir: resolve(packageRoot, "dist-issue-thread"),
emptyOutDir: true,
target: "esnext",
},
});