feat(runner): consolidate accepted Phase 7 issue thread UI
Co-Authored-By: Paperclip <noreply@paperclip.ing>
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 },
|
||||
};
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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`
|
||||
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 72 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
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`
|
||||
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 63 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
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();
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||