From c6d99430eefa2752b009b8f76da00f92e9bd08b4 Mon Sep 17 00:00:00 2001 From: Dotta Date: Mon, 10 Aug 2026 12:22:23 +0000 Subject: [PATCH] feat(runner): consolidate accepted Phase 7 issue thread UI Co-Authored-By: Paperclip --- .../devtools/issue-thread/index.html | 13 + .../issue-thread/issue-thread.spec.ts | 404 ++++++ .../issue-thread/playwright.config.ts | 34 + .../devtools/issue-thread/src/App.tsx | 670 +++++++++ .../devtools/issue-thread/src/Composer.tsx | 150 +++ .../issue-thread/src/EvidencePanel.tsx | 416 ++++++ .../issue-thread/src/InteractionCard.tsx | 528 ++++++++ .../devtools/issue-thread/src/IssueHeader.tsx | 235 ++++ .../devtools/issue-thread/src/ThreadItems.tsx | 341 +++++ .../devtools/issue-thread/src/fake-store.ts | 82 ++ .../issue-thread/src/issue-thread.css | 1199 +++++++++++++++++ .../devtools/issue-thread/src/live-client.ts | 79 ++ .../devtools/issue-thread/src/main.tsx | 24 + .../devtools/issue-thread/src/primitives.tsx | 91 ++ .../devtools/issue-thread/src/route.ts | 78 ++ .../phase-7-issue-thread-ux-contract.md | 429 ++++++ packages/paperclip-runner/docs/index.md | 3 + .../docs/phase-07-issue-thread-ui.md | 152 +++ ...-phase-07g-issue-thread-ui-verification.md | 130 ++ .../knowledge/evidence/index.md | 1 + .../knowledge/evidence/phase-07/index.md | 5 + .../evidence/phase-07/ui-live/index.md | 11 + .../ui-live/live-codex-evidence--desktop.png | Bin 0 -> 89739 bytes .../ui-live/live-codex-turn--desktop.png | Bin 0 -> 63665 bytes .../ui-live/live-codex-turn--mobile.png | Bin 0 -> 48524 bytes .../phase-07/ui/debug-panel-open--desktop.png | Bin 0 -> 111507 bytes .../phase-07/ui/debug-panel-open--mobile.png | Bin 0 -> 42466 bytes .../ui/deliverable-registered--desktop.png | Bin 0 -> 74490 bytes .../ui/deliverable-registered--mobile.png | Bin 0 -> 48465 bytes .../ui/denial-optional-tool--desktop.png | Bin 0 -> 73954 bytes .../ui/denial-optional-tool--mobile.png | Bin 0 -> 47562 bytes .../ui/disposition-terminal--desktop.png | Bin 0 -> 71820 bytes .../ui/disposition-terminal--mobile.png | Bin 0 -> 46892 bytes .../ui/document-revision--desktop.png | Bin 0 -> 70302 bytes .../phase-07/ui/document-revision--mobile.png | Bin 0 -> 46332 bytes .../knowledge/evidence/phase-07/ui/index.md | 29 + ...eraction-confirmation-pending--desktop.png | Bin 0 -> 67558 bytes ...teraction-confirmation-pending--mobile.png | Bin 0 -> 39257 bytes .../interaction-question-pending--desktop.png | Bin 0 -> 65395 bytes .../interaction-question-pending--mobile.png | Bin 0 -> 39117 bytes .../interaction-resolved-mixed--desktop.png | Bin 0 -> 64311 bytes .../ui/interaction-resolved-mixed--mobile.png | Bin 0 -> 38694 bytes .../phase-07/ui/reconnect-banner--desktop.png | Bin 0 -> 68732 bytes .../phase-07/ui/reconnect-banner--mobile.png | Bin 0 -> 45873 bytes .../phase-07/ui/replay-mode--desktop.png | Bin 0 -> 70343 bytes .../phase-07/ui/replay-mode--mobile.png | Bin 0 -> 46230 bytes .../phase-07/ui/thread-baseline--desktop.png | Bin 0 -> 74370 bytes .../phase-07/ui/thread-baseline--mobile.png | Bin 0 -> 47314 bytes .../phase-07/ui/turn-streaming--desktop.png | Bin 0 -> 74333 bytes .../phase-07/ui/turn-streaming--mobile.png | Bin 0 -> 50823 bytes .../2026-08-10-phase-07g-issue-thread-ui.md | 107 ++ .../knowledge/journal/index.md | 1 + .../scripts/phase7-issue-thread-server.mjs | 237 ++++ .../scripts/phase7-issue-thread-smoke.mjs | 123 ++ .../scripts/record-phase7-ui-evidence.mjs | 207 +++ .../record-phase7-ui-live-evidence.mjs | 132 ++ .../src/issue-thread/fixtures.ts | 1037 ++++++++++++++ .../src/issue-thread/index.ts | 3 + .../src/issue-thread/issue-thread.test.ts | 397 ++++++ .../src/issue-thread/live-projection.ts | 729 ++++++++++ .../src/issue-thread/types.ts | 461 +++++++ .../vite.issue-thread.config.ts | 22 + 62 files changed, 8560 insertions(+) create mode 100644 packages/paperclip-runner/devtools/issue-thread/index.html create mode 100644 packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts create mode 100644 packages/paperclip-runner/devtools/issue-thread/playwright.config.ts create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/App.tsx create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/Composer.tsx create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/EvidencePanel.tsx create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/InteractionCard.tsx create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/IssueHeader.tsx create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/ThreadItems.tsx create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/fake-store.ts create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/issue-thread.css create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/live-client.ts create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/main.tsx create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/primitives.tsx create mode 100644 packages/paperclip-runner/devtools/issue-thread/src/route.ts create mode 100644 packages/paperclip-runner/docs/design/phase-7-issue-thread-ux-contract.md create mode 100644 packages/paperclip-runner/docs/phase-07-issue-thread-ui.md create mode 100644 packages/paperclip-runner/knowledge/evidence/2026-08-10-phase-07g-issue-thread-ui-verification.md create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui-live/index.md create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui-live/live-codex-evidence--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui-live/live-codex-turn--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui-live/live-codex-turn--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/debug-panel-open--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/debug-panel-open--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/deliverable-registered--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/deliverable-registered--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/denial-optional-tool--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/denial-optional-tool--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/disposition-terminal--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/disposition-terminal--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/document-revision--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/document-revision--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/index.md create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/interaction-confirmation-pending--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/interaction-confirmation-pending--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/interaction-question-pending--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/interaction-question-pending--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/interaction-resolved-mixed--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/interaction-resolved-mixed--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/reconnect-banner--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/reconnect-banner--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/replay-mode--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/replay-mode--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/thread-baseline--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/thread-baseline--mobile.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/turn-streaming--desktop.png create mode 100644 packages/paperclip-runner/knowledge/evidence/phase-07/ui/turn-streaming--mobile.png create mode 100644 packages/paperclip-runner/knowledge/journal/2026-08-10-phase-07g-issue-thread-ui.md create mode 100644 packages/paperclip-runner/scripts/phase7-issue-thread-server.mjs create mode 100644 packages/paperclip-runner/scripts/phase7-issue-thread-smoke.mjs create mode 100644 packages/paperclip-runner/scripts/record-phase7-ui-evidence.mjs create mode 100644 packages/paperclip-runner/scripts/record-phase7-ui-live-evidence.mjs create mode 100644 packages/paperclip-runner/src/issue-thread/fixtures.ts create mode 100644 packages/paperclip-runner/src/issue-thread/index.ts create mode 100644 packages/paperclip-runner/src/issue-thread/issue-thread.test.ts create mode 100644 packages/paperclip-runner/src/issue-thread/live-projection.ts create mode 100644 packages/paperclip-runner/src/issue-thread/types.ts create mode 100644 packages/paperclip-runner/vite.issue-thread.config.ts diff --git a/packages/paperclip-runner/devtools/issue-thread/index.html b/packages/paperclip-runner/devtools/issue-thread/index.html new file mode 100644 index 00000000000..d59893e1f19 --- /dev/null +++ b/packages/paperclip-runner/devtools/issue-thread/index.html @@ -0,0 +1,13 @@ + + + + + + + Mock Paperclip · Issue thread + + +
+ + + diff --git a/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts new file mode 100644 index 00000000000..a828a68c891 --- /dev/null +++ b/packages/paperclip-runner/devtools/issue-thread/issue-thread.spec.ts @@ -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 { + 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 = {}) { + 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 { + 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([]); + }); + } +}); diff --git a/packages/paperclip-runner/devtools/issue-thread/playwright.config.ts b/packages/paperclip-runner/devtools/issue-thread/playwright.config.ts new file mode 100644 index 00000000000..39b26f659da --- /dev/null +++ b/packages/paperclip-runner/devtools/issue-thread/playwright.config.ts @@ -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, + }, +}); diff --git a/packages/paperclip-runner/devtools/issue-thread/src/App.tsx b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx new file mode 100644 index 00000000000..d86f6f63ef7 --- /dev/null +++ b/packages/paperclip-runner/devtools/issue-thread/src/App.tsx @@ -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(".pit-panel"); + const head = panel?.querySelector(".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(null); + const [error, setError] = useState(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(["tools"]); + const [selectedTurnId, setSelectedTurnId] = useState("all"); + const [highlightedRecordId, setHighlightedRecordId] = useState(route.record); + const [focusInteractionId, setFocusInteractionId] = useState(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(null); + const cancelResetRef = useRef(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((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(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=` 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 ( +
+

+ {error} +

+
+ ); + } + + if (snapshot === null) { + return
; + } + + const showThread = layout !== "segment" || segment === "thread"; + const showPanel = layout === "segment" ? segment === "evidence" : panelOpen; + + return ( +
+ { + 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" ? ( +

+ + Connection lost — retrying (attempt {snapshot.connection.attempt}) +

+ ) : reconnected ? ( +

+ + Reconnected +

+ ) : null} + + {replay !== null ? ( +
+ + Replay {replay.ordinal}/{replay.total} + +
+
+
+ + + +
+ ) : null} + +
+
+
+
{ + const element = event.currentTarget; + const distance = + element.scrollHeight - element.scrollTop - element.clientHeight; + setShowJump(distance > 300); + }} + > +
+ {snapshot.turns.map((turn) => ( + + ))} +
+
+ {showJump ? ( + + ) : null} +
+ + setConfirmReset(true)} + onFocusPending={(interactionId) => { + setFocusInteractionId(interactionId); + document + .getElementById(`interaction-${interactionId}`) + ?.scrollIntoView({ block: "center" }); + }} + /> +
+ + {showPanel && layout === "side" ? ( +
{ + 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 ? ( + + setOpenSections((current) => + current.includes(section) + ? current.filter((entry) => entry !== section) + : [...current, section], + ) + } + onClose={closeEvidence} + onJumpToThread={jumpToThread} + /> + ) : null} +
+ + {confirmReset ? ( +
+
+

+ Reset scenario? +

+

+ This clears the mock state and starts a clean session. The transcript will be lost. +

+
+ + +
+
+
+ ) : null} + +

+ {announcement} +

+
+ ); +} diff --git a/packages/paperclip-runner/devtools/issue-thread/src/Composer.tsx b/packages/paperclip-runner/devtools/issue-thread/src/Composer.tsx new file mode 100644 index 00000000000..6a695930230 --- /dev/null +++ b/packages/paperclip-runner/devtools/issue-thread/src/Composer.tsx @@ -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(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 ( +
+
+ {model.helper !== null ? ( +

+ {model.helper} + {model.state === "waiting" && model.pendingInteractionId !== null ? ( + <> + {" "} + + + ) : null} +

+ ) : null} + + {model.reason !== null ? ( +

+ {model.reason} +

+ ) : null} + +
+ +