diff --git a/packages/paperclip-runner/README.md b/packages/paperclip-runner/README.md index 7c0647c3094..7f03e6c14e2 100644 --- a/packages/paperclip-runner/README.md +++ b/packages/paperclip-runner/README.md @@ -16,9 +16,13 @@ and a real-model example against the mock core. These phases do not import or start Paperclip's server, UI, CLI, or production database. The Phase 4b lower layer adds browser-resolved provider requests, goal capability detection, subagent lineage, control-race semantics, and a package-local demo server that -keeps Codex authentication server-side. +keeps Codex authentication server-side. The Phase 4b live console adds the +browser tracer over that boundary: a live transcript and composer, same-turn +steering, three distinct interrupt races, inline request cards, +capability-gated goal controls, parent/child lineage, reconnect, refresh, +replay, and a redacted protocol inspector. -## Phase 0–4 quick start +## Phase 0–4b quick start From the repository root: @@ -59,6 +63,9 @@ pnpm --filter @paperclipai/paperclip-runner trace:phase2 -- --scenario happy-pat pnpm --filter @paperclipai/paperclip-runner trace:phase3 -- --fault lost-ack pnpm --filter @paperclipai/paperclip-runner trace:phase4 pnpm --filter @paperclipai/paperclip-runner demo:phase4b -- --host 127.0.0.1 --port 4174 + +# Phase 4b: chat with a live session in the browser. +pnpm --filter @paperclipai/paperclip-runner console:phase4b pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port 4179 ``` @@ -85,10 +92,11 @@ pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port | `trace:phase4` | Run the mock core with a real, local skillless Codex app-server session. | | `record:phase4` | Run the safe Codex task and record its validated, normalized trace. | | `demo:phase4b` | Start the package-local HTTP/SSE server with server-only Codex authentication. | +| `console:phase4b` | Start the standalone browser devtool with the Phase 4b live console on `127.0.0.1:4180`. | | `record:phase4b` | Run a safe real Codex task through the demo server and record reconnect/replay evidence. | | `browser:dev` | Start the standalone live/replay/recovery browser devtool. | | `test:browser` | Exercise static replay and live scenarios, then capture temporary screenshots under ignored test output. | -| `verify` | Run the complete deterministic Phase 0 through Phase 4 acceptance sequence. | +| `verify` | Run the complete deterministic Phase 0 through Phase 4b acceptance sequence. | | `verify:rootless` | Extract Debian/Ubuntu browser libraries without root, then run `verify`. | ## Navigate @@ -105,6 +113,8 @@ pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port - [Phase 4 skillless Codex driver reference](docs/phase-04-skillless-codex-driver.md) - [Phase 4b protocol/server tutorial](docs/tutorials/phase-04b-protocol-server.md) - [Phase 4b protocol/server reference](docs/phase-04b-protocol-server.md) +- [Phase 4b live console tutorial](docs/tutorials/phase-04b-live-console.md) +- [Phase 4b live console reference](docs/phase-04b-live-console.md) - [PRP compatibility/versioning policy](docs/protocol-compatibility.md) - [Cumulative end-to-end tutorial](docs/tutorials/end-to-end.md) - [Journal guide](docs/journal.md) @@ -112,5 +122,6 @@ pnpm --filter @paperclipai/paperclip-runner browser:dev --host 127.0.0.1 --port - [Implementation plan](spec/paperclip-native-runner-implementation-plan.md) - [Normative spike specification](spec/paperclip-native-runner-spike-spec.md) -Phase 4 adds the package-local real-model reference driver. Production -Paperclip integration and Phase 5 remain deferred. +Phase 4 adds the package-local real-model reference driver and Phase 4b adds +the package-local browser console over it. Production Paperclip integration and +Phase 5 remain deferred. diff --git a/packages/paperclip-runner/devtools/browser/live-console.spec.ts b/packages/paperclip-runner/devtools/browser/live-console.spec.ts new file mode 100644 index 00000000000..3a03fe77171 --- /dev/null +++ b/packages/paperclip-runner/devtools/browser/live-console.spec.ts @@ -0,0 +1,395 @@ +import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { expect, test, type Page } from "@playwright/test"; + +const evidenceRoot = resolve( + fileURLToPath(new URL("../../knowledge/evidence/phase-04b", import.meta.url)), +); + +const DESKTOP = { width: 1440, height: 900 }; +const MOBILE = { width: 390, height: 844 }; + +function shot(page: Page, name: string) { + return page.screenshot({ path: resolve(evidenceRoot, `${name}.png`), fullPage: true }); +} + +async function openConsole(page: Page) { + await page.goto("/"); + await page.getByRole("button", { name: "Live console" }).click(); + await expect(page.getByRole("heading", { name: "Live Codex protocol console" })).toBeVisible(); +} + +async function runManifest(page: Page, id: string) { + await expect(page.getByTestId("manifest-list")).toBeVisible(); + await page.getByTestId(`manifest-${id}`).click(); + await page.getByTestId("run-manifest").click(); + await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 15_000 }); +} + +test.describe("Phase 4b live console", () => { + test.beforeEach(async ({ page }) => { + await page.setViewportSize(DESKTOP); + }); + + test("shows the empty state and runs a completed turn", async ({ page }) => { + await openConsole(page); + await expect(page.getByTestId("empty-transcript")).toBeVisible(); + await expect(page.getByTestId("composer-send")).toBeDisabled(); + await shot(page, "desktop-01-idle-empty"); + + await runManifest(page, "completion"); + await expect(page.getByTestId("reasoning-item")).toBeVisible(); + await expect(page.getByTestId("tool-item")).toBeVisible(); + await shot(page, "desktop-02-streaming-turn"); + + await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId("live-transcript")).toContainText("finished the task"); + await expect(page.getByTestId("live-transcript")).toContainText( + "Summarize what the Phase 4b demo server proves.", + ); + await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "completed"); + await shot(page, "desktop-03-turn-completed"); + + // The browser must never receive a provider credential. + const body = await page.locator("body").innerText(); + expect(body).not.toContain("Bearer "); + expect(body).not.toContain("auth.json"); + }); + + test("acknowledges steering and keeps rejected text recoverable", async ({ page }) => { + await openConsole(page); + await runManifest(page, "steering"); + await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 }); + + await page.getByTestId("composer-input").fill("Keep it to one sentence."); + await page.getByTestId("composer-send").click(); + const chip = page.getByTestId("steering-chip").first(); + await expect(chip).toContainText("acknowledged", { timeout: 15_000 }); + await shot(page, "desktop-04-steering-acknowledged"); + + await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 25_000 }); + // The composer state machine now offers Send again; force a stale steer. + await page.evaluate(() => window.history.replaceState({}, "", "/")); + }); + + test("rejects a stale steer and offers to resend it", async ({ page }) => { + await openConsole(page); + await runManifest(page, "steering"); + await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 }); + await page.getByTestId("composer-input").fill("Typed while the turn was running."); + + // Let the turn finish before the steer is sent: the classic stale race. + await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 25_000 }); + await expect(page.getByTestId("composer-send")).toHaveText("Send"); + }); + + test("cancels a turn interrupted before it starts", async ({ page }) => { + await openConsole(page); + await runManifest(page, "interrupt-before-start"); + await expect(page.getByTestId("composer-stop")).toBeEnabled(); + await page.getByTestId("composer-stop").click(); + await expect(page.getByTestId("turn-cancelled")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("turn-cancelled")).toContainText("Cancelled before start"); + await shot(page, "desktop-05-interrupt-before-start"); + }); + + test("keeps partial text when a streaming turn is stopped", async ({ page }) => { + await openConsole(page); + await runManifest(page, "interrupt-during-generation"); + await expect(page.getByTestId("live-transcript")).toContainText("The tracer keeps", { + timeout: 15_000, + }); + await page.getByTestId("composer-stop").click(); + await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 }); + await expect(page.getByTestId("interrupted-divider").first()).toBeVisible(); + await expect(page.getByTestId("live-transcript")).not.toContainText("And this final one."); + await shot(page, "desktop-06-interrupt-during-generation"); + }); + + test("keeps an interrupted tool item without a fabricated result", async ({ page }) => { + await openConsole(page); + await runManifest(page, "interrupt-during-tool"); + await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "running", { + timeout: 15_000, + }); + await page.getByTestId("composer-stop").click(); + await expect(page.getByTestId("tool-item")).toHaveAttribute("data-status", "interrupted", { + timeout: 15_000, + }); + await expect(page.getByTestId("turn-interrupted")).toBeVisible(); + await shot(page, "desktop-07-interrupt-during-tool"); + }); + + test("resolves approvals exactly once and collapses the resolved card", async ({ page }) => { + await openConsole(page); + await runManifest(page, "approvals"); + const card = page.getByTestId("request-card").first(); + await expect(card).toHaveAttribute("data-status", "pending", { timeout: 15_000 }); + await expect(page.getByTestId("pending-request-banner")).toBeVisible(); + await expect(page.getByTestId("request-action-accept_for_session")).toBeVisible(); + await shot(page, "desktop-08-request-pending"); + + await page.getByTestId("request-action-accept").first().click(); + await expect(page.getByTestId("request-card").first()).toHaveAttribute( + "data-status", + "resolved", + { timeout: 15_000 }, + ); + await expect(page.getByTestId("request-card").first()).toContainText("resolved — Approve"); + await shot(page, "desktop-09-request-resolved"); + + // Second request only offers accept and decline. + const second = page.getByTestId("request-card").nth(1); + await expect(second).toHaveAttribute("data-status", "pending", { timeout: 15_000 }); + await expect(second.getByTestId("request-action-accept_for_session")).toHaveCount(0); + }); + + test("submits a user-input answer and lets an unanswered request expire", async ({ page }) => { + test.setTimeout(60_000); + await openConsole(page); + await runManifest(page, "user-input"); + await expect(page.getByTestId("request-answer")).toBeVisible({ timeout: 15_000 }); + await page.getByTestId("request-answer").fill("staging"); + await page.getByTestId("request-action-submit").click(); + await expect(page.getByTestId("request-card").first()).toHaveAttribute( + "data-status", + "resolved", + { timeout: 15_000 }, + ); + + await expect(page.getByTestId("request-card").nth(1)).toHaveAttribute( + "data-status", + "expired", + { timeout: 25_000 }, + ); + await expect(page.getByTestId("request-card").nth(1)).toContainText("expired before response"); + await shot(page, "desktop-10-request-expired"); + }); + + test("moves a goal through its lifecycle", async ({ page }) => { + await openConsole(page); + await runManifest(page, "goals"); + await expect(page.getByTestId("goal-banner")).toHaveText("No goal set", { timeout: 15_000 }); + + await page.locator("#goal-menu").click(); + await page.getByRole("menuitem", { name: "Set goal…" }).click(); + await page.getByTestId("goal-objective").fill("Keep the demo goal visible"); + await page.getByTestId("goal-set-confirm").click(); + await expect(page.getByTestId("goal-banner")).toContainText("Keep the demo goal visible", { + timeout: 15_000, + }); + await expect(page.getByTestId("goal-banner")).toContainText("active"); + + await page.locator("#goal-menu").click(); + await page.getByRole("menuitem", { name: "Pause" }).click(); + await expect(page.getByTestId("goal-banner")).toContainText("paused", { timeout: 15_000 }); + await shot(page, "desktop-11-goal-supported"); + }); + + test("disables the goal menu with the exact upstream diagnostic", async ({ page }) => { + await openConsole(page); + await runManifest(page, "goals-unsupported"); + const trigger = page.locator("#goal-menu"); + await expect(trigger).toHaveAttribute("aria-disabled", "true", { timeout: 15_000 }); + const describedBy = await trigger.getAttribute("aria-describedby"); + expect(describedBy).not.toBeNull(); + await expect(page.locator(`#${describedBy}`)).toHaveText( + "Goal operations unsupported: app-server 0.132.0 does not advertise the goals capability", + ); + + await page.getByTestId("toggle-inspector").click(); + await page.getByRole("tab", { name: "Capabilities" }).click(); + await expect(page.getByTestId("inspector-capabilities")).toContainText( + "does not advertise the goals capability", + ); + await shot(page, "desktop-12-goal-unsupported"); + }); + + test("shows child threads and refuses to emulate child steering", async ({ page }) => { + await openConsole(page); + await runManifest(page, "subagents"); + await expect(page.getByTestId("thread-thread-child-b")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId("thread-thread-child-a")).toContainText("Completed"); + await expect(page.getByTestId("thread-thread-child-b")).toContainText("Failed"); + + await page.getByTestId("thread-thread-child-a").click(); + await expect(page.getByTestId("breadcrumb")).toContainText("thread-child-a"); + await expect(page.getByTestId("composer-reason")).toHaveText( + "Direct steering of child threads is not supported by this app-server.", + ); + await expect(page.getByTestId("composer-input")).toBeDisabled(); + await shot(page, "desktop-13-lineage-child"); + }); + + test("shows the failed item and diagnostic in the record", async ({ page }) => { + await openConsole(page); + await runManifest(page, "failure"); + await expect(page.getByTestId("turn-failed")).toBeVisible({ timeout: 20_000 }); + await expect(page.getByTestId("tool-failure")).toContainText("cannot find module"); + await shot(page, "desktop-14-failed-turn"); + }); + + test("resumes the same session after a transport drop", async ({ page }) => { + await openConsole(page); + await runManifest(page, "completion"); + await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 }); + const before = await page.getByTestId("live-transcript").innerText(); + + await page.getByTestId("drop-connection").click(); + await expect(page.getByTestId("reconnect-banner")).toBeVisible(); + await expect(page.getByTestId("composer-input")).toBeDisabled(); + await shot(page, "desktop-15-reconnect-banner"); + + await page.getByTestId("retry-now").click(); + await expect(page.getByTestId("connection-status")).toHaveText("connected", { + timeout: 20_000, + }); + // Live and resumed transcripts must be identical, not merely similar. + expect(await page.getByTestId("live-transcript").innerText()).toContain(before.trim()); + }); + + test("restores the same session across a page refresh", async ({ page }) => { + await openConsole(page); + await runManifest(page, "completion"); + await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 }); + const before = await page.getByTestId("live-transcript").innerText(); + + await page.reload(); + await page.getByRole("button", { name: "Live console" }).click(); + await expect(page.getByTestId("live-transcript")).toContainText("finished the task", { + timeout: 20_000, + }); + expect(await page.getByTestId("live-transcript").innerText()).toContain(before.trim()); + await shot(page, "desktop-16-refresh-replay"); + }); + + test("replays a recorded session with a stepper", async ({ page }) => { + await openConsole(page); + await runManifest(page, "completion"); + await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 }); + + await page.getByTestId("toggle-replay").click(); + await expect(page.getByTestId("replay-badge")).toBeVisible(); + await expect(page.getByTestId("replay-stepper")).toBeVisible(); + await expect(page.getByTestId("empty-transcript")).toBeVisible(); + + await page.getByRole("button", { name: "Step forward" }).click(); + await page.getByRole("button", { name: "Step forward" }).click(); + await expect(page.getByTestId("empty-transcript")).toHaveCount(0); + await shot(page, "desktop-17-replay-mode"); + + await page.getByTestId("toggle-replay").click(); + await expect(page.getByTestId("replay-badge")).toHaveCount(0); + await expect(page.getByTestId("turn-completed")).toBeVisible(); + }); + + test("inspects canonical events and reports reducer parity", async ({ page }) => { + await openConsole(page); + await runManifest(page, "completion"); + await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 }); + + await page.getByTestId("toggle-inspector").click(); + await expect(page.getByTestId("inspector-events")).toBeVisible(); + await page.getByTestId("inspector-filter").fill("turn.completed"); + await expect(page.getByTestId("inspector-events").getByRole("listitem")).toHaveCount(1); + await shot(page, "desktop-18-inspector-events"); + + await page.getByTestId("inspector-filter").fill(""); + await page.getByRole("tab", { name: "Session" }).click(); + await expect(page.getByTestId("inspector-session")).toContainText("Credentials in browser"); + await expect(page.getByTestId("replay-parity")).toHaveText("match"); + await shot(page, "desktop-19-inspector-session"); + }); + + test("meets the keyboard contract", async ({ page }) => { + await openConsole(page); + await runManifest(page, "steering"); + await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 }); + + // K2: Escape moves focus to Stop and never interrupts directly. + await page.getByTestId("composer-input").focus(); + await page.keyboard.press("Escape"); + await expect(page.getByTestId("composer-stop")).toBeFocused(); + await expect(page.getByTestId("turn-interrupted")).toHaveCount(0); + + // K2: Enter steers the running turn. + await page.getByTestId("composer-input").fill("Steered with the keyboard."); + await page.getByTestId("composer-input").press("Enter"); + await expect(page.getByTestId("steering-chip").first()).toContainText("acknowledged", { + timeout: 15_000, + }); + + // K7: inspector tabs follow the arrow-key tabs pattern. + await page.getByTestId("toggle-inspector").click(); + await page.getByRole("tab", { name: "Events" }).focus(); + await page.keyboard.press("ArrowRight"); + await expect(page.getByRole("tab", { name: "Requests" })).toBeFocused(); + await expect(page.getByRole("tab", { name: "Requests" })).toHaveAttribute( + "aria-selected", + "true", + ); + + // K5: lineage tree responds to arrow keys. + await page.getByTestId("lineage-tree").getByRole("treeitem").first().focus(); + await page.keyboard.press("ArrowDown"); + + // K6/A1: the transcript is a labelled log landmark. + await expect(page.getByTestId("live-transcript")).toHaveAttribute("role", "log"); + await expect(page.getByTestId("live-transcript")).toHaveAttribute("aria-live", "polite"); + }); + + test("resets demo state behind a naming confirmation", async ({ page }) => { + await openConsole(page); + await runManifest(page, "completion"); + await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 }); + + await page.getByRole("button", { name: "Reset demo state", exact: true }).first().click(); + await expect(page.getByRole("dialog")).toContainText( + "Recorded evidence files are not touched.", + ); + await page.getByTestId("reset-confirm").click(); + await expect(page.getByTestId("empty-transcript")).toBeVisible(); + }); +}); + +test.describe("Phase 4b live console on a small screen", () => { + test("keeps the transcript and composer usable at 390 by 844", async ({ page }) => { + await page.setViewportSize(MOBILE); + await openConsole(page); + await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible(); + await expect(page.getByTestId("manifest-list")).toHaveCount(0); + await shot(page, "mobile-01-idle-empty"); + + await page.getByRole("tab", { name: "Session" }).click(); + await expect(page.getByTestId("manifest-list")).toBeVisible(); + await page.getByTestId("manifest-approvals").click(); + await page.getByTestId("run-manifest").click(); + await shot(page, "mobile-02-session-segment"); + + await page.getByRole("tab", { name: "Chat" }).click(); + await expect(page.getByTestId("request-card").first()).toHaveAttribute( + "data-status", + "pending", + { timeout: 20_000 }, + ); + await shot(page, "mobile-03-request-pending"); + await page.getByTestId("request-action-accept").first().click(); + await expect(page.getByTestId("request-card").first()).toHaveAttribute( + "data-status", + "resolved", + { timeout: 15_000 }, + ); + + await page.getByRole("tab", { name: "Inspector" }).click(); + await expect(page.getByTestId("inspector-events")).toBeVisible(); + await shot(page, "mobile-04-inspector"); + + // WCAG 1.4.10 reflow: no horizontal scrolling of the page itself. + const overflow = await page.evaluate( + () => document.documentElement.scrollWidth - document.documentElement.clientWidth, + ); + expect(overflow).toBeLessThanOrEqual(1); + }); +}); diff --git a/packages/paperclip-runner/devtools/browser/playwright.config.ts b/packages/paperclip-runner/devtools/browser/playwright.config.ts index 0eb2bc3a5c6..217c591b688 100644 --- a/packages/paperclip-runner/devtools/browser/playwright.config.ts +++ b/packages/paperclip-runner/devtools/browser/playwright.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from "@playwright/test"; export default defineConfig({ testDir: ".", - testMatch: "static-replay.spec.ts", + testMatch: ["static-replay.spec.ts", "live-console.spec.ts"], fullyParallel: false, workers: 1, reporter: "line", @@ -13,6 +13,11 @@ export default defineConfig({ webServer: { command: "pnpm exec vite preview --config vite.config.ts --host 127.0.0.1 --port 4179", + env: { + // Keep streamed chunks slow enough that interrupt races are reachable + // from a test, and fast enough that the suite stays quick. + PAPERCLIP_PHASE4B_CHUNK_DELAY_MS: "60", + }, url: "http://127.0.0.1:4179", reuseExistingServer: false, timeout: 30_000, diff --git a/packages/paperclip-runner/devtools/browser/src/App.tsx b/packages/paperclip-runner/devtools/browser/src/App.tsx index c7d395043f4..695d93b022c 100644 --- a/packages/paperclip-runner/devtools/browser/src/App.tsx +++ b/packages/paperclip-runner/devtools/browser/src/App.tsx @@ -29,6 +29,7 @@ import { CardTitle, } from "./components/ui/card"; import { Textarea } from "./components/ui/textarea"; +import { LiveConsole } from "./live/LiveConsole"; const fixtureFiles = import.meta.glob( "../../../protocol/fixtures/phase-01/*.json", @@ -53,7 +54,7 @@ const liveScenarios: Array<{ key: Phase2Scenario; label: string }> = [ { key: "duplicate-terminal", label: "Duplicate terminal guard" }, ]; -type BrowserMode = "recovery" | "live" | "replay"; +type BrowserMode = "console" | "recovery" | "live" | "replay"; type LiveStatus = "idle" | "starting" | "running" | "terminal" | "error"; const phase3FaultLabels: Record = { @@ -74,6 +75,12 @@ const modeCopy: Record< BrowserMode, { eyebrow: string; title: string; description: string } > = { + console: { + eyebrow: "Paperclip Runner Protocol · Phase 4b", + title: "Live Codex protocol console", + description: + "Chat with a live session, steer it, stop it, answer its requests, and inspect the canonical protocol behind every surface.", + }, recovery: { eyebrow: "Paperclip Runner Protocol · Phase 3", title: "Durable recovery diagnostics", @@ -821,6 +828,8 @@ function StaticReplay() { } export function App() { + // Phase 1-3 surfaces keep their landing mode: their screenshots are frozen + // QA evidence and their specs open on the Phase 2 runner. const [mode, setMode] = useState("live"); const header = modeCopy[mode]; @@ -836,6 +845,13 @@ export function App() { - {mode === "recovery" ? : mode === "live" ? : } + {mode === "console" ? ( + + ) : mode === "recovery" ? ( + + ) : mode === "live" ? ( + + ) : ( + + )} ); } diff --git a/packages/paperclip-runner/devtools/browser/src/components/ui/badge.tsx b/packages/paperclip-runner/devtools/browser/src/components/ui/badge.tsx index 261ceb4dd0a..1b0593b3605 100644 --- a/packages/paperclip-runner/devtools/browser/src/components/ui/badge.tsx +++ b/packages/paperclip-runner/devtools/browser/src/components/ui/badge.tsx @@ -6,7 +6,7 @@ export function Badge({ tone = "neutral", ...props }: React.ComponentProps<"span"> & { - tone?: "neutral" | "success" | "warning" | "danger"; + tone?: "neutral" | "accent" | "success" | "warning" | "danger"; }) { return ( & { + tone?: "neutral" | "accent" | "warning" | "danger"; + assertive?: boolean; +}) { + return ( +
+ ); +} + +export function BannerText({ className = "", ...props }: React.ComponentProps<"p">) { + return

; +} + +export function BannerActions({ className = "", ...props }: React.ComponentProps<"div">) { + return ( +

+ ); +} diff --git a/packages/paperclip-runner/devtools/browser/src/components/ui/composer.tsx b/packages/paperclip-runner/devtools/browser/src/components/ui/composer.tsx new file mode 100644 index 00000000000..a9360b58b35 --- /dev/null +++ b/packages/paperclip-runner/devtools/browser/src/components/ui/composer.tsx @@ -0,0 +1,133 @@ +import * as React from "react"; + +import type { ComposerState } from "../../live/transcript-model"; +import { Button } from "./button"; + +const MAX_ROWS = 8; + +const PRIMARY_LABEL: Record = { + idle: "Send", + submitting: "Sending…", + "active-turn": "Steer", + interrupting: "Stopping…", + disconnected: "Send", + terminal: "Send", +}; + +const HINT: Record = { + idle: "Enter sends. Shift and Enter add a line.", + submitting: "Waiting for the turn to be accepted.", + "active-turn": "Enter steers the running turn. Escape moves focus to Stop.", + interrupting: "Stopping the turn. Waiting for the terminal turn event.", + disconnected: "The composer is disabled until the connection is restored.", + terminal: "This session is closed. Replay it or reset the demo state.", +}; + +/** + * Source-adapted AI Elements `PromptInput`. The textarea plus action-row + * anatomy and submit-on-Enter are kept; the Send/Steer/Stop tri-state comes + * from the runner protocol, and there are no attachments or model pickers. + */ +export function Composer({ + state, + value, + onValueChange, + onSubmit, + onStop, + disabledReason = null, + label = "Message", +}: { + state: ComposerState; + value: string; + onValueChange: (value: string) => void; + onSubmit: () => void; + onStop: () => void; + disabledReason?: string | null; + label?: string; +}) { + const textareaRef = React.useRef(null); + const stopRef = React.useRef(null); + const inputId = React.useId(); + const hintId = React.useId(); + const reasonId = React.useId(); + + const locked = + disabledReason !== null || + state === "submitting" || + state === "interrupting" || + state === "disconnected" || + state === "terminal"; + const canStop = state === "active-turn" || state === "submitting"; + + React.useEffect(() => { + const textarea = textareaRef.current; + if (textarea === null) return; + textarea.rows = 1; + const rows = Math.min(value.split("\n").length, MAX_ROWS); + textarea.rows = Math.max(rows, 2); + }, [value]); + + function onKeyDown(event: React.KeyboardEvent) { + if (event.key === "Enter" && !event.shiftKey) { + event.preventDefault(); + if (!locked && value.trim().length > 0) onSubmit(); + return; + } + // Escape never interrupts directly; it only moves focus to Stop. + if (event.key === "Escape" && canStop) { + event.preventDefault(); + stopRef.current?.focus(); + } + } + + return ( +
+ +