diff --git a/packages/paperclip-runner/docs/design/phase-7i-mobile-chat-ux.md b/packages/paperclip-runner/docs/design/phase-7i-mobile-chat-ux.md index 828bb1f87e5..ee15e74379a 100644 --- a/packages/paperclip-runner/docs/design/phase-7i-mobile-chat-ux.md +++ b/packages/paperclip-runner/docs/design/phase-7i-mobile-chat-ux.md @@ -1,6 +1,8 @@ # Phase 7I Interaction Map — Mobile Chat with Visible Mock Activity -Status: **approved UX contract for 7I implementation** (PAP-16914, 2026-08-09). +Status: **approved UX contract**, implemented in PAP-16916 (2026-08-09). +Deviations taken during implementation are recorded in §11a; the §11 matrix is +recorded by `pnpm run record:phase7i`. Owner: UXDesigner. Implementer: 7I implementation (PAP-16916), consuming the existing 7C mock adapter, 7D semantic catalog, and 7F explorer components. Companion records: [Phase 7 interaction map](phase-7-scenario-explorer-ux.md) @@ -321,9 +323,9 @@ the correct active segment and no horizontal scrollbar. | --- | --- | --- | --- | | 1 | `chat-home` | `#/chat` | Picker + chat intro; no dead panels. | | 2 | `chat-session` | `#/chat/ix-checkbox-01?replay=fake` | Turn-0 control-plane strips; user/model/tool/control-plane grammars all distinguishable; turn activity strips with counts; composer in its mode-correct state; desktop shows the activity rail with the newest turn expanded. | -| 3 | `chat-denied` | `#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=1` | Denied call on danger surface in both conversation and authorization section; missing claim + reason; Activity segment chip counting the deny (mobile). | -| 4 | `chat-activity-diff` | `#/chat/ix-checkbox-01?replay=fake&view=activity&turn=2` | Turn group with exposure counts, calls, authorization, control-plane rows, per-turn state diff (changed vs unchanged domains), reconciliation/wake row, turn parity verdict. | -| 5 | `chat-streaming` | live route, staged | Streaming model card, composer `active-turn` (Steer/Stop), pending activity group header, disabled session controls with reasons. Exempt from pixel determinism; capture once per viewport. | +| 3 | `chat-denied` | `#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=2` | Denied call on danger surface in both conversation and authorization section; missing claim + reason; Activity segment chip counting the deny (mobile). | +| 4 | `chat-activity-diff` | `#/chat/ix-checkbox-01?replay=fake&view=activity&turn=3` | Turn group with exposure counts, calls, authorization, control-plane rows, per-turn state diff (changed vs unchanged domains), reconciliation/wake row, turn parity verdict. | +| 5 | `chat-streaming` | `#/chat/ap-mcp-gate-01?replay=fake&stage=streaming` | Streaming model card, composer `active-turn` (Steer/Stop), pending activity group header, disabled session controls with reasons. Exempt from pixel determinism; capture once per viewport. | The scripted fixtures for `ix-checkbox-01` and `ap-mcp-gate-01` chat sessions are new 7I fixtures (the 7F fake-agent plans are single-shot, not @@ -334,6 +336,59 @@ Acceptance procedure: UXDesigner reviews all ten images against this matrix before 7I implementation is accepted; deviations are fixed or recorded as revisions in this document — silent divergence fails the gate (7F §9 rule). +## 11a. Revisions recorded during 7I implementation (PAP-16916) + +Silent divergence fails the §11 gate, so every deviation the implementation +made from §1–§11 is recorded here rather than left for a reviewer to discover. + +1. **Mode names: `Scripted (deterministic)` / `Codex (bounded)`, and the + composer stays usable in scripted mode.** §3 said replay mode renders the + composer `disconnected` because "free-text input is a live-driver + capability". With the provider relay unavailable in the package-local demo + (the same 7F §3.1 condition), that rule would have made the phase's own + acceptance — "a user can complete successful and denied mock Paperclip + interactions from chat" — unreachable. Scripted mode therefore accepts a + prompt and drives the next recorded turn against the live mock core, and the + composer note says exactly that, with the number of scripted turns left. The + board is never told a model wrote the reply. +2. **`replay=fake` plays once per session, not per route.** An explicit + **Reset session** on a `replay=fake` route returns to the seeded turn 0 and + stays there; the route's auto-play applies to the first session only. + Otherwise reset would be undone in the same frame by the route replaying, + which is the opposite of what §2 promises. +3. **`stage=streaming` added to §9.** §11 shot 5 said "live route, staged". + Staging by hand is a race, so the route parameter holds the last scripted + turn mid-reveal and the shell reports `data-chat-state="streaming"` — a + stable, linkable wait target. `pending` covers the mid-reveal transition. +4. **`data-chat-state` lives on the shell, not only on `chat-shell`.** On the + mobile Activity segment the chat column is hidden, so a wait target that + only existed inside it was unreachable exactly where the evidence is. Both + elements now carry it. +5. **Tool cards reuse the 7F transcript disclosure rather than the SDK + `tool-item`.** `ToolItem` requires a reducer `SessionItemSnapshot`, which a + semantic call is not. The 7F rendering already follows the same + `pcr-disclosure` grammar `ToolItem` itself uses, and reusing it keeps one + description of a semantic call across both surfaces. `Message`, `Composer`, + `Dialog`, `Badge`, and `Banner` are used as specified. +6. **Turn 0 reports `Pass`, not a blank verdict.** The seed makes exactly one + checkable claim — control-plane work with no agent tool — and reports a + verdict over that claim alone. Turns that did no agent work and made no + control-plane claim report `Not run`; no turn ever reports a verdict for + work nobody did. +7. **A third authored turn for `ix-checkbox-01`.** §11 shot 4 asks for a + wake/reconciliation row, but the mock core schedules the continuation wake + when an interaction is **resolved**, not when it is requested. Turn 3 is the + board answering its own checkbox — a control-plane-owned action with no + agent tool — which is a better demonstration of the phase's point than a + fabricated wake would have been. Shot 4's route is therefore `turn=3`. +8. **Filtering the picker no longer clears the selected case on the chat + surface.** Narrowing the rail must not end a live session; the explorer + keeps its existing behaviour. +9. **Evidence captures neutralise the sticky composer.** A full-page capture + stretches the viewport to the content height, which leaves a sticky element + floating mid-document. Pinning is proven by a browser test that scrolls the + transcript and asserts the composer is still on screen. + ## 12. Out of scope / rejected - All 7F §10 rejections stand (no Tailwind/Radix, no dark mode, no markdown diff --git a/packages/paperclip-runner/docs/index.md b/packages/paperclip-runner/docs/index.md index 28bf11705c4..d6d83c34dc9 100644 --- a/packages/paperclip-runner/docs/index.md +++ b/packages/paperclip-runner/docs/index.md @@ -15,6 +15,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase - [Phase 5: run the SDK console and mini consumer](tutorials/phase-05-sdk-console.md) — exercise the versioned public browser/React surface in two independent consumers against fake and real drivers. - [Phase 6: run the thin Paperclip adapter](tutorials/phase-06-thin-paperclip-adapter.md) — prove mock/real conformance, run one feature-flagged local task, inspect replay/finalization, disable the kill switch, and verify legacy fallback. - [Phase 7: explore the capability contract and scenario explorer](tutorials/phase-07-scenario-explorer.md) — prove the 106-case conformance suite, open the read-only browser explorer over the mock control plane, and run the focused verification set, all from a clean checkout with no Paperclip service or external eval repository. +- [Phase 7I: chat with the mock control plane](tutorials/phase-07i-scenario-chat.md) — send prompts to a Phase 7 scenario and watch the mock Paperclip activity for every turn: exposure, typed calls, denials, control-plane-owned actions, state diffs, wakes, and per-turn parity. - [Cumulative end-to-end tutorial](tutorials/end-to-end.md) — the shortest complete workflow available at the current phase. ## Reference @@ -35,9 +36,11 @@ Each tutorial starts with an explanation of what the phase is and what the phase - [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 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) - [Phase 7 scenario explorer UX interaction map](design/phase-7-scenario-explorer-ux.md) +- [Phase 7I mobile chat UX interaction map](design/phase-7i-mobile-chat-ux.md) - [Engineering journal guide](journal.md) - [Dated shadcn/ui and AI Elements compatibility note](research/2026-08-07-ui-library-compatibility.md) - [Phase 4b live-console interaction map](design/phase-4b-interaction-map.md) diff --git a/packages/paperclip-runner/docs/phase-07i-scenario-chat.md b/packages/paperclip-runner/docs/phase-07i-scenario-chat.md new file mode 100644 index 00000000000..f1d4bd2382a --- /dev/null +++ b/packages/paperclip-runner/docs/phase-07i-scenario-chat.md @@ -0,0 +1,105 @@ +# Phase 7I Scenario Chat + +The scenario chat is an interactive surface over the same package-local Phase 7 +runner and [mock control plane](phase-07-mock-control-plane-port.md) that the +[scenario explorer](phase-07-scenario-explorer.md) reads. You pick a scenario, +send prompts, and watch the mock Paperclip activity for every turn instead of +having it hidden behind the conversation. It contacts no Paperclip service, +holds no credential, and introduces no ACPX implementation. + +Sources: `src/phase7/chat-session.ts`, `src/phase7/chat-script.ts`, +`src/phase7/scenario-execution.ts`, and `examples/phase7-explorer/`. +Interaction contract: +[Phase 7I mobile chat UX](design/phase-7i-mobile-chat-ux.md). + +## The turn model + +A **session** is one long-lived mock control plane. Unlike a 7F single-shot +run, the state a turn changes is still there for the next turn — which is what +makes a per-turn diff mean anything. + +- **Turn 0 is the session seed**: checkout, wake routing, and the first tool + exposure decision, all performed by the control plane before anyone can type. + It renders with control-plane styling because no agent tool was involved. +- **Turns 1..N** are one user message each, plus everything the runtime + recorded until the agent settled. + +Every timeline entry carries the `turn` it belongs to, and the artifact carries +a `turns[]` array. The UI groups on those records; it never derives a turn +boundary, a per-turn diff, or a per-turn verdict itself. + +## What each turn records + +| Record | Produced by | +| --- | --- | +| Exposed tools (always / optional with unlocking grant / control-plane — no tool) | the 7D authorization engine | +| Semantic calls and typed results | the 7D tool runtime | +| Authorization decisions, missing claims, redactions | the 7D authorization engine | +| Control-plane actions with audit and decision references | the 7C mock core | +| State diff over the ten entity domains | immutable fixture snapshots | +| Wake / reconciliation events | the 7C mock core's own scheduled wakes | +| Turn parity verdict | `phase7TurnParity` | + +A turn is judged on what that turn did. A session-level expectation about work +the board has not asked for yet would read as a failure of turn 1 rather than +as work still to come, so the session verdict stays separate and is judged over +the whole transcript. + +## Scripts and drivers + +Every one of the 106 corpus scenarios opens in chat. Two carry authored +multi-turn conversations because the acceptance matrix names them: + +- `ix-checkbox-01` — read the options, open a checkbox interaction, then the + board answers it. That answer is a **control-plane-owned action with no agent + tool**, and it is what makes the control plane schedule the continuation wake. +- `ap-mcp-gate-01` — read the approval queue (allowed), then attempt the gated + request without the decide claim (**denied**). The denial is a turn outcome + with its own authorization slice, not a broken session. + +Every other scenario falls back to its 7F plan as a single scripted turn, which +keeps the whole corpus openable without inventing dialogue the traceability rows +do not support. Past the end of a script the session stays usable: the agent +re-reads the task context so the turn still shows real mock activity. + +**Scripted (deterministic)** is the working driver: your prompt drives the next +recorded turn against the in-page mock core. **Codex (bounded)** is disabled +with a named reason unless the local provider relay is running — the same rule +the explorer applies. No provider credential ever reaches the page in either +mode. + +## Routes + +``` +#/chat chat home (picker + intro) +#/chat/ live session, seeded at turn 0 +#/chat/?replay=fake scripted session, auto-played once +#/chat/?replay=fake&view=activity&turn= +#/chat/?replay=fake&filter= +#/chat/?replay=fake&stage=streaming holds the last turn mid-stream +``` + +The shell reports `data-chat-state` (`seeding` / `pending` / `streaming` / +`settled` / `failed`) so captures and tests wait on a state rather than a +timeout. `replay=fake` plays the script once per session: an explicit **Reset +session** returns to the seeded turn 0 and stays there. + +## Boundary + +- No network request leaves the page's own origin, and nothing the board types + is written to `localStorage`. +- The chat renders records the runtime produced. It computes no exposure, + evaluates no claim, applies no redaction, and re-times nothing. +- Redaction happens at the artifact boundary, so a raw secret cannot reach a + rendered record even if an upstream layer returned one. + +## Commands + +```sh +pnpm --filter @paperclipai/paperclip-runner run demo:phase7 # open the surface +pnpm --filter @paperclipai/paperclip-runner run test:phase7 # runtime + component tests +pnpm --filter @paperclipai/paperclip-runner run test:browser:phase7 +pnpm --filter @paperclipai/paperclip-runner run record:phase7i # §11 screenshot matrix +``` + +Walkthrough: [Phase 7I scenario chat tutorial](tutorials/phase-07i-scenario-chat.md). diff --git a/packages/paperclip-runner/docs/tutorials/phase-07i-scenario-chat.md b/packages/paperclip-runner/docs/tutorials/phase-07i-scenario-chat.md new file mode 100644 index 00000000000..0ca87f526e8 --- /dev/null +++ b/packages/paperclip-runner/docs/tutorials/phase-07i-scenario-chat.md @@ -0,0 +1,129 @@ +# Phase 7I Tutorial: Chat With the Mock Control Plane + +**Time to first success: about 3 minutes.** One command opens a chat where you +send prompts to a Phase 7 scenario and watch exactly how the mock Paperclip +control plane was used on every turn. The tutorial runs from the repository +root. It starts no Paperclip service, contacts no Paperclip control plane, and +holds no provider credential. + +Phase 7I extends the Phase 7 browser surface; it does not integrate the runner +into Paperclip. Real integration is Phase 8 and requires separate approval. See +[the future binding boundary reference](../phase-07-future-binding-boundary.md). + +## What you need + +- Node.js 20 or newer and pnpm 9 or newer. Verified with Node 22.22.2. +- No Rust toolchain and no network access after `pnpm install`. + +Install the package workspace from the repository root: + +```sh +pnpm install --filter @paperclipai/paperclip-runner --lockfile=false --offline --ignore-scripts --dev +``` + +## 1. Open the chat (about 1 minute) + +```sh +pnpm --filter @paperclipai/paperclip-runner run demo:phase7 +``` + +Open . + +Before you type anything, the conversation already has content: a **session +seed** strip reading "Control plane checked out the task and opened the run — no +agent tool exists for this," and a **Session seed activity** strip counting what +it did. That is the point of turn 0. Work happened, and no agent tool was +involved. + +The session status chip reads **Idle · seeded**. The right rail — **Activity** +on desktop, the **Activity** segment on a phone — shows the seed group with its +exposure decision, state diff, and verdict. + +## 2. Run a turn that succeeds + +Type into the composer and press Enter: + +``` +Where does the MCP tool approval for this task stand? +``` + +The turn appends: your message as a right-aligned bubble, two semantic calls +(`get_task_context`, `list_approvals`) as tool cards with their disposition +badges, the agent's reply, and a **Turn 1 activity** strip: + +``` +Turn 1 activity ⚙ 2 tools ⌘ 0 control plane Δ 0 domains ✓ Pass +``` + +Click the strip. The Activity stream opens turn 1's group with the six evidence +sections in order — exposed tools, calls and results, authorization, control +plane, state diff, parity. Nothing changed state, and the diff says so. + +## 3. Run a turn that is denied + +``` +Raise the approval request yourself and get the tool switched on. +``` + +`request_approval` is denied. The denial renders in place on the danger surface +with the failing operation, the code `policy_denied`, the reason +`required_claim_missing`, and an explicit line that **no protected state was +returned**. The turn strip counts it (`✕ 1 denied`), and on mobile the Activity +segment carries a `· 1 deny` chip so you can see it without opening the segment. + +The session status still reads **Scripted · deterministic** and the session +parity still passes. A denial is a correct outcome, not a broken session — this +run simply does not hold `governance:approvals:request`. Open the turn's +**Authorization** section to see the claims considered and the missing one. + +## 4. See control-plane-owned work with no agent tool + +Open . +`replay=fake` plays the recorded three-turn conversation. + +Turn 2 opens a checkbox interaction. Turn 3 is the board answering it — and +that answer is performed by the control plane, not by a tool: + +``` +⌘ Control plane recorded the board's answer and scheduled the continuation + wake — no agent tool exists for this. +``` + +Open turn 3's **State diff** section. The `interactions` domain shows +`status: pending → answered`, the unchanged domains collapse to one line, and a +**Control plane** badged row records `wake.scheduled`. The wake is scheduled by +reconciliation; the agent never asked for it. + +## 5. Reset and replay + +**Reset session** asks first, because it discards mutable mock state. Accept it +and the transcript returns to the seeded turn 0 — no turns, no denials, a clean +diff. Switching scenario from the rail mid-session asks the same question, since +two scenarios never share mock state. + +**Replay scripted session** plays the remaining recorded turns and needs no +confirmation: it reproduces an identical timeline. + +## 6. Run the tests and record the evidence + +```sh +pnpm --filter @paperclipai/paperclip-runner run test:phase7 +pnpm --filter @paperclipai/paperclip-runner run test:browser:phase7 +pnpm --filter @paperclipai/paperclip-runner run record:phase7i +``` + +The first covers the session data contract and the chat components, the second +drives the surface in a real browser at both viewports, and the third writes the +ten acceptance screenshots to `knowledge/evidence/phase-07/` — failing if any +route scrolls horizontally. + +## What this does not do + +- No ACPX implementation and no real control-plane integration. +- No provider credential in the browser. **Codex (bounded)** stays disabled with + a named reason unless the local provider relay is running; **Scripted + (deterministic)** drives the same mock core offline. +- Nothing you type is persisted. A reload drops an unsent draft, which is the + accepted trade against storing conversation content. + +Reference: [Phase 7I scenario chat](../phase-07i-scenario-chat.md). diff --git a/packages/paperclip-runner/examples/phase7-chat.spec.ts b/packages/paperclip-runner/examples/phase7-chat.spec.ts new file mode 100644 index 00000000000..174f3f8255b --- /dev/null +++ b/packages/paperclip-runner/examples/phase7-chat.spec.ts @@ -0,0 +1,251 @@ +import { expect, test, type Page } from "@playwright/test"; + +/** + * Phase 7I browser acceptance. + * + * The chat is where the board actually uses the mock control plane, so these + * cover the paths a person takes: send a prompt, get a denial, follow a turn to + * its evidence, reset, and do it again on a phone. Screen captures for the §11 + * matrix are recorded by `scripts/record-phase7i-evidence.mjs`, which reuses + * these routes. + */ + +const BASE = "/phase7-explorer/"; +const MOBILE = { width: 390, height: 844 }; + +async function open(page: Page, hash: string, state = "settled"): Promise { + await page.goto(`${BASE}${hash}`); + await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute( + "data-chat-state", + state, + { timeout: 30_000 }, + ); +} + +async function send(page: Page, prompt: string): Promise { + await page.fill('[data-testid="composer-input"]', prompt); + await page.press('[data-testid="composer-input"]', "Enter"); + await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute( + "data-chat-state", + "settled", + { timeout: 30_000 }, + ); +} + +test.describe("chat shell", () => { + test("chat home offers the picker and an intro, with no dead panel", async ({ page }) => { + await page.goto(`${BASE}#/chat`); + await expect(page.locator('[data-testid="explorer-intro"]')).toContainText("scenario chat"); + await expect(page.locator("nav[aria-label='Scenario picker']")).toBeVisible(); + await expect(page.locator("aside[aria-label='Turn activity']")).toBeVisible(); + await expect(page.locator("h1")).toHaveCount(1); + }); + + test("an unknown chat case is named rather than silently redirected", async ({ page }) => { + await page.goto(`${BASE}#/chat/not-a-real-case`); + await expect(page.locator('[data-testid="unknown-scenario"]')).toContainText("not-a-real-case"); + }); + + test("the seeded session shows control-plane work before any message", async ({ page }) => { + await open(page, "#/chat/ix-checkbox-01"); + await expect(page.locator('[data-testid="chat-seed-label"]')).toBeVisible(); + await expect(page.locator('[data-testid="chat-turn-0"]')).toContainText("Control plane"); + await expect(page.locator('[data-testid="session-status"]')).toHaveText("Idle · seeded"); + await expect(page.locator('[data-testid="turn-activity-strip-0"]')).toBeVisible(); + }); +}); + +test.describe("driving a session from the composer", () => { + test("a typed prompt runs a turn and a denial lands in place", async ({ page }) => { + await open(page, "#/chat/ap-mcp-gate-01"); + await send(page, "Where does the MCP tool approval stand?"); + await send(page, "Raise the approval request yourself."); + + await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeVisible(); + const denial = page.locator('[data-outcome="denied"]').first(); + await expect(denial).toBeVisible(); + await expect(denial).toContainText("required_claim_missing"); + // A denial must never carry protected state. + await expect(denial).toContainText("not returned"); + await expect(page.locator('[data-testid="denial-announcement"]').first()).toHaveAttribute( + "aria-live", + "assertive", + ); + // The session survives a denial; it is an outcome, not a failure. + await expect(page.locator('[data-testid="session-status"]')).toHaveText( + "Scripted · deterministic", + ); + }); + + test("a turn strip opens that turn's evidence and mirrors it to the route", async ({ page }) => { + await open(page, "#/chat/ap-mcp-gate-01?replay=fake"); + await page.click('[data-testid="turn-activity-strip-2"]'); + await expect(page).toHaveURL(/view=activity&turn=2/); + await expect(page.locator('[data-testid="activity-turn-2"]')).toHaveAttribute( + "data-open", + "true", + ); + await expect(page.locator('[data-testid="activity-section-2-authorization"]')).toBeVisible(); + await expect(page.locator('[data-testid="activity-diff-2"]')).toBeVisible(); + }); + + test("reset confirms, returns to the seed, and leaks nothing", async ({ page }) => { + await open(page, "#/chat/ap-mcp-gate-01?replay=fake"); + await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeVisible(); + + await page.click('[data-testid="reset-session"]'); + await page.click('[data-testid="confirm-cancel"]'); + await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeVisible(); + + await page.click('[data-testid="reset-session"]'); + await page.click('[data-testid="confirm-accept"]'); + await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute( + "data-chat-state", + "settled", + { timeout: 30_000 }, + ); + await expect(page.locator('[data-testid="turn-activity-strip-1"]')).toHaveCount(0); + await expect(page.locator('[data-outcome="denied"]')).toHaveCount(0); + await expect(page.locator('[data-testid="session-status"]')).toHaveText("Idle · seeded"); + }); + + test("switching scenario mid-session asks before discarding the mock state", async ({ page }) => { + await open(page, "#/chat/ap-mcp-gate-01?replay=fake"); + await page.fill('[data-testid="scenario-search"]', "ix-checkbox-01"); + await page.click('[data-testid="case-row-ix-checkbox-01"]'); + await expect(page.locator("dialog[open]")).toContainText("Switch scenario?"); + await page.click('[data-testid="confirm-accept"]'); + await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute( + "data-chat-state", + "settled", + { timeout: 30_000 }, + ); + await expect(page.locator('[data-testid="session-header"]')).toContainText("ix-checkbox-01"); + await expect(page.locator('[data-outcome="denied"]')).toHaveCount(0); + }); +}); + +test.describe("determinism and evidence", () => { + test("the scripted route settles to a byte-identical transcript on reload", async ({ page }) => { + const read = async (): Promise => { + await open(page, "#/chat/ix-checkbox-01?replay=fake"); + return (await page.locator('[data-testid="chat-conversation"]').innerText()).trim(); + }; + const first = await read(); + await page.goto("about:blank"); + expect(await read()).toBe(first); + }); + + test("every turn group carries the six evidence sections in order", async ({ page }) => { + await open(page, "#/chat/ix-checkbox-01?replay=fake&view=activity&turn=3"); + const group = page.locator('[data-testid="activity-turn-3"]'); + await expect(group.locator('[data-testid="activity-section-3-exposure"]')).toBeVisible(); + await expect(group.locator('[data-testid="activity-section-3-calls"]')).toBeVisible(); + await expect(group.locator('[data-testid="activity-section-3-control"]')).toBeVisible(); + await expect(group.locator('[data-testid="activity-section-3-diff"]')).toBeVisible(); + await expect(group.locator('[data-testid="activity-section-3-parity"]')).toBeVisible(); + await expect(group.locator('[data-testid="activity-reconciliation-3"]')).toContainText( + "wake.scheduled", + ); + }); + + test("a filter collapses sections without deleting turn groups", async ({ page }) => { + await open(page, "#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=2&filter=authorization"); + await expect(page.locator('[data-testid="activity-turn-0"]')).toBeVisible(); + await expect(page.locator('[data-testid="activity-turn-2"]')).toBeVisible(); + await expect(page.locator('[data-testid="activity-section-2-authorization"]')).toBeVisible(); + await expect(page.locator('[data-testid="activity-section-2-diff"]')).toHaveCount(0); + }); + + test("the staged streaming route holds a running turn", async ({ page }) => { + await open(page, "#/chat/ap-mcp-gate-01?replay=fake&stage=streaming", "streaming"); + await expect(page.locator('[data-slot="composer"]')).toHaveAttribute("data-state", "active-turn"); + await expect(page.locator('[data-testid^="turn-pending-"]')).toBeVisible(); + // Controls that would corrupt the running turn state their reason. + await expect(page.locator('[data-testid="reset-session"]')).toBeDisabled(); + await expect(page.locator('[data-testid="replay-session"]')).toBeDisabled(); + }); + + test("no credential or raw secret reaches the chat page", async ({ page }) => { + await open(page, "#/chat/rs-secret-hygiene-01?replay=fake"); + const body = await page.locator("body").innerText(); + expect(body).not.toContain("fixture-secret-value-not-a-real-credential"); + expect(await page.evaluate(() => window.localStorage.length)).toBe(0); + }); +}); + +test.describe("mobile and accessibility", () => { + test.use({ viewport: MOBILE }); + + test("the activity segment carries the whole stream and counts denials", async ({ page }) => { + await open(page, "#/chat/ap-mcp-gate-01?replay=fake"); + await expect(page.locator('[data-testid="segment-activity-denials"]')).toContainText("deny"); + await page.click('[data-testid="segment-activity"]'); + await expect(page.locator('[data-testid="activity-stream"]')).toBeVisible(); + await expect(page.locator('[data-testid="activity-section-2-authorization"]')).toBeVisible(); + await expect(page.locator('[data-testid="chat-conversation"]')).toBeHidden(); + }); + + test("back to chat returns focus to the turn strip that opened the group", async ({ page }) => { + await open(page, "#/chat/ap-mcp-gate-01?replay=fake"); + await page.click('[data-testid="turn-activity-strip-2"]'); + await expect(page.locator('[data-testid="activity-stream"]')).toBeVisible(); + await page.click('[data-testid="activity-back-to-chat-2"]'); + await expect(page.locator('[data-testid="chat-conversation"]')).toBeVisible(); + await expect(page.locator('[data-testid="turn-activity-strip-2"]')).toBeFocused(); + }); + + test("long payloads never scroll the page sideways", async ({ page }) => { + for (const hash of [ + "#/chat/ix-checkbox-01?replay=fake", + "#/chat/ap-mcp-gate-01?replay=fake&view=activity&turn=2", + "#/chat/mh-subtask-tree-01?replay=fake&view=activity", + ]) { + await open(page, hash); + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + expect(overflow, `${hash} scrolls horizontally`).toBeLessThanOrEqual(0); + } + }); + + test("touch targets on the chat controls meet the minimum", async ({ page }) => { + await open(page, "#/chat/ix-checkbox-01?replay=fake"); + for (const testId of ["segment-chat", "segment-activity", "turn-activity-strip-1"]) { + const box = await page.locator(`[data-testid="${testId}"]`).first().boundingBox(); + expect(box, testId).not.toBeNull(); + expect(box!.height, testId).toBeGreaterThanOrEqual(40); + } + }); + + test("the composer stays in the thumb zone while the transcript scrolls", async ({ page }) => { + await open(page, "#/chat/ix-checkbox-01?replay=fake"); + await page.evaluate(() => window.scrollTo(0, 0)); + const composer = page.locator('[data-testid="composer"]'); + const top = (await composer.boundingBox())!.y; + await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight)); + const bottom = (await composer.boundingBox())!.y; + expect(bottom).not.toBe(top); + // Still on screen after the scroll, not left behind at the top. + const box = (await composer.boundingBox())!; + expect(box.y).toBeLessThan(MOBILE.height); + expect(box.y + box.height).toBeGreaterThan(0); + }); + + test("the composer keyboard contract holds", async ({ page }) => { + await open(page, "#/chat/ix-checkbox-01"); + const input = page.locator('[data-testid="composer-input"]'); + await input.fill("first line"); + await input.press("Shift+Enter"); + await input.type("second line"); + expect(await input.inputValue()).toContain("\n"); + await expect(page.locator('[data-testid="turn-activity-strip-1"]')).toHaveCount(0); + await input.press("Enter"); + await expect(page.locator('[data-testid="explorer-shell"]')).toHaveAttribute( + "data-chat-state", + "settled", + { timeout: 30_000 }, + ); + await expect(page.locator('[data-testid="turn-activity-strip-1"]')).toBeVisible(); + }); +}); diff --git a/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx b/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx index 2bc7ffdd84d..3767eb6d773 100644 --- a/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx +++ b/packages/paperclip-runner/examples/phase7-explorer/src/app.tsx @@ -3,6 +3,8 @@ import * as React from "react"; import { Tabs, type TabDefinition } from "@paperclipai/paperclip-runner/react"; import { phase7RunScenario, + Phase7ChatSession, + type Phase7ChatSessionArtifact, type Phase7EvalSuiteLookup, type Phase7ParityStatus, type Phase7RunArtifact, @@ -21,11 +23,18 @@ import { buildFacets, ScenarioPicker, type Phase7PickerFacets } from "./componen import { RunHeader } from "./components/run-header.js"; import { ScenarioTranscript } from "./components/scenario-transcript.js"; import { EmptyState, Mono } from "./components/primitives.js"; +import { ChatSurface, type Phase7ChatState } from "./components/chat-surface.js"; +import { + ActivityEmptyState, + ActivityStream, + activityDenialCount, +} from "./components/chat-activity-stream.js"; import { DISPOSITION_LABEL } from "./labels.js"; import { EMPTY_FILTERS, parsePhase7Route, serializePhase7Route, + type Phase7ActivityFilter, type Phase7Filters, type Phase7InspectorView, type Phase7Route, @@ -47,10 +56,28 @@ export interface ExplorerAppProps { initialRoute?: Phase7Route; /** Test seam: run scenarios synchronously through an injected runner. */ runScenario?: typeof phase7RunScenario; + /** Test seam: open chat sessions through an injected session factory. */ + openSession?: typeof Phase7ChatSession.open; } type RunState = "idle" | "pending" | "settled" | "failed"; -type Segment = "scenarios" | "run" | "inspect"; +/** + * Mobile segments. The explorer and the chat are two surfaces in one shell, so + * they share the segmented-control mechanism but never its labels: "Run" and + * "Chat" are different jobs and must not look like the same tab renamed. + */ +type Segment = "scenarios" | "run" | "inspect" | "chat" | "activity"; + +const EXPLORER_SEGMENTS = ["scenarios", "run", "inspect"] as const; +const CHAT_SEGMENTS = ["scenarios", "chat", "activity"] as const; + +const SEGMENT_LABEL: Record = { + scenarios: "Scenarios", + run: "Run", + inspect: "Inspect", + chat: "Chat", + activity: "Activity", +}; const INSPECTOR_TABS: readonly TabDefinition[] = [ { id: "context", label: "Context" }, @@ -65,6 +92,7 @@ export function ExplorerApp({ codexAvailable = false, initialRoute, runScenario = phase7RunScenario, + openSession, }: ExplorerAppProps) { const [route, setRoute] = React.useState( () => initialRoute ?? parsePhase7Route(typeof window === "undefined" ? "#/" : window.location.hash), @@ -72,12 +100,13 @@ export function ExplorerApp({ const [artifacts, setArtifacts] = React.useState>({}); const [runState, setRunState] = React.useState("idle"); const [mode, setMode] = React.useState<"fake" | "codex">(route.run ?? "fake"); - const [segment, setSegment] = React.useState(() => - route.caseId === null ? "scenarios" : route.view === "transcript" ? "run" : "inspect", - ); + const [segment, setSegment] = React.useState(() => defaultSegment(route)); const [replayPosition, setReplayPosition] = React.useState(0); const [highlight, setHighlight] = React.useState(null); const [runFocusRequest, setRunFocusRequest] = React.useState(0); + const [chatArtifact, setChatArtifact] = React.useState(null); + const [chatState, setChatState] = React.useState("seeding"); + const [stripFocusRequest, setStripFocusRequest] = React.useState({ turn: 0, nonce: 0 }); const shellRef = React.useRef(null); const runHeaderRef = React.useRef(null); @@ -102,14 +131,23 @@ export function ExplorerApp({ // button) has to move the mobile segment too, or the route says "parity" // while the screen still shows the picker. React.useEffect(() => { - setSegment(route.caseId === null ? "scenarios" : route.view === "transcript" ? "run" : "inspect"); - }, [route.caseId, route.view]); + setSegment(defaultSegment(route)); + }, [route.caseId, route.chatView, route.surface, route.view]); React.useEffect(() => { if (runFocusRequest === 0 || selected === null) return; runHeaderRef.current?.focus(); }, [runFocusRequest, selected]); + React.useEffect(() => { + if (stripFocusRequest.nonce === 0) return; + document + .querySelector( + `[data-testid="turn-activity-strip-${stripFocusRequest.turn}"]`, + ) + ?.focus(); + }, [stripFocusRequest]); + React.useEffect(() => { const cycleRegions = (event: KeyboardEvent): void => { const documentedAlternative = event.ctrlKey && event.code === "Period"; @@ -207,22 +245,67 @@ export function ExplorerApp({ } function setFilters(patch: Partial): void { - navigate({ ...route, caseId: null, filters: { ...route.filters, ...patch } }); + // Narrowing the picker must not end a live chat session, so the chat + // surface keeps its case while the explorer still returns to the corpus. + const caseId = route.surface === "chat" ? route.caseId : null; + navigate({ ...route, caseId, filters: { ...route.filters, ...patch } }); setSegment("scenarios"); } + function setChatView(next: "chat" | "activity"): void { + navigate({ ...route, chatView: next }); + } + + function openTurn(turn: number): void { + navigate({ ...route, chatView: "activity", turn }); + setSegment("activity"); + } + + function backToChat(turn: number): void { + navigate({ ...route, chatView: "chat", turn }); + setSegment("chat"); + // Focus after the commit: on mobile the strip is still hidden at this point. + setStripFocusRequest((request) => ({ turn, nonce: request.nonce + 1 })); + } + + const chat = route.surface === "chat"; + const denials = chatArtifact === null ? 0 : activityDenialCount(chatArtifact); + return (
- Skip to run view + Skip to {chat ? "the chat" : "run view"} + +

Region shortcut: press F6, or Control+Period when the browser reserves F6, to cycle the scenario picker, run view, and inspector. @@ -236,18 +319,27 @@ export function ExplorerApp({

- {(["scenarios", "run", "inspect"] as const).map((value) => ( + {(chat ? CHAT_SEGMENTS : EXPLORER_SEGMENTS).map((value) => ( ))}
@@ -266,7 +358,12 @@ export function ExplorerApp({ onCommitSelection={commitCase} /> -
+
{route.caseId !== null && selected === null ? ( ) : selected === null ? ( - + + ) : chat ? ( + openTurn(chatArtifact?.turns.at(-1)?.turn ?? 0)} + onArtifactChange={setChatArtifact} + onStateChange={setChatState} + onCancelScenarioSwitch={(entryId) => navigate({ ...route, caseId: entryId })} + openSession={openSession} + headerRef={runHeaderRef} + /> ) : ( <> -