feat(runner): consolidate accepted Phase 7 issue thread UI

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-08-10 12:22:23 +00:00
1 parent a7d0c2c27a
commit c6d99430ee
62 files changed
+8560

No files matched your search

@@ -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,404 @@
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("the deliverable strip and card report one size in one unit", async ({ page }) => {
await open(page, "deliverable-registered");
const card = page.locator('[data-thread-item="deliverable"]');
await expect(card).toContainText("18.0 kB");
await expect(
page.locator('[data-tool-strip="register_deliverable"]'),
).toContainText("18.0 kB");
});
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("the replay strip steps, advances, and plays through to the end", async ({ page }) => {
await open(page, "replay-mode");
const strip = page.getByTestId("replay-strip");
const progress = page.getByRole("progressbar", { name: "Replay progress" });
await page.getByTestId("replay-step-back").click();
await expect(strip).toContainText("Replay 11/18");
await expect(progress).toHaveAttribute("aria-valuenow", "11");
await page.getByTestId("replay-next-turn").click();
await expect(strip).toContainText("Replay 12/18");
// Play-all runs the recording out and parks itself at the last ordinal
// instead of leaving the operator to click Next turn eighteen times (§6).
const playAll = page.getByTestId("replay-play-all");
await playAll.click();
await expect(playAll).toHaveAttribute("aria-pressed", "true");
await expect(strip).toContainText("Replay 18/18", { timeout: 15_000 });
await expect(playAll).toHaveAttribute("aria-pressed", "false");
await expect(page.getByTestId("replay-next-turn")).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("Escape closes the desktop overlay sheet and returns focus to its toggle", async ({
page,
}) => {
// Between the mobile segment and the docked side panel, Evidence renders as
// an overlay sheet; §9.1 makes Escape dismiss it.
await page.setViewportSize({ width: 1000, height: 800 });
await open(page, "thread-baseline");
await page.getByTestId("evidence-toggle").click();
const panel = page.getByTestId("evidence-panel");
await expect(panel).toHaveAttribute("data-layout", "overlay");
await expect(page.getByRole("heading", { name: "Evidence" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(panel).toHaveCount(0);
await expect(page.getByTestId("evidence-toggle")).toBeFocused();
});
test("resolving an interaction card moves focus to its state chip", async ({ page }) => {
await open(page, "interaction-question-pending");
const card = page.locator('[data-interaction-id="ix-questions-01"]');
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")).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.issue-thread.config.ts --host 127.0.0.1 --port 4184",
url: "http://127.0.0.1:4184",
reuseExistingServer: false,
timeout: 60_000,
},
});
@@ -0,0 +1,670 @@
import { useCallback, useEffect, useLayoutEffect, 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;
/** Play-all cadence for the replay strip (§6); slow enough to read a turn. */
const REPLAY_STEP_MS = 800;
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;
}
}
/**
* Scroll an Evidence target into the panel viewport. `start` alignment has to
* account for the sticky panel head, which would otherwise sit on top of the
* section header the deep link was supposed to reveal.
*/
function scrollEvidenceIntoView(target: Element, block: "start" | "center"): void {
target.scrollIntoView({ block });
if (block !== "start") return;
const panel = target.closest<HTMLElement>(".pit-panel");
const head = panel?.querySelector<HTMLElement>(".pit-panel-head");
if (panel == null || head == null) return;
panel.scrollTop = Math.max(0, panel.scrollTop - head.offsetHeight);
}
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 [playing, setPlaying] = 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]);
useLayoutEffect(() => {
// A deep link has to land on what it addressed. With `rec` that is the
// record; without it, the opened section's header — otherwise the link
// half-arrives with the section still below the fold. Layout effect so the
// scroll is committed before paint and a capture can never race it.
if (route.panel === null || snapshot === null) return;
const target =
route.record === null
? document.querySelector(`[data-evidence-section="${CSS.escape(route.panel)}"]`)
: document.querySelector(`[data-record-id="${CSS.escape(route.record)}"]`);
if (target !== null) scrollEvidenceIntoView(target, route.record === null ? "start" : "center");
}, [route.panel, route.record, snapshot, panelOpen, segment, openSections]);
useEffect(() => {
setSegment(route.segment);
}, [route.segment]);
/* -------------------------------------------------- settle + auto-follow */
useEffect(() => {
if (snapshot === null) return;
let cancelled = false;
const scroller = scrollRef.current;
void (async () => {
if (typeof document.fonts?.ready?.then === "function") {
await document.fonts.ready;
}
if (cancelled) return;
if (scroller !== null) scroller.scrollTop = scroller.scrollHeight;
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()));
if (!cancelled) setSettled(true);
})();
return () => {
cancelled = true;
};
}, [snapshot]);
useEffect(() => {
if (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 closeEvidence = useCallback(() => {
setPanelOpen(false);
persistPanel(false, panelWidth);
if (layout === "segment") setSegment("thread");
// Focus came into the panel when it opened (§9.2), so hand it back to the
// visible control that owns the panel rather than dropping it on `body`.
window.setTimeout(() => {
const candidates = ['[data-testid="evidence-toggle"]', '[data-testid="segment-thread"]'];
for (const selector of candidates) {
const control = document.querySelector<HTMLElement>(selector);
if (control !== null && control.offsetParent !== null) {
control.focus();
return;
}
}
}, 0);
}, [layout, panelWidth, persistPanel]);
const jumpToThread = useCallback((anchorId: string) => {
setSegment("thread");
const anchor = document.getElementById(anchorId) ?? document.querySelector(`#${CSS.escape(anchorId)}`);
anchor?.scrollIntoView({ block: "center" });
}, []);
const respond = useCallback(
(response: 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]);
/* ----------------------------------------------------------- replay (§6) */
// `?at=<ordinal>` is the source of truth for where the recording is parked,
// so step / next-turn / play-all and the deep link all move the same value.
const replay = useMemo(() => {
if (snapshot === null || snapshot.replay === null) return null;
const { total } = snapshot.replay;
const ordinal =
route.at === null ? snapshot.replay.ordinal : Math.min(total, Math.max(0, route.at));
return { ordinal, total };
}, [snapshot, route.at]);
const seekReplay = useCallback(
(ordinal: number) => {
setPlaying(false);
window.location.hash = phase7RouteHref(route, { at: ordinal });
},
[route],
);
useEffect(() => {
if (!playing || replay === null) return;
if (replay.ordinal >= replay.total) {
setPlaying(false);
return;
}
const timer = window.setTimeout(() => {
window.location.hash = phase7RouteHref(route, { at: replay.ordinal + 1 });
}, REPLAY_STEP_MS);
return () => window.clearTimeout(timer);
}, [playing, replay, route]);
useEffect(() => {
if (replay === null) setPlaying(false);
}, [replay]);
/* ---------------------------------------------------------------- render */
const denialCount = useMemo(
() => (snapshot === null ? 0 : 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}
{replay !== null ? (
<div className="pit-replay-strip" data-testid="replay-strip">
<span>
Replay {replay.ordinal}/{replay.total}
</span>
<div
className="pit-progress"
role="progressbar"
aria-valuemin={0}
aria-valuemax={replay.total}
aria-valuenow={replay.ordinal}
aria-label="Replay progress"
>
<div
className="pit-progress-fill"
style={{ width: `${(replay.ordinal / replay.total) * 100}%` }}
/>
</div>
<button
type="button"
className="pit-button"
disabled={replay.ordinal <= 0}
data-testid="replay-step-back"
onClick={() => seekReplay(replay.ordinal - 1)}
>
Step back
</button>
<button
type="button"
className="pit-button"
disabled={replay.ordinal >= replay.total}
data-testid="replay-next-turn"
onClick={() => seekReplay(replay.ordinal + 1)}
>
Next turn
</button>
<button
type="button"
className="pit-button"
data-variant={playing ? undefined : "primary"}
aria-pressed={playing}
disabled={!playing && replay.ordinal >= replay.total}
data-testid="replay-play-all"
onClick={() => setPlaying((current) => !current)}
>
<span aria-hidden="true">{playing ? "❚❚" : "▶"}</span>
{playing ? "Pause" : "Play all"}
</button>
</div>
) : null}
<div className="pit-body">
<main className="pit-main" hidden={!showThread}>
<div className="pit-main-inner">
<div
className="pit-thread-scroll"
ref={scrollRef}
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={closeEvidence}
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,416 @@
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();
}, []);
useEffect(() => {
// Contract §9.1: Escape closes the desktop overlay sheet. The side panel is
// a persistent region rather than a sheet, and the mobile segment is a tab
// view, so neither of those is dismissible this way.
if (layout !== "overlay") return;
function onKeyDown(event: KeyboardEvent) {
if (event.key !== "Escape") return;
// A modal dialog owns Escape while it is open (§6 reset confirm).
if (document.querySelector('[role="dialog"][aria-modal="true"]') !== null) return;
onClose();
}
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [layout, onClose]);
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,528 @@
import { useEffect, 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 stateChip = useRef<HTMLSpanElement | null>(null);
const previousState = useRef<Phase7ThreadInteractionState>(card.state);
const resolved = RESOLVED_STATES.has(card.state);
const busy = card.state === "submitting";
useEffect(() => {
// Contract §9.2: resolving a card moves focus to its state chip. The
// controls the user just used unmount on resolve, so without this the
// keyboard caret would fall back to `body` exactly when the card changes.
const from = previousState.current;
previousState.current = card.state;
if (from === card.state) return;
if (!RESOLVED_STATES.has(from) && RESOLVED_STATES.has(card.state)) {
stateChip.current?.focus();
}
}, [card.state]);
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"
tabIndex={-1}
chipRef={stateChip}
>
<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,91 @@
import type { ReactNode, Ref } 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,
/** Set to -1 so focus management can land on a chip without adding a tab stop. */
tabIndex,
chipRef,
}: {
tone?: "live" | "mock" | "accent" | "success" | "danger";
children: ReactNode;
title?: string;
testId?: string;
tabIndex?: number;
chipRef?: Ref<HTMLSpanElement>;
}) {
return (
<span
className="pit-chip"
data-tone={tone}
title={title}
data-testid={testId}
tabIndex={tabIndex}
ref={chipRef}
>
{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>
);
}
/** Re-exported so the thread strip and the deliverable card share one unit. */
export { phase7FormatBytes as formatBytes } from "../../../src/issue-thread/types";
@@ -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}` : ""}`;
}
@@ -0,0 +1,429 @@
# Phase 7B — Issue-Thread UX Contract
Status: **binding** for track 7G (Paperclip-style Web UI) and reviewable by tracks 7E/7K.
Source of authority: Phase 7 plan (PAP-16897 `plan` document), Revision 3 §"Page information
architecture", §"Native issue-thread interactions", §"Live execution contract", and Revision 4
§"Final evidence eligibility". Capability vocabulary: Phase 7A generated contract
(`generated/phase-07/capabilities.yaml`, `mcp-tool-map.yaml`, `eval-traceability.yaml`,
`contract-schema.json` — PAP-16939).
If a corner of this contract is underspecified, 7G comments on PAP-16940 and waits for a
revision. 7G does not diverge silently. Deviations are either fixed in the implementation or
written back into this document as an explicit revision.
---
## 0. Vocabulary (normative)
All UI copy and all implementation identifiers use the 7A terms. The UI never invents
capability language.
- **Dispositions** (closed enum): `control_plane_owned`, `always_agent_tool`,
`optional_agent_tool`.
- **Always semantic operations** (thread-visible verbs): `get_task_context`,
`report_progress`, `answer_status_question`, `finish_task`, `block_task`, `request_review`,
`write_document` (+ revision reads), `request_human_input` (kinds `questions`,
`confirmation`, `checkbox`, `suggest_tasks`, `item_verdicts`), `register_deliverable`.
- **Optional operation families**: discovery (`scoped_discovery`, `search_tasks`,
`list_agents`, …), delegation/dependencies (`create_task`, `delegate_task`,
`set_dependencies`, `create_blocked_task`), approvals (`request_approval`,
`decide_approval`, …), cases, workspace runtime, routines, company skills, secrets, admin,
escape hatch. Grants render exactly as 7A writes them (e.g. `rf:read_or_write`).
- **Mock-state kinds**: `operation_result`, `runtime_decision_record`, `active_task_context`.
- **Eval keys**: case `id` (e.g. `ix-confirmation-plan-01`), `group` (16 groups),
`fixtureProfile` (e.g. `hb-baseline`), `browserEvidenceRecipe` (e.g. `hb/hb-context-01`),
`expectedSemanticOperations`, `forbiddenOperations`.
- **Modes**: `live` (real Codex via real runnerd), `fake` (deterministic fake agent),
`replay` (recorded canonical events). A surface never shows an unlabeled mode.
Human-readable labels (UI copy) for the dispositions are fixed: `Agent tool — always`,
`Agent tool — granted`, `Control plane`. The machine term appears in the debug panel and in
tooltips, never invented synonyms ("system tool", "built-in", etc. are forbidden).
## 1. Identity and mode banner (always visible)
The page must answer, at all times and on every viewport, four questions: who is the agent,
what runs it, what control plane it talks to, and whether this session is live.
1. **Three identity chips**, rendered in the issue header and never hidden by scroll
(header is sticky):
- `Real Codex` — cyan chip, only when the active session mode is `live`. In `fake` mode the
chip reads `Fake agent`; in `replay` it reads `Replay`.
- `Real runnerd` — neutral chip with a live process dot (pulse while a runnerd session is
attached; static gray when detached). Shown in `live` mode only; `fake`/`replay` show
`In-process runner`.
- `Mock Paperclip` — amber chip, shown in **all** modes. Tooltip: "All issue records are
mock. No real Paperclip API is reachable."
2. **Mode is data, not styling**: the root element carries `data-session-mode="live|fake|replay"`
and the chips render from the server-reported session record. A session artifact with
`mode=fake` must be visually distinguishable from `live` in every screenshot (Revision 4
evidence eligibility).
3. **Mock identifier scheme**: mock issues use a reserved prefix that cannot be confused with
a real company (fixture default: `MCK-<n>`, e.g. `MCK-31`). The UI never renders a
`/PAP/...`-style link to a real control plane; mock entity links navigate inside the
explorer only.
4. **No credential surface**: no header, panel, tooltip, error, or diff may contain a token,
key, or `Authorization` value. Redacted fields render as `••• redacted` with the redaction
rule name from the authorization record.
## 2. Page information architecture
### 2.1 Desktop (reference viewport 1440×900)
```
┌──────────────────────────────────────────────────────────────┬───────────────┐
│ Issue header (sticky): │ │
│ MCK-31 · title · StatusBadge · PriorityIcon · assignee │ Debug panel │
│ [Fake agent|Real Codex] [Real runnerd] [Mock Paperclip] │ (collapsed │
│ run state · Scenario ▾ · Replay · Reset · Stop │ by default; │
├──────────────────────────────────────────────────────────────┤ resizable │
│ Thread column (centered, max-width 760px, single column): │ 320–640px) │
│ turn groups: user message → agent activity → responses │ │
│ comment / interaction / document / deliverable / │ │
│ disposition cards in chronological order │ │
│ system notices (wakes, reconciliation) as one-line rows │ │
├──────────────────────────────────────────────────────────────┤ │
│ Composer (pinned bottom of thread column) │ │
└──────────────────────────────────────────────────────────────┴───────────────┘
```
- The **thread is the primary readable surface**. Default panel state is **collapsed**; the
user's open/width choice persists per browser (`localStorage`), and deep links may force it
(§10). The page never leads with raw protocol events.
- Thread column measure: max-width 760px, centered in the remaining space; `text-sm` body
scale per the Paperclip type ramp.
- Debug panel: right side, resizable 320–640px with a keyboard-operable splitter
(`role="separator"`, `aria-valuenow`), collapse toggle in the header (`Evidence` button with
open/closed state).
- Below 1100px viewport width the panel switches from side-by-side to an overlay sheet from
the right (same content, same tab order), so the thread column never drops below ~600px.
### 2.2 Mobile (reference viewport 390×844)
- One column. Sticky condensed header: row 1 = `MCK-31` + StatusBadge + overflow menu `⋯`
(Scenario, Replay, Reset inside the menu; **Stop stays outside the menu** whenever a turn is
active). Row 2 = the three identity chips, wrapping to a second line if needed — chips never
cause horizontal page scroll.
- A **segmented control** with exactly two segments — `Thread` and `Evidence` — sits under the
header (pattern validated in the Phase 7I mobile review; a drawer was rejected there and
stays rejected). Thread is default. Badge on `Evidence` shows the current turn's
authorization-denial count when nonzero.
- Composer is fixed to the bottom of the `Thread` segment, above the keyboard inset
(`env(safe-area-inset-bottom)`).
- **No horizontal page scroll at 390px** (`document.scrollingElement.scrollWidth <=
clientWidth` is an automated acceptance check). `pre`/code/diff blocks wrap or scroll inside
their own container only.
- Touch targets ≥ 44×44 CSS px for every actionable element, including chip tooltips
(tap-to-toggle on touch), accordion headers, and interaction-card controls.
## 3. Thread item taxonomy
Every thread item renders from a **mock-core record or canonical event** — the browser holds
no state authority, computes no claim/policy/parity result, and never mutates mock state
outside an interaction response (§5). Items in chronological order, grouped by turn:
| # | Item | Source | Anatomy |
|---|------|--------|---------|
| T1 | **User message** | thread record | Right-aligned bubble style is **not** used; Paperclip comment card with author "You (board user)", timestamp, markdown body. |
| T2 | **Agent response** | model output items | Comment card, author = agent identity chip (`Fake agent` / `Real Codex`), streaming state per §6. Model prose only — never confused with durable records (see T3). |
| T3 | **Durable progress comment** | `report_progress` / `answer_status_question` `operation_result` | Distinct comment card with a `Recorded to mock thread` marker (filled corner tag + tooltip naming the semantic operation). This is the visual boundary between ephemeral model text (T2) and durable mock records. |
| T4 | **Tool activity strip** | semantic call + typed result | One line per call inside the turn group: status glyph (`✓ ok`, `✕ denied`, `⏳ running`), operation id (`write_document`), one-line human summary, `›` expander. Expanded: request args (redacted per rules), typed result, and a `View in Evidence` link that opens the debug panel pre-filtered to that call. Strips are collapsed by default; a turn shows at most 3 strips + `N more…` expander (progressive disclosure). |
| T5 | **Interaction card** | `request_human_input` record | §5. Rendered at its chronological position. |
| T6 | **Document card** | `write_document` result | Document key + title, revision chain (`r3 → r4`), author, `View diff` (opens Evidence → State), and stale marker when a later revision exists. |
| T7 | **Deliverable card** | `register_deliverable` result | File/ref name, kind (attachment bytes / external ref / workspace file), size, registered-by, download affordance for attachment-backed deliverables. |
| T8 | **Dependency / delegation card** | optional-op results (`create_task`, `set_dependencies`, …) | Created mock child issues with identifiers and blocker edges (`MCK-32 blocks MCK-31`). |
| T9 | **Disposition card** | `finish_task` / `block_task` / `request_review` result | Terminal banner card: new status via StatusBadge, explanation body, named blocker owner where applicable. After a terminal disposition the composer enters `disabled` (§6). |
| T10 | **Denial notice** | typed policy denial (`operation_result` with deny) | Inline red-bordered strip variant of T4: `✕ create_task — denied: missing grant su:read_or_write`. Shows deny reason **from the authorization record verbatim**; never leaks protected state or credentials. |
| T11 | **System notice** | `runtime_decision_record` (wake, checkout, reconciliation, budget stop, session events) | One-line, muted, icon + text (e.g. `⚙ Wake: issue_blockers_resolved → turn 3 started`). Progressive disclosure into Evidence → Control plane. Never a card; system notices must read quieter than work content. |
Turn grouping: each turn renders a hairline group header `Turn N · <mode> · <n> tool calls ·
<hh:mm:ss>` binding T2/T4/T5-T10 items produced within it. Thread auto-follows the newest item
only while the user is at the bottom; a `Jump to latest` pill appears when scrolled up
(≥ 300px) or when new items arrive off-screen.
## 4. Composer contract
States (mutually exclusive; `data-composer-state` attribute is the test/screenshot hook):
| State | Trigger | Visual | Controls |
|-------|---------|--------|----------|
| `ready` | session attached, no active turn, issue non-terminal | normal input, `Send` primary | Send (Cmd/Ctrl+Enter), attach disabled in Phase 7 |
| `sending` | message posted, turn not yet streaming | input cleared, inline spinner on Send | Send disabled |
| `streaming` | active turn | input stays **editable** (steer), primary button becomes `Stop` (destructive-outline), helper text `Codex is working — send to steer, or stop the turn.` | Send = steer (queued as next user input), Stop |
| `waiting` | pending interaction card requires the user | input disabled, helper `Answer the pending request above to continue.` with an anchor link that scrolls to and focuses the pending card | none |
| `reconnecting` | transport lost | input disabled, helper `Reconnecting… your session is preserved.` | Retry now |
| `disabled` | terminal disposition, replay mode, or budget stop | input disabled with reason line (`Issue is done`, `Replay is read-only`, `Budget limit reached`) | New scenario / Reset |
Draft text survives refresh (localStorage per session id). Stop never discards the transcript;
a stopped turn's partial output stays in the thread with a `Stopped by user` marker on the
turn header.
## 5. Interaction cards (native lifecycle)
All five `request_human_input` kinds render as native cards using the typed payload's prompt,
options, labels, and validation — never as markdown asking the user to type an answer.
Kinds and their answer controls:
- `questions` (ask_user_questions): typed form — per-question control (radio/select/short
text), one submit.
- `confirmation` (request_confirmation): target summary + Accept / Reject buttons; reject
reason textarea when the payload requires it. **Revision-bound**: the card shows the target
(`plan · r4`) and links the exact revision.
- `checkbox` (request_checkbox_confirmation): checkbox list with min/max enforcement,
default-selected ids, Accept label / Reject label from payload.
- `suggest_tasks`: proposed-task list (title + description); board accepts a subset; accepted
tasks appear as T8 cards afterwards.
- `item_verdicts` (request_item_verdicts): per-item Approve / Reject / Defer segmented
buttons; reason required per `requireReasonOn`; supports partial submit (submitted items
lock, remaining stay editable, card stays `pending`).
**Response authority path (normative):** the card's submit posts the typed response to the
package server; the **mock control plane stores the response before the runner receives it**,
and only then does the same Codex session resume with the typed result. The card's UI state
moves `pending → submitting → resolved` only on server acknowledgment (no optimistic
resolution). This is the *only* browser-initiated mock mutation; there is no other write path
from the page.
**State matrix (all states are required and visually distinct):**
| State | Store shape | Card treatment |
|-------|------------|----------------|
| `pending` | status `pending` | Accent left border (violet), controls enabled, `Waiting for you` chip, focus lands on first control when the card is the reason the composer is `waiting`. |
| `submitting` | in flight | Controls disabled, inline spinner. |
| `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 (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. |
| `issue_closed` | status `cancelled`/`expired`, `result.outcome=issue_closed` | Gray chip `Issue closed`. |
Resolved/expired cards are durable history: they stay in the thread at their chronological
position, are keyboard-reachable, and expose `View request evidence` linking the debug panel
to the related request, policy decision, mock mutation, wake, and resume events (the plan's
required debug linkage). Pending cards survive refresh and reconnect (§6) — rehydrated from
mock state, not from browser memory.
## 6. Session lifecycle behaviors
- **Refresh (F5)**: full restore from server state — same session, same turn ids, transcript,
pending interaction cards, composer state, and mode chips. The session **never restarts
because the browser reconnected**. Scroll restores to latest; a `Restored session` system
notice (T11) is *not* emitted (silent restore) — the evidence panel's session record is the
proof.
- **Reconnect**: on transport drop the composer enters `reconnecting`, a slim amber banner
pins under the header (`Connection lost — retrying (attempt n)`), and streaming indicators
freeze with a `paused` glyph. On reconnect, missed canonical events replay by ordinal (no
duplicates, no gaps) and the banner resolves to a 3s `Reconnected` confirmation.
`prefers-reduced-motion` replaces the banner slide with opacity.
- **Stop**: header Stop and composer Stop are the same action — bounded cancel of the active
turn. Post-state: partial output retained + `Stopped by user` turn marker; session and
pending interactions unaffected; composer returns to `ready`.
- **Reset**: destructive — always behind a confirm dialog (`Reset scenario? This clears the
mock state and starts a clean session. The transcript will be lost.`; confirm button
`Reset scenario`, destructive style; cancel is default focus). Reset re-seeds the fixture,
rotates/clears session authority (Revision 4), and lands on a clean thread with a fresh
`Turn 0` seeded context. Reset affects only the current browser session's scenario instance.
- **Replay**: mode `replay` re-renders a recorded run from canonical events. Composer
`disabled` (`Replay is read-only`), identity chip row shows `Replay` + `Mock Paperclip`,
and a top progress strip allows step/next-turn/play-all with a deterministic `?at=<ordinal>`
deep-link parameter. Replay of a `fake` recording must still be labeled as fake-derived
(chip `Replay · fake source`) so replay evidence can never satisfy a live criterion.
- **Stop/Reset/Replay/Scenario controls** live in the header on desktop; on mobile
Replay/Reset/Scenario collapse into `⋯`, Stop stays exposed while a turn is active (§2.2).
## 7. Side debug panel (Evidence)
Named **Evidence** in UI copy. Content scope: a **turn selector** at the top (`Turn N ▾`,
default = latest; `All turns` option for the state and parity sections). Below it, eight
accordion sections in this order (accordion, not tabs — >7 categories, and multiple sections
must be open simultaneously for review):
1. **Tools exposed** — the turn's visible tool list grouped `Agent tool — always`, then
`Agent tool — granted` (each with its grant, e.g. `rf:read_or_write`), then a separated
muted list `Control plane (not exposed to the agent)` naming `control_plane_owned`
operations relevant to the fixture. Negative evidence is first-class: the control-plane
list exists precisely to show what the model *cannot* call.
2. **Calls & results** — chronological semantic calls: raw Codex request → dispatched command
→ typed result/denial, with operation id, version, and redaction annotations.
3. **Authorization** — one record per decision: operation, claims considered, allow/deny +
reason, redactions applied, resulting state change ref. Deny records use the same red
accent as T10.
4. **Control plane** — `runtime_decision_record` stream: checkout/lock, wake scheduling,
budget, idempotency/retry, reconciliation, session lifecycle.
5. **Runner & events** — canonical PRP events and runnerd/Codex process diagnostics (session
id, thread id, process state, cleanup evidence).
6. **State diff** — before/after per entity class (tasks, comments, documents, interactions,
approvals, artifacts, blockers, workspace, budget, run). Per-turn by default; `All turns`
shows fixture-seed → current. Rendered from immutable snapshots; the browser never
computes a diff from its own bookkeeping.
7. **Traceability** — the fixture's 7A anchors: capability rows (id + `sourceAnchor`), eval
case id/group/`browserEvidenceRecipe`, `expectedSemanticOperations`,
`forbiddenOperations`, `requiredCapabilityGrants`.
8. **Parity** — assertion list with verdict chips (`pass` green / `fail` red /
`intentional gap` gray + note), summarized as `n/m` in the section header.
Cross-linking contract: every T4 strip, T10 denial, and interaction card deep-links into the
matching Evidence record (`View in Evidence`), and every Evidence record links back to its
thread anchor. Deep-link target = section + record id, e.g. `?panel=authorization&rec=<id>`.
Mobile: the same eight sections render inside the `Evidence` segment, full-width accordions,
turn selector pinned under the segmented control.
## 8. Visual language
Follow the Paperclip design language without importing the product `ui/` package:
- Dark theme default, OKLCH neutral grays; semantic tokens only (background/card/muted/
accent/destructive/border/ring equivalents defined package-locally). No raw hex in
components.
- Type ramp: page title `text-xl font-bold`; card titles `text-sm font-medium`; body
`text-sm`; metadata `text-xs text-muted-foreground`; identifiers and operation ids
`font-mono text-xs`.
- Status/priority renders with StatusBadge/StatusIcon-equivalent components using the
product's status hue table (todo blue, in_progress indigo, in_review violet, done green,
blocked red, backlog/cancelled gray).
- Radii ≤ `rounded-xl`; shadows ≤ `shadow-sm`; density = product issue page, not a marketing
layout.
- Interaction cards use the product interaction-card anatomy (title row + prompt + controls +
state chip) so the mock thread reads as a Paperclip issue thread (Jakob's Law is the point
of this phase's demo).
## 9. Accessibility acceptance (blocking)
7G is not acceptable until all of these pass; 7K re-verifies them clean-room:
1. **Keyboard tour** (documented, testable): Tab order = header controls → thread (each card
is a focusable group; Enter expands) → composer → Evidence toggle → panel. The splitter is
arrow-key resizable. Pending interaction controls are reachable without pointer; Escape
closes the mobile `⋯` menu and the desktop overlay sheet.
2. **Focus management**: opening Evidence moves focus to its heading; resolving a card moves
focus to the card's state chip; `waiting` composer's anchor link moves focus to the pending
card's first control. Focus ring = 3px ring token, never suppressed.
3. **Live regions**: streaming agent text in `aria-live="polite"` chunk announcements (throttled
≥ 2s); turn completion, denial notices, and interaction resolution announce via a single
polite status region; reconnect banner is `role="status"`, Stop confirmation `role="alert"`.
4. **Structure**: one `h1` (issue title), landmarks `header/main/complementary` (Evidence),
`form` for composer; interaction cards are `section`s labeled by their prompt; tool strips
are disclosure buttons with `aria-expanded`.
5. **Color independence**: every state chip pairs color with a glyph + text (`✓ Accepted`,
`✕ Denied`, `⏳`); parity verdicts likewise. Contrast ≥ 4.5:1 for text, ≥ 3:1 for UI
glyphs, verified in dark theme.
6. **Reduced motion**: `prefers-reduced-motion` disables pulse dots, banner slides, streaming
shimmer, and smooth scrolling (instant jumps).
7. **Automated gate**: axe (or equivalent) run against every screenshot route in §10 with
zero serious/critical violations, executed in CI alongside screenshot capture.
## 10. Deterministic screenshot contract
### 10.1 Route scheme
- Base route: `#/issue/<fixtureProfile>` (e.g. `#/issue/hb-baseline`). Scenario/fixture ids
come from 7A `fixtureProfile`; per-case evidence uses the 7A `browserEvidenceRecipe` path
(`<group>/<case-id>`) as the canonical evidence id.
- Screenshot state param: `?shot=<slug>` seeds the named deterministic state below in `fake`
mode with: fixed clock (all timestamps render from fixture time), animations/caret/pulse
disabled, network idle, fonts loaded.
- Panel/deep-link params: `?panel=<section>[&rec=<id>]`, `?at=<ordinal>` (replay),
`?seg=thread|evidence` (mobile segment).
- Settle signal: the root element sets `data-thread-state="settled"` when hydration, fixture
load, and auto-scroll are complete. Capture tooling waits for it — never for timeouts.
### 10.2 Required matrix (12 slugs × 2 viewports = 24 PNGs)
Viewports: desktop `1440×900`, mobile `390×844`. Output path:
`knowledge/evidence/phase-07/ui/<slug>--<desktop|mobile>.png` (package-local). Every capture
also asserts `scrollWidth <= clientWidth` on the scrolling element at 390×844.
| Slug | Seeded state | Must be visible |
|------|--------------|-----------------|
| `thread-baseline` | settled 3-turn fake run on `hb-baseline` | header w/ 3 identity chips + mode; T1/T2/T3/T4 items; turn headers; composer `ready` |
| `turn-streaming` | mid-turn stream | streaming indicator, composer `streaming` w/ Stop, editable steer input |
| `interaction-question-pending` | `ix-questions-01` pending card | typed question form, `Waiting for you` chip, composer `waiting` w/ anchor helper |
| `interaction-confirmation-pending` | `ix-confirmation-plan-01` pending | revision-bound target (`plan · r4`) on card, Accept/Reject, reject-reason affordance |
| `interaction-resolved-mixed` | history incl. accepted + rejected + `stale_target` + `superseded_by_comment` | four visually distinct resolved/expired treatments per §5 |
| `denial-optional-tool` | denied `create_task` (missing `su:read_or_write`) | T10 denial strip w/ verbatim deny reason; Evidence badge increment |
| `document-revision` | `dp-plan-doc-01` after `write_document` | T6 card w/ revision chain `r3 → r4` + View diff |
| `deliverable-registered` | `ar-upload-before-done-01` | T7 deliverable card w/ kind + registered-by |
| `disposition-terminal` | `st-done-comment-01` finished | T9 terminal card, StatusBadge `done`, composer `disabled` w/ reason |
| `debug-panel-open` | baseline + `?panel=authorization` | desktop: panel open at 384px w/ 8 sections, authorization records; mobile: `Evidence` segment active |
| `reconnect-banner` | forced transport drop | amber reconnect banner, composer `reconnecting`, frozen stream glyph |
| `replay-mode` | replay of recorded fake run `?at=12` | `Replay · fake source` chip, read-only composer, progress strip |
Determinism rule: two captures of the same slug/viewport from a clean checkout must be
pixel-identical (the Phase 7I byte-identical bar). Anything time-, random-, or
locale-dependent renders from fixture data.
### 10.3 Evidence naming
Per-eval-case evidence (7F/7K scope) reuses `browserEvidenceRecipe` verbatim:
`knowledge/evidence/phase-07/cases/<group>/<case-id>--<viewport>.png`. The §10.2 matrix is
the UI acceptance set; case evidence is additive and follows the same settle/determinism
rules.
## 11. Authority and safety rules (UI-side restatement)
- The browser renders mock-core records, snapshots, and canonical events. It computes no
claim, policy, diff, or parity result client-side. UI-side state math is a defect.
- The only browser-initiated mock mutation is an interaction response (§5). Composer messages
go to the runner session, not to mock state.
- No provider, runner, or control-plane credential ever reaches the browser; redactions render
by rule name. Real Paperclip URLs/API paths never appear.
- Policy and state authority live in the package server + mock `ControlPlanePort`; refresh
and reconnect re-derive everything from them.
## 12. 7G handoff checklist
Implementation acceptance (UXDesigner review) requires:
1. All §3 item types and all §5 interaction states implemented and reachable via fixtures.
2. All §4 composer states with `data-composer-state` hooks.
3. §6 behaviors demonstrated: refresh restore, reconnect replay, stop, reset confirm, replay
read-only.
4. §7 Evidence panel with all eight sections, turn selector, and bidirectional deep links.
5. §10 matrix: 24 deterministic PNGs recorded at the named routes, byte-stable across two
clean runs, plus the 390px no-horizontal-scroll assertion per capture.
6. §9 accessibility gate green (axe + documented keyboard tour).
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.
### Revision 3 — `interaction-resolved-mixed` capture framing (2026-08-10, written by the contract owner during the 7G gate)
Revision 1's §10.2 row required four resolved/expired card treatments visible
in the `interaction-resolved-mixed` frame. Two of this contract's own rules
make that impossible in one capture: §10.1's settle signal ends with
auto-scroll to the latest thread item, and the §5/§8 card anatomy makes four
resolved cards ~1100px tall — taller than either viewport. The seeded history
(answered + accepted + rejected + `stale_target` + `superseded_by_comment`)
cannot fit one auto-scrolled frame.
The row now reads: the seeded state must contain **all five** treatments
(answered, accepted, rejected, `stale_target`, `superseded_by_comment`), the
frame shows the latest-scrolled portion with the expired-family chips
(`Stale …`, `Superseded …`) fully visible, and the visual distinctness of all
five treatments is asserted by the browser suite plus a scrolled review pass
at the gate. Splitting the slug into two frames was rejected because the
24-PNG matrix count is referenced by the 7F/7K evidence and the §9.7 axe gate.
+3
View File
@@ -32,10 +32,13 @@ Each tutorial starts with an explanation of what the phase is and what the phase
- [Phase 7 capability contract (generated)](phase-07-capability-contract.md)
- [Phase 7 capability disposition](phase-07-capability-disposition.md)
- [Phase 7 mock ControlPlanePort](phase-07-mock-control-plane-port.md)
- [Phase 7 semantic catalog and authorization](phase-07-semantic-catalog.md)
- [Phase 7 semantic tool catalog](phase-07-semantic-tools.md)
- [Phase 7 authorization and exposure](phase-07-authorization-and-exposure.md)
- [Phase 7 eval-derived conformance](phase-07-eval-conformance.md)
- [Phase 7 browser scenario explorer](phase-07-scenario-explorer.md)
- [Phase 7 live runnerd/Codex loop](phase-07-live-runnerd-codex.md)
- [Phase 7 Paperclip-style issue-thread UI](phase-07-issue-thread-ui.md)
- [Phase 7I interactive scenario chat](phase-07i-scenario-chat.md)
- [Phase 7 future binding boundary (Phase 8 / ACPX)](phase-07-future-binding-boundary.md)
- [Phase 7 verification commands](phase-07-verification-commands.md)
@@ -0,0 +1,152 @@
# 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 that
Escape dismisses; below 768px the page switches to a `Thread` / `Evidence`
segmented control, with Stop kept outside the `⋯` menu while a turn is active.
Closing the panel by either route hands focus back to the visible control that
owns it.
- **Replay strip** — in `mode=replay` a progress strip pins under the header
with `Step back`, `Next turn`, and `Play all`. `?at=<ordinal>` is the single
source of truth for the parked ordinal, so the three controls and the deep
link all move the same value; `Play all` advances one ordinal every 800 ms
and parks itself at the end of the recording.
## 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.
The committed PNGs are pinned to the Chromium build listed in the Phase 7G
verification evidence, so `check:phase7:ui` needs that same browser. Point
`PAPERCLIP_RUNNER_CHROMIUM_PATH` at it before comparing; a different build makes
every shot mismatch at once, which reads like a regression but is not one. The
check prints the Chromium version it recorded with whenever it reports drift.
## 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`. 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.
Focus management (§9.2) is covered by named regressions in the browser suite:
- Opening Evidence moves focus to its heading; closing it — with the `Close`
button or with Escape on the overlay sheet — returns focus to the toggle.
- Resolving an interaction card moves focus to the card's state chip. The
controls the user just operated unmount on resolve, so without this the
keyboard caret drops to `body` at the moment the card changes.
- The `waiting` composer's anchor moves focus to the pending card's first
control.
## 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,130 @@
---
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 test:browser:phase7` — **50/50 passed**.
The script now runs `build:issue-thread` before Playwright. It did not, and the
suite's `vite preview` server serves the built bundle, so a run could pass
against a stale `dist-issue-thread/` that predated the source under test. That
is how the four round-2 regressions below first came up red against fixed code.
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.
Round-2 additions from the PAP-16952 gate review:
* **Escape closes the desktop overlay sheet** at 1000×800 and returns focus to
the `Evidence` toggle (§9.1, was blocking).
* **Resolving a card moves focus to its state chip** (§9.2, was blocking).
* **The replay strip steps, advances, and plays through** to 18/18, then parks
itself with `Next turn` disabled (§6 `play-all`).
* **The deliverable strip and card report one size in one unit** — both now
format through `phase7FormatBytes`.
Accessibility gate: axe (WCAG 2.1 A/AA) on all **12 slugs × 2 viewports** —
zero serious or critical violations, re-run after the round-2 fixes.
# 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.
Five frames were re-recorded for the round-2 fixes and re-verified byte-stable:
`replay-mode--{desktop,mobile}` (the `Play all` control, and the progress bar
wrapping to its own row at 390px so it is not squeezed to a stub),
`debug-panel-open--{desktop,mobile}` (`?panel=` without `rec=` now scrolls the
opened section clear of the sticky panel head, so the §10.2 "authorization
records" requirement is met in frame rather than below the fold), and
`deliverable-registered--desktop` (`18.4 kB` → `18.0 kB`, matching its card).
# 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.
@@ -17,3 +17,4 @@
* [Phase 7I interactive scenario chat verification](2026-08-09-phase-07i-chat-verification.md) - Turn model, per-turn evidence, denied and successful mock interactions from chat, reset isolation, route determinism, responsive and keyboard behaviour, and the ten-image screenshot acceptance set.
* [Phase 7 documentation, tutorial, and evidence manifest](2026-08-09-phase-07-verification.md) - Command→result matrix for every focused Phase 7 verification, artifact links, and the security, UX, and QA gate issues.
* [Phase 7 evidence](phase-07/) - Screenshot acceptance captures and the eval conformance parity report.
* [Phase 7G issue-thread UI verification](2026-08-10-phase-07g-issue-thread-ui-verification.md) - View-model tests, browser coverage, the axe gate on 12 slugs × 2 viewports, the byte-stable 24-PNG matrix, and the live runnerd/Codex smoke.
@@ -34,3 +34,8 @@ recordings of the same route agree.
| 10 | `mh-multihop` | Multi-hop assertions passing; forbidden operations section. |
| 11 | `rs-restraint` | "No further operations" note and a restraint verdict. |
| 12 | `rs-secret-redaction` | Redaction chips naming their rule; no credential anywhere in the page. |
## Issue-thread UI 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: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 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: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 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?
@@ -17,3 +17,4 @@
* [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 7 reference docs, tutorial, evidence, and OKF](2026-08-09-phase-07.md) - Documentation decisions, observed verification facts across every focused command, the parity-report OKF gap, and the deferred Phase 8 binding boundary.
* [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.
@@ -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,207 @@
#!/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.issue-thread.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");
};
}
// Set by `record` so a drift report can name the Chromium build that produced it.
let browserVersion = "unknown";
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 }),
});
browserVersion = browser.version();
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:",
...drift,
"",
`Recorded with Chromium ${browserVersion}.`,
"Committed PNGs are pinned to one Chromium build. A wholesale mismatch",
"usually means a different browser, not a UI regression: set",
"PAPERCLIP_RUNNER_CHROMIUM_PATH to the Chromium that recorded them.",
"",
].join("\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.issue-thread.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();
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,461 @@
/**
* 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];
}
/**
* The one byte formatter for the whole surface. Deliverable sizes surface in
* two places — the tool-activity strip summary and the deliverable card — and
* they must agree, so both sides (fixtures, live projection, and the browser
* components) format through here rather than rolling their own division.
*/
export function phase7FormatBytes(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`;
}
/**
* 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;
}
@@ -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",
},
});