feat(runner): extract Phase 5 browser SDK
Co-Authored-By: Paperclip <noreply@paperclip.ing>
No files matched your search
@@ -1,3 +1,4 @@
|
||||
/dist-browser/
|
||||
/dist-phase5/
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
@@ -20,9 +20,12 @@ 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.
|
||||
replay, and a redacted protocol inspector. Phase 5 extracts that accepted
|
||||
transport, reducer projection, hook, components, styles, and reference console
|
||||
into versioned public subpaths, then proves the surface with a second minimal
|
||||
consumer.
|
||||
|
||||
## Phase 0–4b quick start
|
||||
## Phase 0–5 quick start
|
||||
|
||||
From the repository root:
|
||||
|
||||
@@ -67,6 +70,9 @@ pnpm --filter @paperclipai/paperclip-runner demo:phase4b -- --host 127.0.0.1 --p
|
||||
# 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
|
||||
|
||||
# Phase 5: open the public-SDK reference console and mini consumer.
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase5
|
||||
```
|
||||
|
||||
Phase 4b provider-backed routes are loopback-only and reject wildcard/LAN
|
||||
@@ -98,9 +104,13 @@ and JSON content; see the protocol-server tutorial for direct `curl` examples.
|
||||
| `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. |
|
||||
| `console:phase5` | Start the public-SDK reference console and mini consumer on `127.0.0.1:4181`. |
|
||||
| `test:phase5` | Run targeted browser-client, reducer-projection, and React component contract tests. |
|
||||
| `test:browser:phase5` | Exercise both consumers with the fake driver, keyboard/a11y checks, reconnect/replay, measurements, and screenshots. |
|
||||
| `record:phase5:codex` | Run both public consumers against a safe real Codex session and capture live screenshots. |
|
||||
| `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 4b acceptance sequence. |
|
||||
| `verify` | Run the complete deterministic Phase 0 through Phase 5 acceptance sequence. |
|
||||
| `verify:rootless` | Extract Debian/Ubuntu browser libraries without root, then run `verify`. |
|
||||
|
||||
## Navigate
|
||||
@@ -119,6 +129,9 @@ and JSON content; see the protocol-server tutorial for direct `curl` examples.
|
||||
- [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)
|
||||
- [Phase 5 SDK console tutorial](docs/tutorials/phase-05-sdk-console.md)
|
||||
- [Phase 5 browser SDK reference](docs/phase-05-sdk.md)
|
||||
- [Phase 5 component decision record](docs/design/phase-5-component-decisions.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)
|
||||
@@ -126,6 +139,6 @@ and JSON content; see the protocol-server tutorial for direct `curl` examples.
|
||||
- [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 and Phase 4b adds
|
||||
the package-local browser console over it. Production Paperclip integration and
|
||||
Phase 5 remain deferred.
|
||||
Phase 4 adds the package-local real-model reference driver, Phase 4b adds the
|
||||
package-local browser console, and Phase 5 extracts a reusable public SDK plus
|
||||
two standalone consumers. Production Paperclip integration remains deferred.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Phase 5 SDK Extraction Decision Record
|
||||
|
||||
Date: 2026-08-08
|
||||
Status: implemented from the approved [Phase 5 component plan](phase-5-component-plan.md)
|
||||
|
||||
## Outcome
|
||||
|
||||
The accepted Phase 4b transport and reducer contracts were copied into a
|
||||
versioned `0.1.0` public surface. The reference console and a deliberately
|
||||
small second consumer import only public package subpaths. Phase 4b and the
|
||||
Phase 1–3 surfaces remain the comparison baseline.
|
||||
|
||||
## Promoted contracts
|
||||
|
||||
The implementation promotes the framework-free client, `useRunnerConsole`,
|
||||
all approved primitives and protocol views, and `RunnerConsoleApp`. Reducer
|
||||
state and canonical events remain the only rendering authority. File, tool,
|
||||
plan, terminal, failure, request, goal, lineage, connection, and replay views
|
||||
do not maintain private protocol state.
|
||||
|
||||
The package exports `.`, `./browser`, `./react`, and `./styles.css`. React and
|
||||
React DOM are peers; the surface adds zero runtime dependencies.
|
||||
|
||||
## Extension surface
|
||||
|
||||
The implementation has exactly the five approved extension points:
|
||||
|
||||
1. item-body renderer;
|
||||
2. request-detail renderer;
|
||||
3. Composer leading and trailing slots;
|
||||
4. scoped `--pcr-*` token overrides;
|
||||
5. Fetch/EventSource/base-URL transport injection.
|
||||
|
||||
The mini consumer demonstrates all five. No new extension registry or
|
||||
headless contract was added during implementation.
|
||||
|
||||
## Kept out
|
||||
|
||||
Markdown `Response`, highlighted `CodeBlock`, a persistent Context meter, a
|
||||
command palette, headless distribution, dark mode, Tailwind/Radix, toasts,
|
||||
and transcript virtualization remain rejected for the reasons in the approved
|
||||
plan. Default text and `<pre>` output keeps protocol evidence inspectable;
|
||||
consumers can opt into rich item bodies through the one renderer contract.
|
||||
|
||||
## Implementation findings
|
||||
|
||||
- Event IDs must not be deduplicated by the hook. Exact duplicate delivery is
|
||||
reducer input, so the shared reducer remains the sole duplicate authority.
|
||||
- Node types are required only for browser-test and Vite configuration. They
|
||||
are not part of the browser runtime or public dependency surface.
|
||||
- Token enforcement must inspect CSS custom-property definitions as well as
|
||||
component usage. The `pcr-` namespace is enforced across SDK sources.
|
||||
- The reference console renders one React tree selected at the 900px
|
||||
breakpoint. Rendering two trees would duplicate landmarks, test IDs, and
|
||||
live regions.
|
||||
- A real provider may explicitly disable goals. The consumer gates buttons on
|
||||
that capability and preserves the upstream diagnostic instead of emulating
|
||||
a goal.
|
||||
- Real-provider steering timing is not a stable visual fixture. Fake-driver
|
||||
tests own race coverage; a safe real completion owns transport, identity,
|
||||
redaction, reconnect, and replay evidence.
|
||||
- Minimal hosts may lack Playwright shared libraries. The package rootless
|
||||
helper runs the same browser commands from a run-owned cache.
|
||||
|
||||
## Compatibility rule
|
||||
|
||||
`0.1.0` is the first frozen SDK surface. Any later removal or semantic change
|
||||
to an export, hook field, component prop, `data-slot`, extension point, or
|
||||
token needs a versioned compatibility decision. Additive protocol fields stay
|
||||
forward-compatible; the schema and shared reducer remain authoritative.
|
||||
@@ -12,6 +12,7 @@ Each tutorial starts with an explanation of what the phase is and what the phase
|
||||
- [Phase 4: run the skillless Codex driver](tutorials/phase-04-skillless-codex.md) — run a safe real-model task, inspect the exact context boundary, steer or interrupt it, and confirm one replay-safe result.
|
||||
- [Phase 4b: run the protocol demo server](tutorials/phase-04b-protocol-server.md) — exercise the server-only Codex boundary, canonical replay, typed controls, and reconnect with `curl`.
|
||||
- [Phase 4b: run the live Codex protocol console](tutorials/phase-04b-live-console.md) — chat with a live session in the browser, steer it, stop it three ways, answer its requests, change its goal, break its connection, and replay the record.
|
||||
- [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.
|
||||
- [Cumulative end-to-end tutorial](tutorials/end-to-end.md) — the shortest complete workflow available at the current phase.
|
||||
|
||||
## Reference
|
||||
@@ -23,12 +24,14 @@ Each tutorial starts with an explanation of what the phase is and what the phase
|
||||
- [Phase 4 skillless Codex driver](phase-04-skillless-codex-driver.md)
|
||||
- [Phase 4b protocol and demo server](phase-04b-protocol-server.md)
|
||||
- [Phase 4b live console](phase-04b-live-console.md)
|
||||
- [Phase 5 browser SDK and reference console](phase-05-sdk.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)
|
||||
- [Phase 4b component decision record (shadcn/ui, AI Elements)](design/phase-4b-component-decisions.md)
|
||||
- [Phase 5 component and SDK surface plan](design/phase-5-component-plan.md)
|
||||
- [Phase 5 SDK extraction decision record](design/phase-5-component-decisions.md)
|
||||
- [Package README](../README.md)
|
||||
|
||||
Phase 4b is implemented and accepted. Phase 5 (SDK extraction and reference
|
||||
console) has an approved UX component plan; implementation is pending.
|
||||
Phase 5 is implemented under this standalone package boundary. Production
|
||||
Paperclip UI integration remains deferred.
|
||||
@@ -0,0 +1,153 @@
|
||||
# Phase 5 Browser SDK and Reference Console
|
||||
|
||||
## Public package surface
|
||||
|
||||
Phase 5 freezes the first browser SDK as package version `0.1.0`.
|
||||
|
||||
| Import | Purpose |
|
||||
| --- | --- |
|
||||
| `@paperclipai/paperclip-runner` | Protocol, reducer, and existing package contracts |
|
||||
| `@paperclipai/paperclip-runner/browser` | Framework-free HTTP/SSE client, protocol types, transcript projection |
|
||||
| `@paperclipai/paperclip-runner/react` | Hook, reference console, and reusable React components |
|
||||
| `@paperclipai/paperclip-runner/styles.css` | Scoped light-theme token and component styles |
|
||||
|
||||
React and React DOM are peer dependencies. The extracted surface adds no
|
||||
runtime dependency. It is client-only: it uses `window`, `sessionStorage`,
|
||||
Fetch, and EventSource, so applications must mount it in the browser rather
|
||||
than render it on the server.
|
||||
|
||||
## Framework-free client
|
||||
|
||||
```ts
|
||||
import { createRunnerClient } from "@paperclipai/paperclip-runner/browser";
|
||||
|
||||
const client = createRunnerClient({ baseUrl: "/api/phase4b" });
|
||||
const manifests = await client.fetchManifests();
|
||||
```
|
||||
|
||||
`RunnerClient` exposes manifest listing; session create/read/close; event
|
||||
history and SSE streaming; turn start, steering, and interrupt; typed request
|
||||
resolution; goal operations; and reconnect. `RunnerClientError` preserves the
|
||||
HTTP status, stable error code, and redacted server message.
|
||||
|
||||
The protocol server remains authoritative. The browser client never creates a
|
||||
second event model, invents capabilities, changes run/session/provider
|
||||
identities, or removes duplicate events before the shared reducer sees them.
|
||||
|
||||
## React hook
|
||||
|
||||
```tsx
|
||||
import { useRunnerConsole } from "@paperclipai/paperclip-runner/react";
|
||||
|
||||
function Console() {
|
||||
const runner = useRunnerConsole({ baseUrl: "/api/phase4b" });
|
||||
return <p>{runner.connection}: {runner.replayParity ? "match" : "waiting"}</p>;
|
||||
}
|
||||
```
|
||||
|
||||
`useRunnerConsole` returns manifests and selection, canonical server state,
|
||||
the exact event list, reducer snapshot, transcript projection, connection and
|
||||
retry state, composer state, steering acknowledgements, a single polite
|
||||
announcement channel, errors, busy state, lineage selection, replay controls,
|
||||
and verbs for every supported mutation. Durable history is re-read before a
|
||||
reconnected stream opens. Replay uses the same reducer as live state.
|
||||
|
||||
## Components
|
||||
|
||||
The `./react` export includes:
|
||||
|
||||
- primitives: `Button`, `Badge`, `Card`, `Textarea`, `Tabs`, `Menu`, `Dialog`,
|
||||
`Tooltip`, and `Banner`;
|
||||
- protocol views: `Conversation`, `Message`, `ReasoningItem`, `ToolItem`,
|
||||
`RequestCard`, `SessionTimeline`, `Inspector`, `ReplayControls`, and
|
||||
`ConnectionBanner`;
|
||||
- composition: `Composer`, `RunnerConsoleApp`, and `useRunnerConsole`.
|
||||
|
||||
Components take reducer/protocol shapes. Every component owns a stable
|
||||
`data-slot` and `pcr-` class. Terminal, file, tool, plan, request, failure,
|
||||
lineage, goal, connection, and replay states come only from canonical events
|
||||
or public session state.
|
||||
|
||||
## Five extension points
|
||||
|
||||
These are the complete extension surface for `0.1.0`:
|
||||
|
||||
1. `Conversation`, `Message`, `ReasoningItem`, and `ToolItem` accept
|
||||
`renderItemBody(item)` for markdown, highlighting, or another item body.
|
||||
2. `RequestCard` accepts `renderRequestDetail(request)` for its detail region.
|
||||
3. `Composer` accepts `leadingActions` and `trailingActions`.
|
||||
4. Consumers may override `--pcr-*` properties under `.pcr-root`.
|
||||
5. `createRunnerClient` and `useRunnerConsole` accept `baseUrl`, `fetchImpl`,
|
||||
and `eventSourceFactory` transport injection.
|
||||
|
||||
There is no headless distribution, component registry, render-prop shell,
|
||||
dark theme, toast layer, or virtualized transcript in this version.
|
||||
|
||||
## Transport and credential boundary
|
||||
|
||||
```tsx
|
||||
const runner = useRunnerConsole({
|
||||
baseUrl: "/runner",
|
||||
fetchImpl: (input, init) => fetch(input, {
|
||||
...init,
|
||||
headers: { ...Object.fromEntries(new Headers(init?.headers)), "x-app-session": appSession },
|
||||
}),
|
||||
eventSourceFactory: (url) => new EventSource(url),
|
||||
});
|
||||
```
|
||||
|
||||
Transport injection is for browser-to-protocol-server authentication, proxies,
|
||||
and test doubles. Provider credentials stay on that server. Do not place a
|
||||
provider bearer token in component props, manifest text, events, diagnostics,
|
||||
browser storage, or an EventSource query string. The package demo fixes and
|
||||
validates its workspace server-side and redacts provider paths and credentials.
|
||||
|
||||
## Tokens and contrast
|
||||
|
||||
`styles.css` declares a light-only `color-scheme` and all visual values under
|
||||
`.pcr-root`. The shipped foreground/surface pairs are:
|
||||
|
||||
| Foreground | Surface | Contrast |
|
||||
| --- | --- | ---: |
|
||||
| `--pcr-foreground` | `--pcr-background` | 16.4:1 |
|
||||
| `--pcr-card-foreground` | `--pcr-card` | 17.6:1 |
|
||||
| `--pcr-primary-foreground` | `--pcr-primary` | 15.6:1 |
|
||||
| `--pcr-muted-foreground` | `--pcr-muted` | 5.0:1 |
|
||||
| `--pcr-success` | `--pcr-success-surface` | 6.5:1 |
|
||||
| `--pcr-warning` | `--pcr-warning-surface` | 5.4:1 |
|
||||
| `--pcr-danger` | `--pcr-danger-surface` | 6.2:1 |
|
||||
| `--pcr-accent` | `--pcr-accent-surface` | 5.5:1 |
|
||||
|
||||
When a consumer overrides either side of a pair, that consumer owns a new
|
||||
contrast check. Keep normal text at 4.5:1 or better. Motion respects
|
||||
`prefers-reduced-motion`. All mobile controls use the `--pcr-touch-target`
|
||||
minimum.
|
||||
|
||||
Minimum supported widths are 320px for `Conversation`, `Composer`, and
|
||||
`Inspector`, and 280px for `RequestCard`. `RunnerConsoleApp` renders one
|
||||
responsive tree: three panes above 900px and one selected pane below it.
|
||||
|
||||
## Keyboard and accessibility contract
|
||||
|
||||
- The transcript is a labelled `role="log"`; consumers render the hook's
|
||||
`announcement` once in an `aria-live="polite"` region.
|
||||
- Composer Enter sends or steers; Shift+Enter adds a line. Stop remains a
|
||||
separate button.
|
||||
- Tabs use arrow-key roving focus plus Home/End.
|
||||
- Menu supports Enter/Space/ArrowDown, arrows, Home/End, and Escape with focus
|
||||
restoration.
|
||||
- The native dialog traps focus, closes on Escape, and restores its trigger.
|
||||
- Reasoning and tool disclosures use native summary keyboard behavior.
|
||||
- Replay Left/Right steps; Space toggles play from the scrubber.
|
||||
- Status is always named in text, not communicated by color alone.
|
||||
- Informational banners do not steal focus. A blocking reconnect failure may.
|
||||
|
||||
## Reference applications
|
||||
|
||||
`examples/reference-console/` composes `RunnerConsoleApp` only from public
|
||||
exports. `examples/mini-consumer/` imports only the three public SDK subpaths
|
||||
and visibly exercises every extension point. Both run against the package fake
|
||||
driver or the real Codex driver through the same server-only adapter.
|
||||
|
||||
See the [hand-run tutorial](tutorials/phase-05-sdk-console.md) and the
|
||||
[implementation decision record](design/phase-5-component-decisions.md).
|
||||
@@ -3,7 +3,7 @@
|
||||
## What this tutorial is
|
||||
|
||||
This tutorial combines each implemented Native Runner phase into one procedure.
|
||||
It currently includes Phase 0 through the Phase 4b protocol/server layer.
|
||||
It currently includes Phase 0 through the Phase 5 browser SDK layer.
|
||||
|
||||
## What this tutorial proves
|
||||
|
||||
@@ -18,6 +18,8 @@ replay modes. It also includes the Rust outbound WebSocket client, durable
|
||||
outbox, package-local mock core, recovery CLI, and recovery browser view.
|
||||
The final phase adds a skillless task envelope, direct app-server driver,
|
||||
semantic completion tools, and the same reducer/replay proof used by fixtures.
|
||||
Phase 5 freezes that browser transport and reducer projection as public SDK
|
||||
subpaths, then proves them with a reference console and a second consumer.
|
||||
|
||||
## Current end-to-end path
|
||||
|
||||
@@ -54,6 +56,12 @@ semantic completion tools, and the same reducer/replay proof used by fixtures.
|
||||
is rejected, and reconnect keeps the same run and session identities.
|
||||
24. Open the [Phase 4b protocol/server journal](../../knowledge/journal/2026-08-08-phase-04b-protocol-server.md)
|
||||
and its linked deterministic and real-Codex evidence.
|
||||
25. Follow [Phase 5: Run the SDK Console and Mini Consumer](phase-05-sdk-console.md).
|
||||
26. Run the fake lifecycle in both consumers, then confirm the mini consumer
|
||||
reaches `Replay parity: match` after reconnect and replay.
|
||||
27. Run the safe real-Codex browser smoke and inspect the Phase 5 screenshots.
|
||||
28. Open the [Phase 5 journal](../../knowledge/journal/2026-08-08-phase-05-sdk.md)
|
||||
and its linked package-acceptance evidence.
|
||||
|
||||
The one-command form after installation is:
|
||||
|
||||
@@ -134,6 +142,14 @@ pnpm --filter @paperclipai/paperclip-runner verify:rootless
|
||||
transcript from the durable cursor;
|
||||
- no provider credential reaches the browser DOM, and adapted components add no
|
||||
new runtime dependency.
|
||||
- the browser and React contracts are versioned package subpaths with React as
|
||||
a peer and no new runtime dependency;
|
||||
- the reference console and mini consumer import public APIs only;
|
||||
- exactly five extension points cover item bodies, request details, Composer
|
||||
actions, token theming, and transport injection;
|
||||
- duplicate canonical events reach the shared reducer unchanged;
|
||||
- both consumers preserve identity through reconnect and reduce replay to the
|
||||
same final state.
|
||||
|
||||
## Step 6: Chat with a live session in the browser
|
||||
|
||||
@@ -147,5 +163,13 @@ state above from the eleven deterministic demo chats. Add
|
||||
`PAPERCLIP_PHASE4B_DRIVER=codex` to run the identical screens against a real
|
||||
Codex session.
|
||||
|
||||
Phase 5 remains gated on the complete Phase 4b review, QA, live-service,
|
||||
and human-checkpoint evidence.
|
||||
## Step 7: Run the reusable SDK consumers
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase5
|
||||
```
|
||||
|
||||
Open `http://127.0.0.1:4181/reference-console/` and
|
||||
`http://127.0.0.1:4181/mini-consumer/`. Follow the
|
||||
[Phase 5 tutorial](phase-05-sdk-console.md) for the deterministic lifecycle,
|
||||
real-Codex smoke, keyboard checks, and package acceptance command.
|
||||
@@ -0,0 +1,131 @@
|
||||
# Phase 5: Run the SDK Console and Mini Consumer
|
||||
|
||||
## What this phase is
|
||||
|
||||
Phase 5 makes the accepted browser tracer reusable. It has a browser client,
|
||||
a React hook, reusable components, one reference console, and one small second
|
||||
consumer.
|
||||
|
||||
The two apps use public package imports. They do not import the old demo UI.
|
||||
Provider credentials stay in the local server.
|
||||
|
||||
## What this phase proves
|
||||
|
||||
This tutorial proves that a second app can use the SDK without a private
|
||||
import. It also proves that live events and replay events reduce to the same
|
||||
state. You can run every hard state with the fake driver. You can then run a
|
||||
safe turn with your real local Codex login.
|
||||
|
||||
Run commands from the repository root.
|
||||
|
||||
## 1. Run the small SDK tests
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner test:phase5
|
||||
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
```
|
||||
|
||||
These tests check reducer parity, components, tokens, and package boundaries.
|
||||
|
||||
## 2. Open both apps with the fake driver
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner console:phase5
|
||||
```
|
||||
|
||||
Open these pages:
|
||||
|
||||
- `http://127.0.0.1:4181/reference-console/`
|
||||
- `http://127.0.0.1:4181/mini-consumer/`
|
||||
|
||||
The fake driver is the default. It does not need a provider login.
|
||||
|
||||
## 3. Check the reference console
|
||||
|
||||
Use the manifest list. Run these flows:
|
||||
|
||||
1. Run **Completion**. Read the streaming and terminal messages.
|
||||
2. Run **Same-turn steering**. Type a new message while the turn is active.
|
||||
The button says **Steer**. Check the acknowledgement.
|
||||
3. Run each interrupt flow. Confirm that the visible outcome names the race.
|
||||
4. Run **Command and file approvals**. Resolve one request. Confirm that the
|
||||
card locks after the first click and later shows the canonical result.
|
||||
5. Run **Goal lifecycle**. Set and clear the goal from the Goal menu.
|
||||
6. Press **Drop connection**. Watch reconnect and gap recovery use the same
|
||||
session.
|
||||
7. Enter replay. Step with Left and Right. Confirm the inspector says the
|
||||
replay matches live state.
|
||||
8. Run **Item and turn failure**. Confirm that the exact server diagnostic is
|
||||
part of the transcript.
|
||||
|
||||
Resize the browser to 390 by 844. The app must show one pane at a time. It must
|
||||
not create a second hidden transcript or a horizontal page scrollbar.
|
||||
|
||||
## 4. Check the mini consumer
|
||||
|
||||
The mini app is intentionally small. Run a turn, steer it, stop it, resolve a
|
||||
request, set and clear a goal, drop the connection, and enter replay.
|
||||
|
||||
Look for these proof points:
|
||||
|
||||
- **Mini renderer** shows the custom item-body renderer.
|
||||
- **Mini detail** shows the custom request-detail renderer.
|
||||
- Set goal and Clear goal use the two Composer slots.
|
||||
- The blue accent is a local `--pcr-*` token override.
|
||||
- Fetch and EventSource are injected by the app.
|
||||
- **Replay parity: match** appears after durable history is complete.
|
||||
|
||||
If the real driver says goals are unavailable, the two goal buttons stay
|
||||
disabled. This is correct. The SDK must not pretend the provider supports a
|
||||
feature.
|
||||
|
||||
## 5. Run the browser acceptance tests
|
||||
|
||||
On a machine with Playwright system libraries:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner test:browser:phase5
|
||||
```
|
||||
|
||||
On a minimal Debian or Ubuntu machine without root access:
|
||||
|
||||
```sh
|
||||
cd packages/paperclip-runner
|
||||
bash scripts/verify-rootless-linux.sh pnpm run test:browser:phase5
|
||||
```
|
||||
|
||||
This runs keyboard, accessibility, reconnect, gap-recovery, replay, fake
|
||||
driver, viewport measurement, and screenshot checks.
|
||||
|
||||
## 6. Run a real Codex session
|
||||
|
||||
Use a machine where the local Codex CLI is already authenticated.
|
||||
|
||||
```sh
|
||||
cd packages/paperclip-runner
|
||||
bash scripts/verify-rootless-linux.sh pnpm run record:phase5:codex
|
||||
```
|
||||
|
||||
The test runs a safe completion through both public consumers. It checks that
|
||||
the browser contains no bearer token or `auth.json` path. The mini consumer
|
||||
then drops and reconnects the transport, enters replay, and confirms parity.
|
||||
|
||||
Real provider timing is not used to prove every race. The deterministic fake
|
||||
driver proves steering, interrupt, requests, and goal branches. The real run
|
||||
proves the same public transport and identity boundary against Codex.
|
||||
|
||||
## 7. Run the package acceptance command
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner verify
|
||||
```
|
||||
|
||||
Use `verify:rootless` if the host needs package-local browser libraries.
|
||||
|
||||
## Expected result
|
||||
|
||||
All commands exit with status 0. The fake and real apps use public imports.
|
||||
Reconnect keeps run, session, and provider identity. Replay reports `match`.
|
||||
Provider credentials do not appear in browser state, DOM text, events, or
|
||||
diagnostics.
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Paperclip Runner mini consumer</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/mini-consumer/src/main.tsx"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,97 @@
|
||||
import * as React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import type {
|
||||
EventSourceLike,
|
||||
SessionItemSnapshot,
|
||||
} from "@paperclipai/paperclip-runner/browser";
|
||||
import {
|
||||
Composer,
|
||||
ConnectionBanner,
|
||||
Conversation,
|
||||
ReplayControls,
|
||||
RequestCard,
|
||||
useRunnerConsole,
|
||||
} from "@paperclipai/paperclip-runner/react";
|
||||
import "@paperclipai/paperclip-runner/styles.css";
|
||||
import "./mini.css";
|
||||
|
||||
const injectedFetch: typeof fetch = (input, init) => window.fetch(input, init);
|
||||
const injectedEventSource = (url: string): EventSourceLike => new window.EventSource(url);
|
||||
|
||||
function MiniConsumer() {
|
||||
// This pass-through wrapper is deliberate: the second consumer proves the
|
||||
// fifth extension point without adding provider credentials to component
|
||||
// props or browser state.
|
||||
const console_ = useRunnerConsole({
|
||||
baseUrl: "/api/phase4b",
|
||||
fetchImpl: injectedFetch,
|
||||
eventSourceFactory: injectedEventSource,
|
||||
});
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const requests = console_.transcript.filter((entry) => entry.kind === "request");
|
||||
const conversation = console_.transcript.filter((entry) => entry.kind !== "request");
|
||||
const customItemBody = (item: SessionItemSnapshot) => (
|
||||
<p className="pcr-mini-custom-body"><strong>Mini renderer:</strong> {item.text}</p>
|
||||
);
|
||||
|
||||
function submit() {
|
||||
const text = draft.trim();
|
||||
if (text.length === 0) return;
|
||||
setDraft("");
|
||||
if (console_.composer === "active-turn") void console_.steer(text);
|
||||
else void console_.send(text);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="pcr-root pcr-mini-theme" data-slot="mini-consumer" data-testid="mini-consumer">
|
||||
<header className="pcr-mini-header">
|
||||
<p className="pcr-eyebrow">Second public-API consumer</p>
|
||||
<h1>Runner mini consumer</h1>
|
||||
<span className="pcr-mini-theme-proof" data-testid="mini-token-override">Mini blue token theme</span>
|
||||
<select data-testid="mini-manifest" aria-label="Demo manifest" value={console_.selectedManifestId} onChange={(event) => console_.selectManifest(event.target.value)}>
|
||||
{console_.manifests.map((manifest) => <option key={manifest.id} value={manifest.id}>{manifest.name}</option>)}
|
||||
</select>
|
||||
<button data-testid="mini-start" type="button" onClick={() => { const manifest = console_.manifests.find((entry) => entry.id === console_.selectedManifestId); void console_.start({ manifestId: console_.selectedManifestId, message: manifest?.prompt ?? "Run the SDK flow." }); }}>Start selected flow</button>
|
||||
</header>
|
||||
<ConnectionBanner connection={console_.connection} reconnectAttempt={console_.reconnectAttempt} onRetry={console_.retryNow} />
|
||||
<Conversation entries={conversation} renderItemBody={customItemBody} />
|
||||
<ul className="pcr-mini-steering" aria-label="Steering acknowledgements">
|
||||
{console_.steeringChips.map((chip) => (
|
||||
<li key={chip.id} data-testid="mini-steering-chip" data-status={chip.status}>
|
||||
<strong>{chip.status}</strong> {chip.text}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<ol className="pcr-mini-requests" aria-label="Runtime requests">
|
||||
{requests.map((request) => (
|
||||
<RequestCard key={request.key} request={request} onResolve={(resolution) => void console_.resolve(request.requestId, request.turnId ?? "", resolution)} renderRequestDetail={(current) => <pre className="pcr-payload">Mini detail: {JSON.stringify(current.details, null, 2)}</pre>} />
|
||||
))}
|
||||
</ol>
|
||||
<Composer
|
||||
state={console_.composer}
|
||||
value={draft}
|
||||
onValueChange={setDraft}
|
||||
onSubmit={submit}
|
||||
onStop={() => void console_.interrupt()}
|
||||
leadingActions={<button data-testid="mini-goal-set" type="button" disabled={console_.state?.capabilities.goals !== true} onClick={() => void console_.goal("set", { objective: "Exercise the public SDK." })}>Set goal</button>}
|
||||
trailingActions={<button data-testid="mini-goal-clear" type="button" disabled={console_.state?.capabilities.goals !== true || console_.state.goal === null} onClick={() => void console_.goal("clear")}>Clear goal</button>}
|
||||
/>
|
||||
<nav className="pcr-mini-actions" aria-label="SDK lifecycle controls">
|
||||
<button data-testid="mini-drop-connection" type="button" onClick={console_.dropConnection}>Drop connection</button>
|
||||
<button data-testid="mini-recover-gap" type="button" onClick={console_.retryNow}>Recover gap</button>
|
||||
<button data-testid="mini-toggle-replay" type="button" disabled={console_.events.length === 0} onClick={console_.replay.active ? console_.replay.exit : console_.replay.enter}>{console_.replay.active ? "Exit replay" : "Enter replay"}</button>
|
||||
</nav>
|
||||
<p data-testid="mini-goal-state">
|
||||
Goal: {console_.state?.goal?.objective ?? "none"}
|
||||
</p>
|
||||
{console_.replay.active ? <ReplayControls replay={console_.replay} /> : null}
|
||||
<p className="pcr-mini-parity" data-testid="mini-replay-parity">Replay parity: {console_.replayParity === null ? "waiting" : console_.replayParity ? "match" : "mismatch"}</p>
|
||||
<p className="pcr-visually-hidden" aria-live="polite" data-testid="mini-announcement">{console_.announcement}</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root === null) throw new Error("Mini consumer root is missing.");
|
||||
createRoot(root).render(<React.StrictMode><MiniConsumer /></React.StrictMode>);
|
||||
@@ -0,0 +1,70 @@
|
||||
.pcr-mini-theme {
|
||||
--pcr-accent: oklch(0.52 0.14 250);
|
||||
--pcr-accent-foreground: oklch(0.99 0 0);
|
||||
max-width: 48rem;
|
||||
min-height: 100vh;
|
||||
margin: 0 auto;
|
||||
padding: var(--pcr-space-5);
|
||||
}
|
||||
|
||||
.pcr-mini-header,
|
||||
.pcr-mini-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: var(--pcr-space-3);
|
||||
}
|
||||
|
||||
.pcr-mini-header button,
|
||||
.pcr-mini-actions button,
|
||||
.pcr-mini-theme button {
|
||||
min-height: var(--pcr-touch-target);
|
||||
padding: var(--pcr-space-2) var(--pcr-space-3);
|
||||
border: 0.0625rem solid var(--pcr-border);
|
||||
border-radius: var(--pcr-radius-sm);
|
||||
background: var(--pcr-card);
|
||||
color: var(--pcr-foreground);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pcr-mini-theme button:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.pcr-mini-custom-body {
|
||||
color: var(--pcr-accent);
|
||||
}
|
||||
|
||||
.pcr-mini-theme-proof {
|
||||
padding: var(--pcr-space-2) var(--pcr-space-3);
|
||||
border-radius: var(--pcr-radius-sm);
|
||||
background: var(--pcr-accent);
|
||||
color: var(--pcr-accent-foreground);
|
||||
font-weight: var(--pcr-weight-semibold);
|
||||
}
|
||||
|
||||
.pcr-mini-requests {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-3);
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pcr-mini-steering {
|
||||
display: grid;
|
||||
gap: var(--pcr-space-2);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.pcr-mini-steering li {
|
||||
padding: var(--pcr-space-2);
|
||||
border: 0.0625rem solid var(--pcr-border);
|
||||
border-radius: var(--pcr-radius-sm);
|
||||
}
|
||||
|
||||
.pcr-mini-parity {
|
||||
font-family: var(--pcr-font-mono);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
const evidenceRoot = resolve(
|
||||
fileURLToPath(new URL("../knowledge/evidence/phase-05", import.meta.url)),
|
||||
);
|
||||
mkdirSync(evidenceRoot, { recursive: true });
|
||||
|
||||
test("runs the reference console through the real Codex driver", async ({ page }) => {
|
||||
await page.goto("/reference-console/");
|
||||
await expect(page.getByTestId("manifest-completion")).toBeVisible();
|
||||
await page.getByTestId("manifest-completion").click();
|
||||
await page.getByTestId("run-manifest").click();
|
||||
await expect(page.locator("body")).toContainText("Turn completed", { timeout: 180_000 });
|
||||
await expect(page.getByText("Credentials in browser").locator("..")).toContainText("none");
|
||||
const body = await page.locator("body").innerText();
|
||||
expect(body).not.toContain("Bearer ");
|
||||
expect(body).not.toContain("auth.json");
|
||||
await page.screenshot({
|
||||
path: resolve(evidenceRoot, "reference-live-codex-1440x900.png"),
|
||||
});
|
||||
});
|
||||
|
||||
test("runs the mini consumer through real session, goal, reconnect, and replay paths", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/mini-consumer/");
|
||||
await expect(page.getByRole("heading", { name: "Runner mini consumer" })).toBeVisible();
|
||||
await page.getByLabel("Demo manifest").selectOption("completion");
|
||||
await page.getByRole("button", { name: "Start selected flow" }).click();
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 180_000 });
|
||||
|
||||
if (await page.getByTestId("mini-goal-set").isEnabled()) {
|
||||
await page.getByTestId("mini-goal-set").click();
|
||||
await expect(page.getByTestId("mini-goal-state")).toContainText(
|
||||
"Exercise the public SDK.",
|
||||
{ timeout: 30_000 },
|
||||
);
|
||||
await page.getByTestId("mini-goal-clear").click();
|
||||
await expect(page.getByTestId("mini-goal-state")).toContainText("none", {
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
await page.getByTestId("mini-drop-connection").click();
|
||||
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
|
||||
await expect(page.getByTestId("reconnect-banner")).toHaveCount(0, { timeout: 30_000 });
|
||||
await page.getByTestId("mini-toggle-replay").click();
|
||||
await expect(page.getByTestId("replay-controls")).toBeVisible();
|
||||
await page.getByTestId("replay-position").press("ArrowRight");
|
||||
await expect(page.getByTestId("mini-replay-parity")).toContainText("match");
|
||||
await page.screenshot({ path: resolve(evidenceRoot, "mini-live-codex-1440x900.png") });
|
||||
});
|
||||
@@ -0,0 +1,222 @@
|
||||
import { mkdirSync } from "node:fs";
|
||||
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-05", import.meta.url)));
|
||||
const DESKTOP = { width: 1440, height: 900 };
|
||||
const MOBILE = { width: 390, height: 844 };
|
||||
mkdirSync(evidenceRoot, { recursive: true });
|
||||
|
||||
async function screenshotPair(page: Page, name: string, mobileSegment = "chat") {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.screenshot({ path: resolve(evidenceRoot, `reference-${name}-1440x900.png`) });
|
||||
await page.setViewportSize(MOBILE);
|
||||
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
|
||||
const segment = page.getByRole("tab", { name: new RegExp(`^${mobileSegment}$`, "i") });
|
||||
await segment.click();
|
||||
await expect(page.locator('[data-slot="runner-console-app"]')).toBeVisible();
|
||||
await page.screenshot({ path: resolve(evidenceRoot, `reference-${name}-390x844.png`) });
|
||||
await page.setViewportSize(DESKTOP);
|
||||
}
|
||||
|
||||
async function miniScreenshotPair(page: Page, name: string) {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.screenshot({ path: resolve(evidenceRoot, `mini-${name}-1440x900.png`) });
|
||||
await page.setViewportSize(MOBILE);
|
||||
await page.screenshot({ path: resolve(evidenceRoot, `mini-${name}-390x844.png`) });
|
||||
await page.setViewportSize(DESKTOP);
|
||||
}
|
||||
|
||||
async function openReference(page: Page) {
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.goto("/reference-console/");
|
||||
await expect(page.getByRole("heading", { level: 1 })).toBeVisible();
|
||||
await expect(page.getByTestId("manifest-list")).toBeVisible();
|
||||
}
|
||||
|
||||
async function runManifest(page: Page, id: string) {
|
||||
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 5 reference console evidence", () => {
|
||||
test("captures idle and streaming states at both canonical viewports", async ({ page }) => {
|
||||
await openReference(page);
|
||||
await expect(page.getByTestId("empty-transcript")).toBeVisible();
|
||||
await screenshotPair(page, "idle-empty");
|
||||
|
||||
await runManifest(page, "completion");
|
||||
await expect(page.getByTestId("reasoning-item")).toBeVisible();
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
|
||||
await screenshotPair(page, "streaming-turn");
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
expect(await page.locator("body").innerText()).not.toContain("Bearer ");
|
||||
expect(await page.locator("body").innerText()).not.toContain("auth.json");
|
||||
});
|
||||
|
||||
test("captures steering and interrupt states", async ({ page }) => {
|
||||
await openReference(page);
|
||||
await runManifest(page, "steering");
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
|
||||
await page.getByTestId("composer-input").fill("Keep it short.");
|
||||
await page.getByTestId("composer-send").click();
|
||||
await expect(page.getByTestId("steering-chip")).toContainText("acknowledged", { timeout: 15_000 });
|
||||
await screenshotPair(page, "steering-acknowledged");
|
||||
|
||||
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
|
||||
await page.getByTestId("reset-confirm").click();
|
||||
await runManifest(page, "interrupt-during-generation");
|
||||
await expect(page.getByTestId("runner-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 screenshotPair(page, "interrupt");
|
||||
});
|
||||
|
||||
test("captures request, goal, reconnect, replay, and failure states", async ({ page }) => {
|
||||
await openReference(page);
|
||||
await runManifest(page, "approvals");
|
||||
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
|
||||
await screenshotPair(page, "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 screenshotPair(page, "request-resolved");
|
||||
|
||||
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
|
||||
await page.getByTestId("reset-confirm").click();
|
||||
await runManifest(page, "goals");
|
||||
await page.getByRole("button", { name: "Goal", exact: true }).click();
|
||||
await page.getByRole("menuitem", { name: "Set goal…" }).click();
|
||||
await page.getByTestId("goal-objective").fill("Prove the SDK lifecycle.");
|
||||
await page.getByTestId("goal-set-confirm").click();
|
||||
await expect(page.getByTestId("goal-banner")).toContainText("Prove the SDK lifecycle.");
|
||||
await screenshotPair(page, "goal-set", "session");
|
||||
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.getByTestId("drop-connection").click();
|
||||
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
|
||||
await screenshotPair(page, "reconnect-banner");
|
||||
await expect(page.getByTestId("connection-status")).toHaveText("connected", { timeout: 10_000 });
|
||||
|
||||
await page.getByTestId("toggle-replay").click();
|
||||
await expect(page.getByTestId("replay-controls")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Step forward" }).click();
|
||||
await screenshotPair(page, "replay-mode");
|
||||
|
||||
await page.setViewportSize(DESKTOP);
|
||||
await page.getByTestId("toggle-replay").click();
|
||||
await page.getByRole("button", { name: "Reset demo state", exact: true }).click();
|
||||
await page.getByTestId("reset-confirm").click();
|
||||
await runManifest(page, "failure");
|
||||
await expect(page.getByTestId("turn-failed")).toBeVisible({ timeout: 20_000 });
|
||||
await screenshotPair(page, "failed-turn");
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Phase 5 mini consumer evidence", () => {
|
||||
test("uses public render, token, request, reconnect, and replay extension points", async ({ page }) => {
|
||||
await page.goto("/mini-consumer/");
|
||||
await expect(page.getByRole("heading", { name: "Runner mini consumer" })).toBeVisible();
|
||||
await miniScreenshotPair(page, "idle");
|
||||
|
||||
await page.getByRole("button", { name: "Start selected flow" }).click();
|
||||
await expect(page.getByText("Mini renderer:").first()).toBeVisible({ timeout: 15_000 });
|
||||
await miniScreenshotPair(page, "streaming-custom-renderer-token");
|
||||
|
||||
await page.getByTestId("composer-input").fill("Keep the mini answer short.");
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Steer");
|
||||
await page.getByTestId("composer-send").click();
|
||||
await expect(page.getByTestId("mini-steering-chip")).toContainText("acknowledged", { timeout: 15_000 });
|
||||
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
await page.getByLabel("Demo manifest").selectOption("interrupt-during-generation");
|
||||
await page.getByRole("button", { name: "Start selected flow" }).click();
|
||||
await expect(page.getByTestId("composer-send")).toHaveText("Steer", { timeout: 15_000 });
|
||||
await page.getByTestId("composer-stop").click();
|
||||
await expect(page.getByTestId("turn-interrupted")).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByLabel("Demo manifest").selectOption("approvals");
|
||||
await page.getByRole("button", { name: "Start selected flow" }).click();
|
||||
await expect(page.getByTestId("request-card").first()).toHaveAttribute("data-status", "pending", { timeout: 15_000 });
|
||||
await miniScreenshotPair(page, "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.getByTestId("mini-goal-set").click();
|
||||
await expect(page.getByTestId("mini-goal-state")).toContainText("Exercise the public SDK.", { timeout: 15_000 });
|
||||
await page.getByTestId("mini-goal-clear").click();
|
||||
await expect(page.getByTestId("mini-goal-state")).toContainText("none", { timeout: 15_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Drop connection" }).click();
|
||||
await expect(page.getByTestId("reconnect-banner")).toBeVisible();
|
||||
await miniScreenshotPair(page, "reconnect");
|
||||
await expect(page.getByTestId("reconnect-banner")).toHaveCount(0, { timeout: 10_000 });
|
||||
|
||||
await page.getByRole("button", { name: "Enter replay" }).click();
|
||||
await expect(page.getByTestId("replay-controls")).toBeVisible();
|
||||
await page.getByTestId("replay-position").press("ArrowRight");
|
||||
await expect(page.getByTestId("mini-replay-parity")).toContainText("match");
|
||||
await miniScreenshotPair(page, "replay");
|
||||
});
|
||||
});
|
||||
|
||||
test("meets keyboard, accessibility, responsive, and grid contracts", async ({ page }) => {
|
||||
await openReference(page);
|
||||
await runManifest(page, "completion");
|
||||
await expect(page.getByTestId("turn-completed")).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await page.getByTestId("composer-input").fill("first");
|
||||
await page.getByTestId("composer-input").press("Shift+Enter");
|
||||
await expect(page.getByTestId("composer-input")).toHaveValue("first\n");
|
||||
|
||||
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();
|
||||
|
||||
const goalMenu = page.getByRole("button", { name: "Goal", exact: true });
|
||||
await goalMenu.focus();
|
||||
await page.keyboard.press("ArrowDown");
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
await expect(page.getByRole("menuitem").first()).toBeFocused();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(goalMenu).toBeFocused();
|
||||
await expect(page.getByRole("menu")).toHaveCount(0);
|
||||
|
||||
await goalMenu.press("ArrowDown");
|
||||
await expect(page.getByRole("menu")).toBeVisible();
|
||||
await expect(page.getByRole("menuitem").first()).toBeFocused();
|
||||
await page.getByRole("menuitem", { name: "Set goal…" }).click();
|
||||
await expect(page.getByRole("dialog", { name: "Set goal" })).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog", { name: "Set goal" })).not.toBeVisible();
|
||||
|
||||
await page.getByTestId("toggle-replay").click();
|
||||
await page.getByTestId("replay-position").focus();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(page.getByTestId("replay-position")).toHaveValue("1");
|
||||
await page.keyboard.press("Space");
|
||||
await expect(page.getByTestId("replay-controls").getByRole("button", { name: "Pause", exact: true })).toBeVisible();
|
||||
|
||||
expect(await page.getByRole("log").count()).toBe(1);
|
||||
expect(await page.locator('[aria-live="polite"]').count()).toBe(1);
|
||||
const unnamed = await page.locator("button").evaluateAll((buttons) => buttons.filter((button) => (button.getAttribute("aria-label") ?? button.textContent ?? "").trim().length === 0).length);
|
||||
expect(unnamed).toBe(0);
|
||||
|
||||
const boxes = await page.evaluate(() => {
|
||||
const root = document.querySelector<HTMLElement>('[data-slot="runner-console-app"]')!;
|
||||
const rail = document.querySelector<HTMLElement>(".pcr-console-rail")!;
|
||||
const center = document.querySelector<HTMLElement>(".pcr-console-center")!;
|
||||
return { root: root.getBoundingClientRect(), rail: rail.getBoundingClientRect(), center: center.getBoundingClientRect() };
|
||||
});
|
||||
expect(boxes.rail.right).toBeLessThanOrEqual(boxes.center.left);
|
||||
expect(boxes.center.right).toBeLessThanOrEqual(boxes.root.right);
|
||||
|
||||
await page.setViewportSize(MOBILE);
|
||||
await expect(page.getByRole("tab", { name: "Chat" })).toBeVisible();
|
||||
expect(await page.getByRole("log").count()).toBe(1);
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth);
|
||||
expect(overflow).toBeLessThanOrEqual(1);
|
||||
});
|
||||
@@ -0,0 +1,24 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "phase5.spec.ts",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 120_000,
|
||||
reporter: "line",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4192",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
webServer: {
|
||||
command: "pnpm exec vite --config vite.phase5.config.ts --host 127.0.0.1 --port 4192",
|
||||
env: {
|
||||
PAPERCLIP_PHASE4B_CHUNK_DELAY_MS: "250",
|
||||
PAPERCLIP_PHASE4B_MAX_SESSIONS: "64",
|
||||
},
|
||||
url: "http://127.0.0.1:4192/reference-console/",
|
||||
reuseExistingServer: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,29 @@
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
export default defineConfig({
|
||||
testDir: ".",
|
||||
testMatch: "phase5-real.spec.ts",
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
timeout: 240_000,
|
||||
reporter: "line",
|
||||
use: {
|
||||
baseURL: "http://127.0.0.1:4193",
|
||||
viewport: { width: 1440, height: 900 },
|
||||
},
|
||||
webServer: {
|
||||
command: "pnpm exec vite --config vite.phase5.config.ts --host 127.0.0.1 --port 4193",
|
||||
env: {
|
||||
PAPERCLIP_PHASE4B_DRIVER: "codex",
|
||||
PAPERCLIP_PHASE4B_MAX_SESSIONS: "8",
|
||||
// The browser middleware creates its disposable working directory below
|
||||
// this run-owned root. Tell the nested Codex process about that exact
|
||||
// assignment so its workspace-boundary guard remains fail closed.
|
||||
PAPERCLIP_WORKSPACE_CWD:
|
||||
process.env.PAPERCLIP_RUN_SCRATCH_DIR ?? process.cwd(),
|
||||
},
|
||||
url: "http://127.0.0.1:4193/reference-console/",
|
||||
reuseExistingServer: false,
|
||||
timeout: 30_000,
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Paperclip Runner reference console</title>
|
||||
</head>
|
||||
<body><div id="root"></div><script type="module" src="/reference-console/src/main.tsx"></script></body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
import * as React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { RunnerConsoleApp } from "@paperclipai/paperclip-runner/react";
|
||||
import "@paperclipai/paperclip-runner/styles.css";
|
||||
|
||||
const root = document.getElementById("root");
|
||||
if (root === null) throw new Error("Reference console root is missing.");
|
||||
createRoot(root).render(<React.StrictMode><RunnerConsoleApp /></React.StrictMode>);
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
type: Verification Evidence
|
||||
title: Phase 5 browser SDK verification
|
||||
description: Targeted and package-acceptance evidence for the versioned SDK, two public consumers, fake/real drivers, accessibility, reconnect, and replay.
|
||||
tags: [native-runner, phase-5, sdk, browser, react, codex, evidence]
|
||||
status: stable
|
||||
generated: { by: openai/codex-local, at: 2026-08-08T07:50:00Z }
|
||||
---
|
||||
|
||||
# Scope
|
||||
|
||||
This record covers Phase 5 under `packages/paperclip-runner/`: the versioned
|
||||
browser and React surface, scoped styles, reference console, mini consumer,
|
||||
package boundaries, fake and real drivers, documentation, and visual evidence.
|
||||
It does not change production `server/`, `ui/`, database, routes, persistence,
|
||||
or legacy adapters.
|
||||
|
||||
# Targeted tests
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner test:phase5
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:typescript
|
||||
pnpm --filter @paperclipai/paperclip-runner typecheck:browser
|
||||
pnpm --filter @paperclipai/paperclip-runner check:browser-tokens
|
||||
pnpm --filter @paperclipai/paperclip-runner check:forbidden-imports
|
||||
```
|
||||
|
||||
Results:
|
||||
|
||||
- Phase 5 Vitest: **3 files, 20 tests passed**.
|
||||
- TypeScript public and browser configurations: passed.
|
||||
- Generated protocol source check: passed.
|
||||
- SDK token gate: passed.
|
||||
- Package/deep-import boundary gate: passed.
|
||||
|
||||
Coverage includes framework-free client errors and injection, exact duplicate
|
||||
event delivery to the shared reducer, file/tool/plan projection, exact failure
|
||||
diagnostics, component `data-slot` contracts, request locking, and the five
|
||||
extension points.
|
||||
|
||||
# Fake-driver browser acceptance
|
||||
|
||||
```sh
|
||||
cd packages/paperclip-runner
|
||||
bash scripts/verify-rootless-linux.sh pnpm run test:browser:phase5
|
||||
```
|
||||
|
||||
Result: **5 Playwright tests passed** in 16.4 seconds after adding an explicit
|
||||
responsive-tree readiness check before mobile evidence capture.
|
||||
|
||||
The tests cover both public consumers, every required fake lifecycle state,
|
||||
Composer/menu/dialog/tabs/replay keyboard behavior, one polite live region,
|
||||
semantic control names, status text, reduced-motion-safe styles, exact mobile
|
||||
and desktop bounding-box measurements, reconnect and gap recovery, durable
|
||||
cursor replay, and live/replay reducer parity.
|
||||
|
||||
# Real Codex evidence
|
||||
|
||||
Direct server trace:
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner record:phase4b -- \
|
||||
knowledge/evidence/phase-05/phase5-real-codex-trace.json
|
||||
```
|
||||
|
||||
Result: passed. The trace proves exact file output, a validated semantic
|
||||
result, one terminal event, continuous source sequence, stable run/session and
|
||||
provider identity after reconnect, replay, explicit unsupported-goal state,
|
||||
and absent provider credentials and host paths.
|
||||
|
||||
Browser consumers:
|
||||
|
||||
```sh
|
||||
cd packages/paperclip-runner
|
||||
bash scripts/verify-rootless-linux.sh pnpm run record:phase5:codex
|
||||
```
|
||||
|
||||
Result: reference console passed and produced its live screenshot. The mini
|
||||
consumer was then rerun independently after replacing a timing-sensitive real
|
||||
steering visual with the safe completion manifest:
|
||||
|
||||
```sh
|
||||
bash scripts/verify-rootless-linux.sh pnpm exec playwright test \
|
||||
--config examples/playwright.real.config.ts --grep 'mini consumer'
|
||||
```
|
||||
|
||||
Result: **1 test passed** in 14.1 seconds. Codex explicitly reported goals as
|
||||
disabled; the consumer kept its goal controls disabled. The public consumer
|
||||
completed a safe real turn, reconnected the same session, entered replay, and
|
||||
reported parity. Provider credentials were not rendered.
|
||||
|
||||
# Screenshots
|
||||
|
||||
The [`phase-05/`](phase-05/) directory contains 33 files:
|
||||
|
||||
- Reference console at 1440x900 and 390x844: idle, streaming, steering,
|
||||
interrupt, pending and resolved requests, goal, reconnect, replay, and
|
||||
failure.
|
||||
- Mini consumer at both viewports: idle, custom renderer and token override,
|
||||
pending request with custom detail, reconnect, and replay.
|
||||
- Live Codex reference-console and mini-consumer captures at 1440x900.
|
||||
- The redacted real Codex trace described above.
|
||||
|
||||
Visual inspection covered a desktop idle surface, mobile pending request,
|
||||
custom renderer/token override, mini real replay, and reference real Codex
|
||||
completion. The mobile request controls fit the viewport; the live reference
|
||||
shows a completed terminal tool and passed observations.
|
||||
|
||||
# Package acceptance
|
||||
|
||||
```sh
|
||||
cd packages/paperclip-runner
|
||||
bash scripts/verify-rootless-linux.sh pnpm run verify
|
||||
```
|
||||
|
||||
Result: **passed with exit status 0**. Notable totals in the cumulative run:
|
||||
|
||||
- TypeScript: **18 files, 171 tests passed**;
|
||||
- Rust: **37 unit tests and 2 supervisor integration tests passed**;
|
||||
- boundary/recorder Node tests: **8 passed**;
|
||||
- Phase 5 targeted suite: **20 passed**;
|
||||
- accepted Phase 1–4b browser suite: **28 passed**;
|
||||
- Phase 5 browser suite: **5 passed**;
|
||||
- Phase 0 Rust/TypeScript and Phase 1 fixture/reducer parity: passed;
|
||||
- Phase 0 tracer, Phase 1 replay, Phase 2 happy path, and Phase 3 lost-ACK
|
||||
recovery: passed.
|
||||
|
||||
# Documentation
|
||||
|
||||
```sh
|
||||
pnpm --filter @paperclipai/paperclip-runner docs:validate
|
||||
```
|
||||
|
||||
Result: documentation links and OKF v0.2 validation pass.
|
||||
|
||||
# Known provider constraints
|
||||
|
||||
Real-provider timing is not the oracle for race screenshots. The deterministic
|
||||
driver owns steering, interrupt, request, and goal branches. Real Codex owns
|
||||
the provider authentication boundary, normalized identity, redaction, safe
|
||||
completion, reconnect, and replay. This preserves upstream terminal and
|
||||
capability truth instead of hiding a real failure or fabricating support.
|
||||
@@ -11,3 +11,4 @@
|
||||
* [Phase 4b protocol/server verification](2026-08-08-phase-04b-protocol-server-verification.md) - Browser-resolved requests, goals, lineage, controls, reconnect, redaction, and real Codex server evidence.
|
||||
* [Phase 4b live console verification](2026-08-08-phase-04b-live-console-verification.md) - Demo-driver, transcript-model, browser, keyboard, boundary, token, and screenshot evidence for the live console.
|
||||
* [Phase 4b QA verification](2026-08-08-phase-04b-qa-verification.md) - Independent QA pass: clean-start tutorial in a real browser, deterministic demos, real Codex end-to-end session, full `verify` acceptance (exit 0), and screenshots (PAP-16837).
|
||||
* [Phase 5 browser SDK verification](2026-08-08-phase-05-verification.md) - Targeted and package-acceptance evidence for the versioned SDK, both public consumers, fake/real drivers, accessibility, reconnect, replay, and screenshots.
|
||||
|
After Width: | Height: | Size: 41 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 35 KiB |
|
After Width: | Height: | Size: 68 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 55 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 93 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 33 KiB |
|
After Width: | Height: | Size: 96 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 101 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 78 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,83 @@
|
||||
---
|
||||
type: Engineering Journal Entry
|
||||
title: Phase 5 browser SDK and second consumer
|
||||
description: Versioned browser and React contracts, a reference console, a public-API-only mini consumer, and fake/real driver evidence.
|
||||
tags: [native-runner, phase-5, browser, sdk, react, replay, accessibility]
|
||||
status: stable
|
||||
generated: { by: openai/codex-local, at: 2026-08-08T07:45:00Z }
|
||||
entry_kind: phase
|
||||
phase: "5"
|
||||
---
|
||||
|
||||
# Context
|
||||
|
||||
The accepted Phase 4b console proved the protocol, interaction, and visual
|
||||
contract in one package-local application. Phase 5 needed to extract that
|
||||
surface without changing protocol authority or turning the accepted demo into
|
||||
a hidden dependency. A second consumer had to prove that the public API was
|
||||
sufficient.
|
||||
|
||||
# Decisions
|
||||
|
||||
1. Package version `0.1.0` freezes four public entries: `.`, `./browser`,
|
||||
`./react`, and `./styles.css`. React and React DOM are peers. No runtime
|
||||
dependency was added.
|
||||
2. The framework-free client owns HTTP/SSE transport only. Canonical events
|
||||
pass unchanged into the shared reducer; browser code does not manufacture
|
||||
identities, capabilities, request outcomes, or replay state.
|
||||
3. `useRunnerConsole` exposes the accepted hook state and verbs, restores the
|
||||
session from a durable server cursor, and uses the same reducer for live and
|
||||
replay projections.
|
||||
4. The component surface keeps exactly five extension points: item body,
|
||||
request detail, Composer action slots, scoped token overrides, and transport
|
||||
injection.
|
||||
5. `RunnerConsoleApp` reproduces the full accepted console. The mini consumer
|
||||
is a single-column tracer bullet that imports public subpaths only and
|
||||
visibly exercises all extension points.
|
||||
6. `.pcr-root` owns a light-only token sheet. One React tree is selected at
|
||||
900px; duplicate hidden mobile/desktop trees are forbidden.
|
||||
7. Deterministic fake-driver tests own timing-sensitive steering, interrupt,
|
||||
request, and goal branches. The real Codex smoke owns the provider boundary,
|
||||
stable identities, redaction, safe completion, reconnect, and replay.
|
||||
|
||||
Full contracts are in the [SDK reference](../../docs/phase-05-sdk.md) and
|
||||
[decision record](../../docs/design/phase-5-component-decisions.md).
|
||||
|
||||
# Failures and corrections
|
||||
|
||||
- The browser TypeScript configuration initially lacked Node types required by
|
||||
Vite and Playwright configuration files. Node types were added to that
|
||||
configuration only; they did not enter the browser runtime.
|
||||
- An early hook implementation removed repeated source event IDs. That hid
|
||||
reducer input and violated parity authority. The hook now preserves the exact
|
||||
stream and lets the shared reducer classify duplicates.
|
||||
- A real steering run produced an acknowledged steer and valid semantic result
|
||||
but terminalized as a provider failure. The real browser smoke now uses the
|
||||
safe completion manifest. Deterministic tests continue to prove all timing
|
||||
races without weakening terminal truth.
|
||||
- The real browser server first selected the repository worktree outside its
|
||||
assigned runtime workspace. The test config now gives Codex the run-owned
|
||||
workspace directory.
|
||||
- Minimal-host Chromium lacked shared libraries. The existing rootless helper
|
||||
was generalized to run a supplied package command against run-owned ARM64
|
||||
libraries.
|
||||
|
||||
# Evidence
|
||||
|
||||
- Targeted client/reducer/component tests and public-import boundary tests.
|
||||
- Browser flows at 1440x900 and 390x844, including keyboard, live-region,
|
||||
measurement, reconnect, gap recovery, and replay checks.
|
||||
- Fake-driver lifecycle screenshots plus live reference and mini Codex shots.
|
||||
- A direct real-driver trace proving exact file output, semantic result,
|
||||
terminal uniqueness, continuous sequence, stable identities, replay,
|
||||
explicit goal capability, and absent credentials/host paths.
|
||||
- Complete package acceptance results in
|
||||
[Phase 5 verification](../evidence/2026-08-08-phase-05-verification.md).
|
||||
|
||||
# Remaining questions
|
||||
|
||||
- Dark mode and transcript virtualization remain data-gated future decisions.
|
||||
- Production Paperclip UI integration is Phase 6 work and is outside this
|
||||
standalone package boundary.
|
||||
- An independent UX re-review owns final judgement of the attached canonical
|
||||
screenshots.
|
||||
@@ -12,3 +12,4 @@
|
||||
* [Phase 4b protocol and demo-server layer](2026-08-08-phase-04b-protocol-server.md) - Browser-resolved provider requests, control races, goals, lineage, replay, and server-only credentials.
|
||||
* [Phase 4b live console browser tracer](2026-08-08-phase-04b-live-console.md) - Reducer-only browser surfaces, adapted components with no new runtime dependencies, interrupt races, reconnect, replay, and the layout and stream failures found on the way.
|
||||
* [Phase 5 UX gate — reusable console and SDK component plan](2026-08-08-phase-05-ux-plan.md) - Frozen consumer-facing component contracts, five extension points, pcr- token rules, and the mini-consumer tracer flow for the SDK extraction.
|
||||
* [Phase 5 browser SDK and second consumer](2026-08-08-phase-05-sdk.md) - Versioned browser/React exports, the public reference console and mini consumer, exact reducer authority, and fake/real driver evidence.
|
||||
@@ -28,4 +28,7 @@
|
||||
a deterministic demo driver, eleven demo chats, adapted shadcn/AI Elements
|
||||
components with no new runtime dependencies, and a boundary check that keeps
|
||||
it that way.
|
||||
|
||||
* **Phase 5 browser SDK**: Extracted versioned browser, React, and scoped-style
|
||||
exports; added a public-API-only reference console and mini consumer; proved
|
||||
the five approved extension points, keyboard/accessibility contracts,
|
||||
reconnect and replay parity, package boundaries, and safe real-Codex use.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@paperclipai/paperclip-runner",
|
||||
"version": "0.0.0",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"license": "MIT",
|
||||
"type": "module",
|
||||
@@ -8,18 +8,29 @@
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
}
|
||||
},
|
||||
"./browser": {
|
||||
"types": "./dist/browser/index.d.ts",
|
||||
"import": "./dist/browser/index.js"
|
||||
},
|
||||
"./react": {
|
||||
"types": "./dist/react/index.d.ts",
|
||||
"import": "./dist/react/index.js"
|
||||
},
|
||||
"./styles.css": "./styles.css"
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"protocol",
|
||||
"styles.css",
|
||||
"README.md"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "pnpm run build:typescript && pnpm run build:rust && pnpm run build:browser",
|
||||
"build": "pnpm run build:typescript && pnpm run build:rust && pnpm run build:browser && pnpm run build:phase5",
|
||||
"build:typescript": "pnpm run generate:protocol-types && tsc -p tsconfig.json",
|
||||
"build:rust": "cargo build --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"build:browser": "vite build --config vite.config.ts",
|
||||
"build:phase5": "vite build --config vite.phase5.config.ts",
|
||||
"typecheck": "pnpm run typecheck:typescript && pnpm run typecheck:rust && pnpm run typecheck:browser",
|
||||
"typecheck:typescript": "pnpm run check:protocol-types && tsc -p tsconfig.json --noEmit",
|
||||
"typecheck:rust": "cargo check --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
@@ -28,6 +39,9 @@
|
||||
"test:typescript": "vitest run",
|
||||
"test:rust": "cargo test --manifest-path runner/Cargo.toml --locked --workspace",
|
||||
"test:browser": "pnpm run build:typescript && pnpm run build:phase2-binaries && pnpm run build:browser && playwright test --config devtools/browser/playwright.config.ts",
|
||||
"test:phase5": "vitest run src/browser/client.test.ts src/browser/transcript-model.test.ts src/react/public-contract.test.tsx",
|
||||
"test:browser:phase5": "pnpm run build:typescript && playwright test --config examples/playwright.config.ts",
|
||||
"record:phase5:codex": "pnpm run build:typescript && playwright test --config examples/playwright.real.config.ts",
|
||||
"build:phase2-binaries": "cargo build --manifest-path runner/Cargo.toml --locked --workspace --bins",
|
||||
"generate:protocol-types": "node scripts/generate-protocol-schema-module.mjs",
|
||||
"check:protocol-types": "node scripts/generate-protocol-schema-module.mjs --check",
|
||||
@@ -53,11 +67,16 @@
|
||||
"record:phase3": "pnpm run build:typescript && pnpm run build:phase2-binaries && node scripts/record-phase3-evidence.mjs",
|
||||
"record:phase3:lost-ack": "pnpm run trace:phase3 -- --fault lost-ack --output knowledge/evidence/phase-03-lost-ack.json",
|
||||
"console:phase4b": "pnpm run build:typescript && vite --config vite.config.ts --host 127.0.0.1 --port 4180",
|
||||
"console:phase5": "pnpm run build:typescript && vite --config vite.phase5.config.ts --host 127.0.0.1 --port 4181",
|
||||
"browser:dev": "pnpm run build:typescript && pnpm run build:phase2-binaries && vite --config vite.config.ts",
|
||||
"browser:preview": "vite preview --config vite.config.ts",
|
||||
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1 && pnpm run trace:phase2 -- --quiet && pnpm run trace:phase3",
|
||||
"verify": "pnpm run build && pnpm run typecheck && pnpm run check:phase1-goldens && pnpm run test && pnpm run test:phase5 && pnpm run check:browser-tokens && pnpm run test:browser && pnpm run test:browser:phase5 && pnpm run check:forbidden-imports && pnpm run docs:validate && pnpm run check:phase0-parity && pnpm run check:phase1-parity && pnpm run trace:phase0 && pnpm run replay:phase1 && pnpm run trace:phase2 -- --quiet && pnpm run trace:phase3",
|
||||
"verify:rootless": "bash scripts/verify-rootless-linux.sh"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18",
|
||||
"react-dom": ">=18"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.61.1",
|
||||
"@types/node": "^22.20.1",
|
||||
|
||||
@@ -3,7 +3,11 @@ import { dirname, extname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const sourceRoot = resolve(packageRoot, "devtools/browser/src");
|
||||
const sourceRoots = [
|
||||
resolve(packageRoot, "devtools/browser/src"),
|
||||
resolve(packageRoot, "src/react"),
|
||||
resolve(packageRoot, "examples"),
|
||||
];
|
||||
|
||||
async function sourceFiles(directory) {
|
||||
const entries = await readdir(directory, { withFileTypes: true });
|
||||
@@ -25,7 +29,7 @@ const componentRules = [
|
||||
];
|
||||
const violations = [];
|
||||
|
||||
for (const path of await sourceFiles(sourceRoot)) {
|
||||
for (const path of (await Promise.all(sourceRoots.map(sourceFiles))).flat()) {
|
||||
if (![".ts", ".tsx"].includes(extname(path))) {
|
||||
continue;
|
||||
}
|
||||
@@ -37,7 +41,14 @@ for (const path of await sourceFiles(sourceRoot)) {
|
||||
}
|
||||
}
|
||||
|
||||
const styles = await readFile(resolve(sourceRoot, "styles.css"), "utf8");
|
||||
for (const path of await sourceFiles(resolve(packageRoot, "src/react"))) {
|
||||
if (![".ts", ".tsx"].includes(extname(path))) continue;
|
||||
if (/\bui-[a-z-]+/.test(await readFile(path, "utf8"))) {
|
||||
violations.push(`${path.slice(packageRoot.length + 1)}: legacy ui- class name`);
|
||||
}
|
||||
}
|
||||
|
||||
const styles = await readFile(resolve(packageRoot, "devtools/browser/src/styles.css"), "utf8");
|
||||
for (const token of [
|
||||
"--background",
|
||||
"--foreground",
|
||||
@@ -57,6 +68,58 @@ for (const token of [
|
||||
}
|
||||
}
|
||||
|
||||
const sdkStyles = await readFile(resolve(packageRoot, "styles.css"), "utf8");
|
||||
for (const token of [
|
||||
"--pcr-background",
|
||||
"--pcr-foreground",
|
||||
"--pcr-card",
|
||||
"--pcr-primary",
|
||||
"--pcr-muted",
|
||||
"--pcr-border",
|
||||
"--pcr-ring",
|
||||
"--pcr-font-sans",
|
||||
"--pcr-font-mono",
|
||||
"--pcr-space-1",
|
||||
"--pcr-radius-sm",
|
||||
"--pcr-motion-fast",
|
||||
"--pcr-touch-target",
|
||||
]) {
|
||||
if (!sdkStyles.includes(`${token}:`)) {
|
||||
violations.push(`styles.css: missing ${token}`);
|
||||
}
|
||||
}
|
||||
if (!sdkStyles.startsWith(".pcr-root {")) {
|
||||
violations.push("styles.css: token sheet must be scoped to .pcr-root");
|
||||
}
|
||||
if (sdkStyles.includes(":root")) {
|
||||
violations.push("styles.css: SDK tokens may not leak onto :root");
|
||||
}
|
||||
if (sdkStyles.includes("\\n")) {
|
||||
violations.push("styles.css: contains a literal \\n escape sequence");
|
||||
}
|
||||
if (/--pcr---|\.pcr-[a-z-]+--pcr-/i.test(sdkStyles)) {
|
||||
violations.push("styles.css: malformed repeated pcr prefix");
|
||||
}
|
||||
const sdkTokenNames = new Set([
|
||||
...[...sdkStyles.matchAll(/--([a-z][a-z0-9-]*)\s*:/gi)].map((match) => match[1]),
|
||||
...[...sdkStyles.matchAll(/var\(\s*--([a-z][a-z0-9-]*)/gi)].map(
|
||||
(match) => match[1],
|
||||
),
|
||||
]);
|
||||
for (const tokenName of sdkTokenNames) {
|
||||
if (!tokenName.startsWith("pcr-")) {
|
||||
violations.push(`styles.css: unprefixed SDK token --${tokenName}`);
|
||||
}
|
||||
}
|
||||
const cssWithoutComments = sdkStyles.replaceAll(/\/\*[\s\S]*?\*\//g, "");
|
||||
const openBraces = [...cssWithoutComments].filter((character) => character === "{").length;
|
||||
const closeBraces = [...cssWithoutComments].filter((character) => character === "}").length;
|
||||
if (openBraces !== closeBraces) {
|
||||
violations.push(
|
||||
`styles.css: unbalanced braces (${openBraces} open, ${closeBraces} close)`,
|
||||
);
|
||||
}
|
||||
|
||||
if (violations.length > 0) {
|
||||
process.stderr.write(`Standalone browser token check failed:\n- ${violations.join("\n- ")}\n`);
|
||||
process.exitCode = 1;
|
||||
|
||||
@@ -48,3 +48,14 @@ test("a negative fixture proves that a browser UI runtime import is rejected", a
|
||||
assert.match(violations[0].reason, /adapts component source/);
|
||||
assert.ok(violations[0].file.startsWith(defaultPackageRoot));
|
||||
});
|
||||
|
||||
test("a negative fixture proves that an SDK consumer deep import is rejected", async () => {
|
||||
const violations = await checkForbiddenImports({
|
||||
scanRoots: ["test-fixtures/forbidden-sdk-consumer/examples"],
|
||||
cargoRoots: [],
|
||||
checkManifest: false,
|
||||
});
|
||||
|
||||
assert.equal(violations.length, 1);
|
||||
assert.match(violations[0].reason, /may not deep-import/);
|
||||
});
|
||||
@@ -80,12 +80,32 @@ function isForbiddenBrowserPackage(specifier) {
|
||||
}
|
||||
|
||||
function violationReason({ file, packageRoot, specifier }) {
|
||||
if (specifier.startsWith("@paperclipai/") && specifier !== "@paperclipai/paperclip-runner") {
|
||||
const relativeFile = relative(packageRoot, file).split(/[\\/]/).join("/");
|
||||
const isExampleConsumer = relativeFile.startsWith("examples/");
|
||||
const publicRunnerImports = new Set([
|
||||
"@paperclipai/paperclip-runner/browser",
|
||||
"@paperclipai/paperclip-runner/react",
|
||||
"@paperclipai/paperclip-runner/styles.css",
|
||||
]);
|
||||
if (
|
||||
specifier.startsWith("@paperclipai/paperclip-runner/") &&
|
||||
!publicRunnerImports.has(specifier)
|
||||
) {
|
||||
return "runner consumers may import only declared public subpaths";
|
||||
}
|
||||
if (isExampleConsumer && specifier === "@paperclipai/paperclip-runner") {
|
||||
return "runner consumers may import only browser, react, and styles.css public subpaths";
|
||||
}
|
||||
if (
|
||||
specifier.startsWith("@paperclipai/") &&
|
||||
specifier !== "@paperclipai/paperclip-runner" &&
|
||||
!publicRunnerImports.has(specifier)
|
||||
) {
|
||||
return "Paperclip workspace packages are outside the standalone boundary";
|
||||
}
|
||||
|
||||
if (
|
||||
relative(packageRoot, file).split(/[\\/]/).join("/").includes("devtools/browser/") &&
|
||||
["devtools/browser/", "src/react/", "examples/"].some((root) => relativeFile.includes(root)) &&
|
||||
isForbiddenBrowserPackage(specifier)
|
||||
) {
|
||||
return "the standalone browser app adapts component source instead of adopting its runtime";
|
||||
@@ -100,6 +120,17 @@ function violationReason({ file, packageRoot, specifier }) {
|
||||
if (!isInside(packageRoot, resolvedImport)) {
|
||||
return "relative imports may not escape packages/paperclip-runner";
|
||||
}
|
||||
const segments = relativeFile.split("/");
|
||||
const examplesIndex = segments.indexOf("examples");
|
||||
if (examplesIndex >= 0 && segments[examplesIndex + 1] !== undefined) {
|
||||
const consumerRoot = resolve(
|
||||
packageRoot,
|
||||
segments.slice(0, examplesIndex + 2).join("/"),
|
||||
);
|
||||
if (!isInside(consumerRoot, resolvedImport)) {
|
||||
return "example consumers may not deep-import package or demo internals";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
@@ -172,7 +203,7 @@ async function cargoManifestViolations(packageRoot, cargoRoots) {
|
||||
|
||||
export async function checkForbiddenImports({
|
||||
packageRoot = defaultPackageRoot,
|
||||
scanRoots = ["src", "scripts", "runner"],
|
||||
scanRoots = ["src", "scripts", "runner", "examples"],
|
||||
cargoRoots = ["runner"],
|
||||
checkManifest = true,
|
||||
} = {}) {
|
||||
|
||||
@@ -78,5 +78,9 @@ case "$debian_architecture" in
|
||||
esac
|
||||
|
||||
rootless_library_path="$rootless_deps_dir/usr/lib/$library_triplet:$rootless_deps_dir/lib/$library_triplet:$rootless_deps_dir/usr/lib"
|
||||
printf 'Running Phase 2 verification with rootless browser libraries from %s.\n' "$rootless_deps_dir"
|
||||
LD_LIBRARY_PATH="$rootless_library_path${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" pnpm run verify
|
||||
printf 'Running verification with rootless browser libraries from %s.\n' "$rootless_deps_dir"
|
||||
if [[ $# -gt 0 ]]; then
|
||||
LD_LIBRARY_PATH="$rootless_library_path${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" "$@"
|
||||
else
|
||||
LD_LIBRARY_PATH="$rootless_library_path${LD_LIBRARY_PATH:+:$LD_LIBRARY_PATH}" pnpm run verify
|
||||
fi
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import type { PrpEvent } from "../protocol/phase1-contract.js";
|
||||
import {
|
||||
createRunnerClient,
|
||||
RunnerClientError,
|
||||
type EventSourceLike,
|
||||
} from "./client.js";
|
||||
|
||||
function json(body: unknown, status = 200): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
describe("public runner browser client", () => {
|
||||
it("uses an injected fetch transport and normalizes the base URL", async () => {
|
||||
const calls: Array<{ input: string; init?: RequestInit }> = [];
|
||||
const fetchImpl = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
calls.push({ input: String(input), ...(init === undefined ? {} : { init }) });
|
||||
return json({ manifests: [{ id: "completion" }] });
|
||||
}) as typeof fetch;
|
||||
const client = createRunnerClient({ baseUrl: "https://runner.test/api/", fetchImpl });
|
||||
|
||||
await expect(client.fetchManifests()).resolves.toEqual([{ id: "completion" }]);
|
||||
expect(calls[0]?.input).toBe("https://runner.test/api/manifests");
|
||||
});
|
||||
|
||||
it("preserves structured status and code on server errors", async () => {
|
||||
const fetchImpl = vi.fn(async () => json({ error: "stale_turn", message: "Turn ended." }, 409)) as unknown as typeof fetch;
|
||||
const client = createRunnerClient({ fetchImpl });
|
||||
|
||||
await expect(client.startTurn("session", "hello")).rejects.toEqual(
|
||||
expect.objectContaining<Partial<RunnerClientError>>({
|
||||
name: "RunnerClientError",
|
||||
status: 409,
|
||||
code: "stale_turn",
|
||||
message: "Turn ended.",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("injects EventSource without changing canonical events or reconnect cursor", () => {
|
||||
let url = "";
|
||||
let closed = false;
|
||||
const source: EventSourceLike = {
|
||||
onopen: null,
|
||||
onmessage: null,
|
||||
onerror: null,
|
||||
close: () => { closed = true; },
|
||||
};
|
||||
const client = createRunnerClient({
|
||||
fetchImpl: vi.fn() as unknown as typeof fetch,
|
||||
eventSourceFactory: (nextUrl) => { url = nextUrl; return source; },
|
||||
});
|
||||
const event = {
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: "event-9",
|
||||
sourceKind: "harness",
|
||||
sourceInstanceId: "driver",
|
||||
sourceSeq: 9,
|
||||
emittedAt: "2026-08-08T00:00:00.000Z",
|
||||
eventType: "item.delta",
|
||||
payload: { text: "exact" },
|
||||
} as PrpEvent;
|
||||
const onEvent = vi.fn();
|
||||
const onError = vi.fn();
|
||||
const handle = client.openEventStream("session/id", 8, { onEvent, onError });
|
||||
|
||||
expect(url).toBe("/api/phase4b/sessions/session%2Fid/stream?after=8");
|
||||
source.onmessage?.({ data: JSON.stringify(event) } as MessageEvent<string>);
|
||||
expect(onEvent).toHaveBeenCalledWith(event);
|
||||
source.onmessage?.({ data: "not json" } as MessageEvent<string>);
|
||||
expect(onError).toHaveBeenCalledOnce();
|
||||
handle.close();
|
||||
expect(closed).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import type { PrpEvent } from "../protocol/phase1-contract.js";
|
||||
import type {
|
||||
GoalOperation,
|
||||
ManifestSummary,
|
||||
RunnerSessionState,
|
||||
} from "./protocol.js";
|
||||
|
||||
export interface RunnerClientErrorBody {
|
||||
error?: unknown;
|
||||
message?: unknown;
|
||||
}
|
||||
|
||||
export class RunnerClientError extends Error {
|
||||
readonly status: number;
|
||||
readonly code: string;
|
||||
|
||||
constructor(status: number, code: string, message: string) {
|
||||
super(message);
|
||||
this.name = "RunnerClientError";
|
||||
this.status = status;
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
export interface EventSourceLike {
|
||||
onopen: ((event: Event) => void) | null;
|
||||
onmessage: ((event: MessageEvent<string>) => void) | null;
|
||||
onerror: ((event: Event) => void) | null;
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export type EventSourceFactory = (url: string) => EventSourceLike;
|
||||
|
||||
export interface RunnerClientOptions {
|
||||
/** Defaults to the package-local reference protocol server. */
|
||||
baseUrl?: string;
|
||||
/** Inject a credential-aware fetch wrapper without exposing credentials to components. */
|
||||
fetchImpl?: typeof fetch;
|
||||
/** Inject an EventSource-compatible transport for auth, proxies, or tests. */
|
||||
eventSourceFactory?: EventSourceFactory;
|
||||
}
|
||||
|
||||
export interface EventStreamHandle {
|
||||
close(): void;
|
||||
}
|
||||
|
||||
export interface RunnerClient {
|
||||
fetchManifests(): Promise<ManifestSummary[]>;
|
||||
createSession(input: {
|
||||
manifest: string;
|
||||
objective: string;
|
||||
message?: string;
|
||||
startTurn?: boolean;
|
||||
}): Promise<RunnerSessionState>;
|
||||
readSession(sessionId: string): Promise<RunnerSessionState>;
|
||||
readEvents(
|
||||
sessionId: string,
|
||||
after?: number,
|
||||
): Promise<{ events: PrpEvent[]; cursor: number; replay: boolean }>;
|
||||
startTurn(sessionId: string, text: string): Promise<RunnerSessionState>;
|
||||
steerTurn(sessionId: string, turnId: string, text: string): Promise<RunnerSessionState>;
|
||||
interruptTurn(sessionId: string, turnId: string | null): Promise<RunnerSessionState>;
|
||||
resolveRequest(
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
turnId: string,
|
||||
resolution: Record<string, unknown>,
|
||||
): Promise<RunnerSessionState>;
|
||||
goalOperation(
|
||||
sessionId: string,
|
||||
operation: GoalOperation,
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<RunnerSessionState>;
|
||||
reconnectSession(sessionId: string): Promise<RunnerSessionState>;
|
||||
closeSession(sessionId: string): Promise<void>;
|
||||
openEventStream(
|
||||
sessionId: string,
|
||||
after: number,
|
||||
handlers: {
|
||||
onEvent: (event: PrpEvent) => void;
|
||||
onOpen?: () => void;
|
||||
onError?: () => void;
|
||||
},
|
||||
): EventStreamHandle;
|
||||
}
|
||||
|
||||
function normalizedBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.endsWith("/") ? baseUrl.slice(0, -1) : baseUrl;
|
||||
}
|
||||
|
||||
function nativeFetch(): typeof fetch {
|
||||
if (typeof globalThis.fetch !== "function") {
|
||||
throw new Error("A fetch implementation is required in this environment.");
|
||||
}
|
||||
return globalThis.fetch.bind(globalThis);
|
||||
}
|
||||
|
||||
function nativeEventSource(url: string): EventSourceLike {
|
||||
if (typeof globalThis.EventSource !== "function") {
|
||||
throw new Error("An EventSource factory is required in this environment.");
|
||||
}
|
||||
return new globalThis.EventSource(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the framework-free browser client. Provider credentials remain on
|
||||
* the protocol server; injected transports may carry browser-to-server auth
|
||||
* but that auth is never copied into public session state or component props.
|
||||
*/
|
||||
export function createRunnerClient(options: RunnerClientOptions = {}): RunnerClient {
|
||||
const baseUrl = normalizedBaseUrl(options.baseUrl ?? "/api/phase4b");
|
||||
const fetchImpl = options.fetchImpl ?? nativeFetch();
|
||||
const eventSourceFactory = options.eventSourceFactory ?? nativeEventSource;
|
||||
|
||||
async function send<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const headers = new Headers(init?.headers);
|
||||
if (init?.body !== undefined && !headers.has("content-type")) {
|
||||
headers.set("content-type", "application/json");
|
||||
}
|
||||
const response = await fetchImpl(`${baseUrl}${path}`, { ...init, headers });
|
||||
const text = await response.text();
|
||||
let body: RunnerClientErrorBody = {};
|
||||
if (text.length > 0) {
|
||||
try {
|
||||
body = JSON.parse(text) as RunnerClientErrorBody;
|
||||
} catch {
|
||||
if (!response.ok) {
|
||||
throw new RunnerClientError(response.status, "invalid_response", "The runner returned invalid JSON.");
|
||||
}
|
||||
throw new RunnerClientError(response.status, "invalid_response", "The runner returned invalid JSON.");
|
||||
}
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new RunnerClientError(
|
||||
response.status,
|
||||
typeof body.error === "string" ? body.error : "request_failed",
|
||||
typeof body.message === "string" ? body.message : `Request to ${path} failed.`,
|
||||
);
|
||||
}
|
||||
return body as T;
|
||||
}
|
||||
|
||||
return {
|
||||
async fetchManifests() {
|
||||
const body = await send<{ manifests: ManifestSummary[] }>("/manifests");
|
||||
return body.manifests;
|
||||
},
|
||||
createSession(input) {
|
||||
return send<RunnerSessionState>("/sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
},
|
||||
readSession(sessionId) {
|
||||
return send<RunnerSessionState>(`/sessions/${encodeURIComponent(sessionId)}`);
|
||||
},
|
||||
readEvents(sessionId, after = 0) {
|
||||
return send(`/sessions/${encodeURIComponent(sessionId)}/events?after=${after}`);
|
||||
},
|
||||
startTurn(sessionId, text) {
|
||||
return send<RunnerSessionState>(`/sessions/${encodeURIComponent(sessionId)}/turns`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ text }),
|
||||
});
|
||||
},
|
||||
steerTurn(sessionId, turnId, text) {
|
||||
return send<RunnerSessionState>(`/sessions/${encodeURIComponent(sessionId)}/steer`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ turnId, text }),
|
||||
});
|
||||
},
|
||||
interruptTurn(sessionId, turnId) {
|
||||
return send<RunnerSessionState>(`/sessions/${encodeURIComponent(sessionId)}/interrupt`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ reason: "browser_operator", ...(turnId === null ? {} : { turnId }) }),
|
||||
});
|
||||
},
|
||||
resolveRequest(sessionId, requestId, turnId, resolution) {
|
||||
return send<RunnerSessionState>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/requests/${encodeURIComponent(requestId)}/resolve`,
|
||||
{ method: "POST", body: JSON.stringify({ turnId, resolution }) },
|
||||
);
|
||||
},
|
||||
goalOperation(sessionId, operation, body = {}) {
|
||||
return send<RunnerSessionState>(
|
||||
`/sessions/${encodeURIComponent(sessionId)}/goal/${operation}`,
|
||||
{ method: "POST", body: JSON.stringify(body) },
|
||||
);
|
||||
},
|
||||
reconnectSession(sessionId) {
|
||||
return send<RunnerSessionState>(`/sessions/${encodeURIComponent(sessionId)}/reconnect`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
},
|
||||
async closeSession(sessionId) {
|
||||
await send(`/sessions/${encodeURIComponent(sessionId)}/close`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
});
|
||||
},
|
||||
openEventStream(sessionId, after, handlers) {
|
||||
const source = eventSourceFactory(
|
||||
`${baseUrl}/sessions/${encodeURIComponent(sessionId)}/stream?after=${after}`,
|
||||
);
|
||||
source.onopen = () => handlers.onOpen?.();
|
||||
source.onmessage = (message) => {
|
||||
try {
|
||||
handlers.onEvent(JSON.parse(message.data) as PrpEvent);
|
||||
} catch {
|
||||
handlers.onError?.();
|
||||
}
|
||||
};
|
||||
source.onerror = () => handlers.onError?.();
|
||||
return { close: () => source.close() };
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export * from "../protocol/phase1-contract.js";
|
||||
export * from "../reducer/session-reducer.js";
|
||||
export * from "./client.js";
|
||||
export * from "./protocol.js";
|
||||
export * from "./transcript-model.js";
|
||||
@@ -0,0 +1,114 @@
|
||||
import type { PrpEvent } from "../protocol/phase1-contract.js";
|
||||
import type { SessionSnapshot } from "../reducer/session-reducer.js";
|
||||
|
||||
/**
|
||||
* The browser only ever sees the demo server's public state and the canonical
|
||||
* PRP event stream. There is no second event model and no client-side session
|
||||
* cache: every surface reads these shapes or the reducer snapshot they carry.
|
||||
*/
|
||||
export interface RunnerCapabilities {
|
||||
resume: boolean;
|
||||
typedEvents: boolean;
|
||||
steering: boolean;
|
||||
interruption: boolean;
|
||||
structuredResult: boolean;
|
||||
read?: boolean;
|
||||
reconciliation?: boolean;
|
||||
usage?: boolean;
|
||||
runtimeRequestResolution?: boolean;
|
||||
goals?: boolean;
|
||||
threadLineage?: boolean;
|
||||
unsupported?: string[];
|
||||
}
|
||||
|
||||
export interface RunnerPendingRequest {
|
||||
requestId: string;
|
||||
requestKind: string;
|
||||
method: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
status: string;
|
||||
prompt: string;
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface RunnerGoal {
|
||||
threadId: string;
|
||||
objective: string;
|
||||
status: string;
|
||||
tokenBudget: number | null;
|
||||
tokensUsed: number;
|
||||
timeUsedSeconds: number;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface RunnerLineageEntry {
|
||||
threadId: string;
|
||||
providerSessionId: string | null;
|
||||
parentThreadId: string | null;
|
||||
depth: number;
|
||||
nickname: string | null;
|
||||
role: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface RunnerSessionState {
|
||||
sessionId: string;
|
||||
runId: string;
|
||||
normalizedSessionId: string;
|
||||
manifest: string | null;
|
||||
providerAuthentication: string;
|
||||
credentialsExposed: boolean;
|
||||
capabilities: RunnerCapabilities;
|
||||
driverSession: {
|
||||
driverSessionId: string;
|
||||
providerSessionId?: string | null;
|
||||
displayId?: string | null;
|
||||
};
|
||||
activeTurnId: string | null;
|
||||
pendingRequests: RunnerPendingRequest[];
|
||||
goal: RunnerGoal | null;
|
||||
lineage: RunnerLineageEntry[];
|
||||
cursor: number;
|
||||
snapshot: SessionSnapshot;
|
||||
}
|
||||
|
||||
export interface ManifestSummary {
|
||||
id: string;
|
||||
name: string;
|
||||
purpose: string;
|
||||
scenarios: string[];
|
||||
objective: string;
|
||||
prompt: string;
|
||||
expectedObservations: string[];
|
||||
}
|
||||
|
||||
export type ConnectionStatus =
|
||||
| "idle"
|
||||
| "connecting"
|
||||
| "connected"
|
||||
| "reconnecting"
|
||||
| "disconnected"
|
||||
| "terminal";
|
||||
|
||||
export type GoalOperation = "get" | "set" | "pause" | "resume" | "clear";
|
||||
|
||||
export type RunnerEventList = readonly PrpEvent[];
|
||||
|
||||
export const GOAL_OPERATIONS: readonly GoalOperation[] = [
|
||||
"set",
|
||||
"get",
|
||||
"pause",
|
||||
"resume",
|
||||
"clear",
|
||||
];
|
||||
|
||||
/** Human labels for the five fixed goal verbs (interaction map §5). */
|
||||
export const GOAL_OPERATION_LABELS: Record<GoalOperation, string> = {
|
||||
set: "Set goal…",
|
||||
get: "View",
|
||||
pause: "Pause",
|
||||
resume: "Resume",
|
||||
clear: "Clear",
|
||||
};
|
||||
@@ -0,0 +1,309 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { Phase4bScriptedDriver } from "../mock-core/phase4b-scripted-driver";
|
||||
import {
|
||||
parseHarnessRuntimeRequestResolution,
|
||||
type HarnessSession,
|
||||
} from "../contracts/harness-driver";
|
||||
import type { PrpEvent } from "../protocol/phase1-contract";
|
||||
import {
|
||||
applyPrpEvent,
|
||||
createSessionSnapshotFromMetadata,
|
||||
reduceSessionEvents,
|
||||
type SessionSnapshot,
|
||||
} from "../reducer/session-reducer";
|
||||
import type { RunnerCapabilities } from "./protocol.js";
|
||||
import {
|
||||
buildTranscript,
|
||||
capabilityRows,
|
||||
composerState,
|
||||
goalAvailability,
|
||||
runtimeRequestSubmission,
|
||||
transcriptRole,
|
||||
type TranscriptEntry,
|
||||
} from "./transcript-model.js";
|
||||
|
||||
const FULL: RunnerCapabilities = {
|
||||
resume: true,
|
||||
typedEvents: true,
|
||||
steering: true,
|
||||
interruption: true,
|
||||
structuredResult: true,
|
||||
runtimeRequestResolution: true,
|
||||
goals: true,
|
||||
threadLineage: true,
|
||||
};
|
||||
|
||||
function seed(runId: string, sessionId: string): SessionSnapshot {
|
||||
return createSessionSnapshotFromMetadata({
|
||||
fixtureName: "phase4b-demo",
|
||||
identity: {
|
||||
schema: "paperclip.prp.identity.v1",
|
||||
companyId: "package-local-demo",
|
||||
issueId: "phase4b-demo",
|
||||
runId,
|
||||
environmentLeaseId: "package-local",
|
||||
runnerInstanceId: "phase4b-demo-server",
|
||||
normalizedSessionId: sessionId,
|
||||
driverSessionId: `thread-${sessionId}`,
|
||||
},
|
||||
capabilities: {
|
||||
schema: "paperclip.prp.capabilities.v1",
|
||||
sessionReusePolicy: "reuse_per_issue",
|
||||
driver: { kind: "phase4b-demo", version: "1" },
|
||||
steer: true,
|
||||
interrupt: true,
|
||||
resume: true,
|
||||
runtimeRequests: true,
|
||||
structuredResult: true,
|
||||
typedEvents: true,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function drive(
|
||||
manifestId: string,
|
||||
interact?: (session: HarnessSession, turnId: string) => Promise<void>,
|
||||
): Promise<{ events: PrpEvent[]; snapshot: SessionSnapshot }> {
|
||||
const driver = new Phase4bScriptedDriver({ manifestId, chunkDelayMs: 0 });
|
||||
const session = await driver.openSession({
|
||||
runId: `run-${manifestId}`,
|
||||
normalizedSessionId: `session-${manifestId}`,
|
||||
workingDirectory: "/demo",
|
||||
});
|
||||
const events: PrpEvent[] = [];
|
||||
const pump = (async () => {
|
||||
for await (const event of session.events()) events.push(event);
|
||||
})();
|
||||
const { turnId } = await session.startTurn({ message: { role: "user", text: "Go." } });
|
||||
if (interact !== undefined) await interact(session, turnId);
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 60));
|
||||
await session.close({ reason: "test" });
|
||||
await pump;
|
||||
return {
|
||||
events,
|
||||
snapshot: events.reduce(applyPrpEvent, seed(`run-${manifestId}`, `session-${manifestId}`)),
|
||||
};
|
||||
}
|
||||
|
||||
function kinds(entries: readonly TranscriptEntry[]): string[] {
|
||||
return entries.map((entry) => (entry.kind === "item" ? `item:${entry.role}` : entry.kind));
|
||||
}
|
||||
|
||||
describe("transcriptRole", () => {
|
||||
it("maps canonical item kinds onto transcript roles", () => {
|
||||
expect(transcriptRole("reasoning")).toBe("reasoning");
|
||||
expect(transcriptRole("agentMessage")).toBe("assistant");
|
||||
expect(transcriptRole("commandExecution")).toBe("tool");
|
||||
expect(transcriptRole("fileChange")).toBe("tool");
|
||||
expect(transcriptRole("planUpdate")).toBe("tool");
|
||||
expect(transcriptRole("steering_acknowledgement")).toBe("steering");
|
||||
expect(transcriptRole("thread_lineage")).toBe("lineage");
|
||||
expect(transcriptRole("something_new")).toBe("system");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildTranscript", () => {
|
||||
it("orders entries by the reducer timeline and shows the submitted message", async () => {
|
||||
const { events, snapshot } = await drive("completion");
|
||||
const entries = buildTranscript(snapshot, events);
|
||||
expect(kinds(entries)).toEqual([
|
||||
"user",
|
||||
"item:reasoning",
|
||||
"item:tool",
|
||||
"item:assistant",
|
||||
"turn",
|
||||
]);
|
||||
const user = entries[0];
|
||||
expect(user.kind === "user" && user.text).toBe("Go.");
|
||||
const tool = entries[2];
|
||||
expect(tool.kind === "item" && tool.input).toBe("docs/phase-04b-protocol-server.md");
|
||||
expect(tool.kind === "item" && tool.output).toContain("server-sent events");
|
||||
});
|
||||
|
||||
it("marks an interrupted item and never claims it completed cleanly", async () => {
|
||||
const { events, snapshot } = await drive("interrupt-during-tool", async (session, turnId) => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
await session.interrupt?.({ turnId });
|
||||
});
|
||||
const entries = buildTranscript(snapshot, events);
|
||||
const tool = entries.find((entry) => entry.kind === "item" && entry.role === "tool");
|
||||
expect(tool?.kind === "item" && tool.interrupted).toBe(true);
|
||||
expect(tool?.kind === "item" && tool.output).toBeNull();
|
||||
const turn = entries.find((entry) => entry.kind === "turn");
|
||||
expect(turn?.kind === "turn" && turn.state).toBe("interrupted");
|
||||
});
|
||||
|
||||
it("keeps a resolved request in the record with the chosen action", async () => {
|
||||
const { events, snapshot } = await drive("approvals", async (session, turnId) => {
|
||||
await new Promise<void>((resolve) => setTimeout(resolve, 20));
|
||||
await session.resolveRuntimeRequest?.({
|
||||
requestId: "request-command",
|
||||
turnId,
|
||||
resolution: { action: "accept_for_session" },
|
||||
});
|
||||
});
|
||||
const request = buildTranscript(snapshot, events).find(
|
||||
(entry) => entry.kind === "request" && entry.requestId === "request-command",
|
||||
);
|
||||
expect(request?.kind === "request" && request.status).toBe("resolved");
|
||||
expect(request?.kind === "request" && request.resolvedAction).toBe("accept_for_session");
|
||||
expect(request?.kind === "request" && request.actions).toEqual([
|
||||
"accept",
|
||||
"accept_for_session",
|
||||
"decline",
|
||||
"cancel",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders a failed item with its exact diagnostic", async () => {
|
||||
const { events, snapshot } = await drive("failure");
|
||||
const failed = buildTranscript(snapshot, events).find(
|
||||
(entry) => entry.kind === "item" && entry.failure !== null,
|
||||
);
|
||||
expect(failed?.kind === "item" && failed.failure).toContain("cannot find module");
|
||||
});
|
||||
|
||||
it("keeps a replayed prefix a real prefix of the finished transcript", async () => {
|
||||
// Replay mode reduces events[0..n]. Stepping forward must only ever add
|
||||
// entries in the same positions the live session produced.
|
||||
const { events, snapshot } = await drive("completion");
|
||||
const full = buildTranscript(snapshot, events);
|
||||
|
||||
for (let cut = 1; cut <= events.length; cut += 1) {
|
||||
const prefix = events.slice(0, cut);
|
||||
const stepped = buildTranscript(
|
||||
prefix.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
|
||||
prefix,
|
||||
);
|
||||
expect(stepped.length).toBeLessThanOrEqual(full.length);
|
||||
stepped.forEach((entry, index) => {
|
||||
expect(entry.key).toBe(full[index]?.key);
|
||||
expect(entry.position).toBe(full[index]?.position);
|
||||
});
|
||||
}
|
||||
expect(
|
||||
buildTranscript(
|
||||
events.reduce(applyPrpEvent, seed("run-completion", "session-completion")),
|
||||
events,
|
||||
),
|
||||
).toEqual(full);
|
||||
});
|
||||
|
||||
it("keeps deliberate duplicate ids in reducer parity evidence", async () => {
|
||||
const { events } = await drive("completion");
|
||||
const duplicate = events.find((event) => event.eventType === "item.delta");
|
||||
expect(duplicate).toBeDefined();
|
||||
const replay = duplicate === undefined ? events : [...events, duplicate];
|
||||
const snapshot = reduceSessionEvents(
|
||||
seed("run-completion", "session-completion"),
|
||||
replay,
|
||||
);
|
||||
expect(snapshot.duplicateEventIds).toContain(duplicate?.sourceEventId);
|
||||
expect(snapshot.integrity).toBe("complete");
|
||||
});
|
||||
});
|
||||
|
||||
describe("composerState", () => {
|
||||
const base = {
|
||||
connection: "connected" as const,
|
||||
submitting: false,
|
||||
interrupting: false,
|
||||
activeTurnId: null,
|
||||
sessionState: "running" as const,
|
||||
};
|
||||
|
||||
it("shows exactly one state per protocol situation", () => {
|
||||
expect(composerState(base)).toBe("idle");
|
||||
expect(composerState({ ...base, submitting: true })).toBe("submitting");
|
||||
expect(composerState({ ...base, activeTurnId: "turn-1" })).toBe("active-turn");
|
||||
expect(composerState({ ...base, interrupting: true, activeTurnId: "turn-1" })).toBe(
|
||||
"interrupting",
|
||||
);
|
||||
expect(composerState({ ...base, connection: "disconnected" })).toBe("disconnected");
|
||||
expect(composerState({ ...base, sessionState: "closed" })).toBe("terminal");
|
||||
});
|
||||
|
||||
it("puts a terminal session ahead of every other state", () => {
|
||||
expect(
|
||||
composerState({
|
||||
...base,
|
||||
sessionState: "failed",
|
||||
activeTurnId: "turn-1",
|
||||
connection: "disconnected",
|
||||
}),
|
||||
).toBe("terminal");
|
||||
});
|
||||
});
|
||||
|
||||
describe("goalAvailability", () => {
|
||||
it("disables every verb with the exact diagnostic when goals are unsupported", () => {
|
||||
const rows = goalAvailability(
|
||||
{
|
||||
...FULL,
|
||||
goals: false,
|
||||
unsupported: ["Goal operations unsupported: app-server 0.132.0 does not advertise goals"],
|
||||
},
|
||||
null,
|
||||
);
|
||||
expect(rows).toHaveLength(5);
|
||||
expect(rows.every((row) => !row.enabled)).toBe(true);
|
||||
expect(rows[0]?.reason).toContain("does not advertise goals");
|
||||
});
|
||||
|
||||
it("disables only the verbs that are invalid for the current goal state", () => {
|
||||
const active = goalAvailability(FULL, "active");
|
||||
expect(active.find((row) => row.operation === "pause")?.enabled).toBe(true);
|
||||
expect(active.find((row) => row.operation === "resume")?.enabled).toBe(false);
|
||||
|
||||
const paused = goalAvailability(FULL, "paused");
|
||||
expect(paused.find((row) => row.operation === "resume")?.enabled).toBe(true);
|
||||
expect(paused.find((row) => row.operation === "pause")?.enabled).toBe(false);
|
||||
|
||||
const none = goalAvailability(FULL, null);
|
||||
expect(none.find((row) => row.operation === "set")?.enabled).toBe(true);
|
||||
expect(none.find((row) => row.operation === "clear")?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtimeRequestSubmission", () => {
|
||||
it("shapes each submit body the way its request kind is validated", async () => {
|
||||
expect(runtimeRequestSubmission("user_input", "staging")).toEqual({
|
||||
action: "submit",
|
||||
answers: { answer: { answers: ["staging"] } },
|
||||
});
|
||||
expect(runtimeRequestSubmission("elicitation", "stable")).toEqual({
|
||||
action: "submit",
|
||||
content: { answer: "stable" },
|
||||
});
|
||||
|
||||
// The driver is the authority on shape, so both bodies are checked against
|
||||
// the real validator rather than against this test's expectations alone.
|
||||
for (const [requestKind, answer] of [
|
||||
["user_input", "staging"],
|
||||
["elicitation", "stable"],
|
||||
] as const) {
|
||||
expect(() =>
|
||||
parseHarnessRuntimeRequestResolution(
|
||||
requestKind,
|
||||
runtimeRequestSubmission(requestKind, answer),
|
||||
)).not.toThrow();
|
||||
// ...and the other kind's body is rejected, which is what the browser
|
||||
// used to send for elicitation.
|
||||
const wrongKind = requestKind === "user_input" ? "elicitation" : "user_input";
|
||||
expect(() =>
|
||||
parseHarnessRuntimeRequestResolution(
|
||||
requestKind,
|
||||
runtimeRequestSubmission(wrongKind, answer),
|
||||
)).toThrow(/rejected its resolution/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("capabilityRows", () => {
|
||||
it("gives every unsupported capability a diagnostic string", () => {
|
||||
const rows = capabilityRows({ ...FULL, goals: false, usage: false });
|
||||
expect(rows.filter((row) => !row.supported).every((row) => row.diagnostic !== null)).toBe(true);
|
||||
expect(rows.find((row) => row.name === "steer")?.supported).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,404 @@
|
||||
import type { PrpEvent } from "../protocol/phase1-contract.js";
|
||||
import type {
|
||||
SessionItemSnapshot,
|
||||
SessionSnapshot,
|
||||
} from "../reducer/session-reducer.js";
|
||||
import type { GoalOperation, RunnerCapabilities } from "./protocol.js";
|
||||
|
||||
/**
|
||||
* A projection of reducer state plus the canonical events that produced it.
|
||||
* It never stores anything the protocol did not send, so a replayed session
|
||||
* and a live session with the same events project identically.
|
||||
*/
|
||||
export type TranscriptRole =
|
||||
| "user"
|
||||
| "assistant"
|
||||
| "reasoning"
|
||||
| "tool"
|
||||
| "steering"
|
||||
| "interrupt"
|
||||
| "goal"
|
||||
| "lineage"
|
||||
| "system";
|
||||
|
||||
export interface TranscriptItemEntry {
|
||||
kind: "item";
|
||||
key: string;
|
||||
position: number;
|
||||
role: TranscriptRole;
|
||||
item: SessionItemSnapshot;
|
||||
interrupted: boolean;
|
||||
streaming: boolean;
|
||||
toolName: string | null;
|
||||
input: string | null;
|
||||
output: string | null;
|
||||
failure: string | null;
|
||||
}
|
||||
|
||||
export interface TranscriptUserEntry {
|
||||
kind: "user";
|
||||
key: string;
|
||||
position: number;
|
||||
turnId: string | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface TranscriptRequestEntry {
|
||||
kind: "request";
|
||||
key: string;
|
||||
position: number;
|
||||
requestId: string;
|
||||
requestKind: string;
|
||||
type: string;
|
||||
prompt: string;
|
||||
status: "pending" | "resolved" | "expired" | "cancelled";
|
||||
actions: string[];
|
||||
details: Record<string, unknown>;
|
||||
resolvedAction: string | null;
|
||||
resolvedAt: string | null;
|
||||
turnId: string | null;
|
||||
}
|
||||
|
||||
export interface TranscriptTurnEntry {
|
||||
kind: "turn";
|
||||
key: string;
|
||||
position: number;
|
||||
turnId: string | null;
|
||||
state: "completed" | "failed" | "interrupted" | "cancelled";
|
||||
detail: string;
|
||||
}
|
||||
|
||||
export interface TranscriptDiagnosticEntry {
|
||||
kind: "diagnostic";
|
||||
key: string;
|
||||
position: number;
|
||||
message: string;
|
||||
code: string | null;
|
||||
}
|
||||
|
||||
export type TranscriptEntry =
|
||||
| TranscriptItemEntry
|
||||
| TranscriptUserEntry
|
||||
| TranscriptRequestEntry
|
||||
| TranscriptTurnEntry
|
||||
| TranscriptDiagnosticEntry;
|
||||
|
||||
const TOOL_KINDS = new Set([
|
||||
"commandExecution",
|
||||
"command_execution",
|
||||
"fileChange",
|
||||
"file_change",
|
||||
"mcpToolCall",
|
||||
"tool",
|
||||
"webSearch",
|
||||
"patchApply",
|
||||
"plan",
|
||||
"planUpdate",
|
||||
"update_plan",
|
||||
]);
|
||||
|
||||
const ASSISTANT_KINDS = new Set(["agentMessage", "assistant", "assistantMessage", "message"]);
|
||||
|
||||
export function transcriptRole(kind: string): TranscriptRole {
|
||||
if (kind === "reasoning") return "reasoning";
|
||||
if (ASSISTANT_KINDS.has(kind)) return "assistant";
|
||||
if (TOOL_KINDS.has(kind)) return "tool";
|
||||
if (kind === "steering_acknowledgement") return "steering";
|
||||
if (kind === "interrupt_acknowledgement") return "interrupt";
|
||||
if (kind === "goal" || kind === "goal_cleared") return "goal";
|
||||
if (kind === "thread_lineage") return "lineage";
|
||||
return "system";
|
||||
}
|
||||
|
||||
function payloadRecord(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: {};
|
||||
}
|
||||
|
||||
function payloadText(value: unknown): string | null {
|
||||
return typeof value === "string" && value.length > 0 ? value : null;
|
||||
}
|
||||
|
||||
interface ItemFacts {
|
||||
interrupted: boolean;
|
||||
toolName: string | null;
|
||||
input: string | null;
|
||||
output: string | null;
|
||||
failure: string | null;
|
||||
terminal: boolean;
|
||||
}
|
||||
|
||||
function collectItemFacts(events: readonly PrpEvent[]): Map<string, ItemFacts> {
|
||||
const facts = new Map<string, ItemFacts>();
|
||||
for (const event of events) {
|
||||
if (event.itemId === undefined || !event.eventType.startsWith("item.")) continue;
|
||||
const payload = payloadRecord(event.payload);
|
||||
const current = facts.get(event.itemId) ?? {
|
||||
interrupted: false,
|
||||
toolName: null,
|
||||
input: null,
|
||||
output: null,
|
||||
failure: null,
|
||||
terminal: false,
|
||||
};
|
||||
if (payload.interrupted === true || payload.status === "interrupted") {
|
||||
current.interrupted = true;
|
||||
}
|
||||
current.toolName = payloadText(payload.toolName) ?? current.toolName;
|
||||
current.input = payloadText(payload.input) ?? current.input;
|
||||
current.output = payloadText(payload.output) ?? current.output;
|
||||
if (event.eventType === "item.failed") {
|
||||
current.failure = payloadText(payload.message) ?? "The item failed.";
|
||||
}
|
||||
if (event.eventType === "item.completed" || event.eventType === "item.failed") {
|
||||
current.terminal = true;
|
||||
}
|
||||
facts.set(event.itemId, current);
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the submit body a request kind actually accepts. Elicitation answers
|
||||
* travel as `content` and user-input answers as `answers`; the runner validates
|
||||
* both against the request kind, so sending the wrong one fails closed rather
|
||||
* than resolving the request with an empty response.
|
||||
*/
|
||||
export function runtimeRequestSubmission(
|
||||
requestKind: string,
|
||||
answer: string,
|
||||
): Record<string, unknown> {
|
||||
if (requestKind === "elicitation") {
|
||||
return { action: "submit", content: { answer } };
|
||||
}
|
||||
return { action: "submit", answers: { answer: { answers: [answer] } } };
|
||||
}
|
||||
|
||||
function requestActions(details: unknown): string[] {
|
||||
const record = payloadRecord(details);
|
||||
return Array.isArray(record.actions)
|
||||
? record.actions.filter((action): action is string => typeof action === "string")
|
||||
: [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the ordered transcript. Order comes from the reducer timeline, which
|
||||
* is authoritative; nothing is re-sorted client-side.
|
||||
*/
|
||||
export function buildTranscript(
|
||||
snapshot: SessionSnapshot,
|
||||
events: readonly PrpEvent[],
|
||||
): TranscriptEntry[] {
|
||||
const facts = collectItemFacts(events);
|
||||
const items = new Map(snapshot.items.map((item) => [item.itemId, item]));
|
||||
const seenItems = new Set<string>();
|
||||
const entries: TranscriptEntry[] = [];
|
||||
const eventsById = new Map(events.map((event) => [event.sourceEventId, event]));
|
||||
|
||||
for (const timelineEntry of snapshot.timeline) {
|
||||
const event = eventsById.get(timelineEntry.sourceEventId);
|
||||
const payload = payloadRecord(event?.payload);
|
||||
const position = timelineEntry.position;
|
||||
|
||||
if (timelineEntry.eventType === "turn.submitted") {
|
||||
entries.push({
|
||||
kind: "user",
|
||||
key: `user:${timelineEntry.sourceEventId}`,
|
||||
position,
|
||||
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
|
||||
text: payloadText(payload.text) ?? "(no message text in the canonical event)",
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
["turn.completed", "turn.failed", "turn.interrupted", "turn.cancelled"].includes(
|
||||
timelineEntry.eventType,
|
||||
)
|
||||
) {
|
||||
const state = timelineEntry.eventType.split(".").at(-1) as TranscriptTurnEntry["state"];
|
||||
entries.push({
|
||||
kind: "turn",
|
||||
key: `turn:${timelineEntry.sourceEventId}`,
|
||||
position,
|
||||
turnId: typeof payload.turnId === "string" ? payload.turnId : null,
|
||||
state,
|
||||
detail:
|
||||
payloadText(payload.message) ??
|
||||
payloadText(payload.reason) ??
|
||||
timelineEntry.summary,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (timelineEntry.eventType === "runtime_request.created") {
|
||||
const request = payloadRecord(payload.request ?? payload);
|
||||
const requestId = typeof request.requestId === "string" ? request.requestId : "";
|
||||
const projected = snapshot.requests.find(
|
||||
(candidate) => candidate.requestId === requestId,
|
||||
);
|
||||
const resolution = events.find(
|
||||
(candidate) =>
|
||||
candidate.eventType.startsWith("runtime_request.") &&
|
||||
candidate.eventType !== "runtime_request.created" &&
|
||||
payloadRecord(candidate.payload).requestId === requestId,
|
||||
);
|
||||
entries.push({
|
||||
kind: "request",
|
||||
key: `request:${requestId}`,
|
||||
position,
|
||||
requestId,
|
||||
requestKind: typeof request.requestKind === "string" ? request.requestKind : "runtime",
|
||||
type: typeof request.type === "string" ? request.type : "runtime",
|
||||
prompt: payloadText(request.prompt) ?? "Runtime request",
|
||||
status: (projected?.status ?? "pending") as TranscriptRequestEntry["status"],
|
||||
actions: requestActions(request),
|
||||
details: payloadRecord(request.details),
|
||||
resolvedAction: payloadText(payloadRecord(resolution?.payload).action),
|
||||
resolvedAt: resolution?.emittedAt ?? null,
|
||||
turnId: typeof request.turnId === "string" ? request.turnId : null,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
timelineEntry.eventType === "harness.diagnostic" ||
|
||||
timelineEntry.eventType === "runner.diagnostic"
|
||||
) {
|
||||
entries.push({
|
||||
kind: "diagnostic",
|
||||
key: `diagnostic:${timelineEntry.sourceEventId}`,
|
||||
position,
|
||||
message: payloadText(payload.message) ?? timelineEntry.summary,
|
||||
code: payloadText(payload.code),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const itemId = timelineEntry.itemId;
|
||||
if (itemId === undefined || seenItems.has(itemId)) continue;
|
||||
const item = items.get(itemId);
|
||||
if (item === undefined) continue;
|
||||
seenItems.add(itemId);
|
||||
const itemFacts = facts.get(itemId);
|
||||
entries.push({
|
||||
kind: "item",
|
||||
key: `item:${itemId}`,
|
||||
position,
|
||||
role: transcriptRole(item.kind),
|
||||
item,
|
||||
interrupted: itemFacts?.interrupted ?? false,
|
||||
streaming: item.status === "running" && itemFacts?.terminal !== true,
|
||||
toolName: itemFacts?.toolName ?? null,
|
||||
input: itemFacts?.input ?? null,
|
||||
output: itemFacts?.output ?? null,
|
||||
failure: itemFacts?.failure ?? null,
|
||||
});
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export type ComposerState =
|
||||
| "idle"
|
||||
| "submitting"
|
||||
| "active-turn"
|
||||
| "interrupting"
|
||||
| "disconnected"
|
||||
| "terminal";
|
||||
|
||||
export interface ComposerStateInput {
|
||||
connection: "idle" | "connecting" | "connected" | "reconnecting" | "disconnected" | "terminal";
|
||||
submitting: boolean;
|
||||
interrupting: boolean;
|
||||
activeTurnId: string | null;
|
||||
sessionState: SessionSnapshot["sessionState"];
|
||||
}
|
||||
|
||||
/** One state machine with exactly one visible primary action. */
|
||||
export function composerState(input: ComposerStateInput): ComposerState {
|
||||
if (input.sessionState === "closed" || input.sessionState === "failed") return "terminal";
|
||||
if (input.connection === "disconnected" || input.connection === "reconnecting") {
|
||||
return "disconnected";
|
||||
}
|
||||
if (input.interrupting) return "interrupting";
|
||||
if (input.activeTurnId !== null) return "active-turn";
|
||||
if (input.submitting) return "submitting";
|
||||
return "idle";
|
||||
}
|
||||
|
||||
export interface GoalOperationAvailability {
|
||||
operation: GoalOperation;
|
||||
enabled: boolean;
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
const GOAL_UNSUPPORTED_FALLBACK =
|
||||
"Goal operations unsupported: this app-server does not advertise the goals capability";
|
||||
|
||||
/**
|
||||
* Capability gating for the goal menu. An unsupported verb is disabled with
|
||||
* the exact upstream diagnostic — never hidden and never emulated.
|
||||
*/
|
||||
export function goalAvailability(
|
||||
capabilities: RunnerCapabilities,
|
||||
goalStatus: string | null,
|
||||
): GoalOperationAvailability[] {
|
||||
const unsupported = capabilities.unsupported ?? [];
|
||||
if (capabilities.goals !== true) {
|
||||
const reason = unsupported[0] ?? GOAL_UNSUPPORTED_FALLBACK;
|
||||
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => ({
|
||||
operation,
|
||||
enabled: false,
|
||||
reason,
|
||||
}));
|
||||
}
|
||||
return (["set", "get", "pause", "resume", "clear"] as GoalOperation[]).map((operation) => {
|
||||
if (operation === "set") return { operation, enabled: true, reason: null };
|
||||
if (goalStatus === null) {
|
||||
return {
|
||||
operation,
|
||||
enabled: false,
|
||||
reason: "No goal is set on this thread yet.",
|
||||
};
|
||||
}
|
||||
if (operation === "pause" && goalStatus !== "active") {
|
||||
return { operation, enabled: false, reason: `The goal is already ${goalStatus}.` };
|
||||
}
|
||||
if (operation === "resume" && goalStatus === "active") {
|
||||
return { operation, enabled: false, reason: "The goal is already active." };
|
||||
}
|
||||
return { operation, enabled: true, reason: null };
|
||||
});
|
||||
}
|
||||
|
||||
export interface CapabilityRow {
|
||||
name: string;
|
||||
supported: boolean;
|
||||
diagnostic: string | null;
|
||||
}
|
||||
|
||||
export function capabilityRows(capabilities: RunnerCapabilities): CapabilityRow[] {
|
||||
const unsupported = capabilities.unsupported ?? [];
|
||||
const rows: Array<[string, boolean | undefined]> = [
|
||||
["steer", capabilities.steering],
|
||||
["interrupt", capabilities.interruption],
|
||||
["resume", capabilities.resume],
|
||||
["runtime requests", capabilities.runtimeRequestResolution],
|
||||
["goals", capabilities.goals],
|
||||
["child threads", capabilities.threadLineage],
|
||||
["structured result", capabilities.structuredResult],
|
||||
["typed events", capabilities.typedEvents],
|
||||
["usage", capabilities.usage],
|
||||
];
|
||||
return rows.map(([name, supported]) => ({
|
||||
name,
|
||||
supported: supported === true,
|
||||
diagnostic:
|
||||
supported === true
|
||||
? null
|
||||
: (unsupported.find((entry) => entry.toLowerCase().includes(name.split(" ")[0]!)) ??
|
||||
`${name} is not advertised by this app-server`),
|
||||
}));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import * as React from "react";
|
||||
|
||||
/** Source-adapted shadcn Badge with semantic state supplied through data-tone. */
|
||||
export function Badge({
|
||||
className = "",
|
||||
tone = "neutral",
|
||||
...props
|
||||
}: React.ComponentProps<"span"> & {
|
||||
tone?: "neutral" | "accent" | "success" | "warning" | "danger";
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
data-slot="badge"
|
||||
data-tone={tone}
|
||||
className={`pcr-badge ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Source-adapted shadcn Alert. Failures stay in the transcript record, so this
|
||||
* is only used for standing conditions (reconnect, pending request, replay).
|
||||
*/
|
||||
export function Banner({
|
||||
className = "",
|
||||
tone = "neutral",
|
||||
assertive = false,
|
||||
...props
|
||||
}: React.ComponentProps<"div"> & {
|
||||
tone?: "neutral" | "info" | "accent" | "success" | "warning" | "danger";
|
||||
assertive?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
data-slot="banner"
|
||||
data-tone={tone}
|
||||
{...(assertive ? { role: "alert", "aria-live": "assertive" } : {})}
|
||||
className={`pcr-banner ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BannerText({ className = "", ...props }: React.ComponentProps<"p">) {
|
||||
return <p data-slot="banner-text" className={`pcr-banner-text ${className}`.trim()} {...props} />;
|
||||
}
|
||||
|
||||
export function BannerActions({ className = "", ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="banner-actions"
|
||||
className={`pcr-banner-actions ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
|
||||
/** Source-adapted shadcn Button for the standalone, non-Tailwind devtool. */
|
||||
export function Button({
|
||||
className = "",
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<"button"> & {
|
||||
variant?: "default" | "danger" | "ghost";
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
data-slot="button"
|
||||
className={`pcr-button pcr-button--${variant} ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import * as React from "react";
|
||||
|
||||
function slotClass(slot: string, className: string | undefined) {
|
||||
return `${slot} ${className ?? ""}`.trim();
|
||||
}
|
||||
|
||||
/** Source-adapted shadcn Card family for the standalone devtool. */
|
||||
export function Card({ className, ...props }: React.ComponentProps<"section">) {
|
||||
return <section data-slot="card" className={slotClass("pcr-card", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-header" className={slotClass("pcr-card-header", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardTitle({ className, ...props }: React.ComponentProps<"h2">) {
|
||||
return <h2 data-slot="card-title" className={slotClass("pcr-card-title", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
|
||||
return <p data-slot="card-description" className={slotClass("pcr-card-description", className)} {...props} />;
|
||||
}
|
||||
|
||||
export function CardContent({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return <div data-slot="card-content" className={slotClass("pcr-card-content", className)} {...props} />;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ComposerState } from "../../browser/transcript-model.js";
|
||||
import { Button } from "./button.js";
|
||||
|
||||
const MAX_ROWS = 8;
|
||||
const PRIMARY_LABEL: Record<ComposerState, string> = {
|
||||
idle: "Send",
|
||||
submitting: "Sending…",
|
||||
"active-turn": "Steer",
|
||||
interrupting: "Stopping…",
|
||||
disconnected: "Send",
|
||||
terminal: "Send",
|
||||
};
|
||||
const HINT: Record<ComposerState, string> = {
|
||||
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 session state.",
|
||||
};
|
||||
|
||||
export function Composer({
|
||||
state,
|
||||
value,
|
||||
onValueChange,
|
||||
onSubmit,
|
||||
onStop,
|
||||
disabledReason = null,
|
||||
label = "Message",
|
||||
leadingActions,
|
||||
trailingActions,
|
||||
}: {
|
||||
state: ComposerState;
|
||||
value: string;
|
||||
onValueChange: (value: string) => void;
|
||||
onSubmit: () => void;
|
||||
onStop: () => void;
|
||||
disabledReason?: string | null;
|
||||
label?: string;
|
||||
leadingActions?: React.ReactNode;
|
||||
trailingActions?: React.ReactNode;
|
||||
}) {
|
||||
const textareaRef = React.useRef<HTMLTextAreaElement>(null);
|
||||
const stopRef = React.useRef<HTMLButtonElement>(null);
|
||||
const inputId = React.useId();
|
||||
const hintId = React.useId();
|
||||
const reasonId = React.useId();
|
||||
const locked = disabledReason !== null || ["submitting", "interrupting", "disconnected", "terminal"].includes(state);
|
||||
const canStop = state === "active-turn" || state === "submitting";
|
||||
|
||||
React.useEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea === null) return;
|
||||
textarea.rows = Math.max(Math.min(value.split("\n").length, MAX_ROWS), 2);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div data-slot="composer" data-state={state} className="pcr-composer">
|
||||
<label className="pcr-visually-hidden" htmlFor={inputId}>{label}</label>
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
id={inputId}
|
||||
className="pcr-composer-input"
|
||||
data-testid="composer-input"
|
||||
rows={2}
|
||||
value={value}
|
||||
disabled={locked}
|
||||
aria-describedby={disabledReason === null ? hintId : `${hintId} ${reasonId}`}
|
||||
placeholder={state === "active-turn" ? "Steer the running turn…" : "Send a message…"}
|
||||
onChange={(event) => onValueChange(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
if (!locked && value.trim().length > 0) onSubmit();
|
||||
} else if (event.key === "Escape" && canStop) {
|
||||
event.preventDefault();
|
||||
stopRef.current?.focus();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<div className="pcr-composer-actions">
|
||||
<div className="pcr-composer-leading">{leadingActions}</div>
|
||||
<p id={hintId} className="pcr-composer-hint">{HINT[state]}</p>
|
||||
{disabledReason === null ? null : <p id={reasonId} className="pcr-composer-reason">{disabledReason}</p>}
|
||||
<div className="pcr-composer-buttons">
|
||||
<Button ref={stopRef} type="button" variant="danger" data-testid="composer-stop" disabled={!canStop} onClick={onStop}>Stop</Button>
|
||||
<Button type="button" data-testid="composer-send" disabled={locked || value.trim().length === 0} onClick={onSubmit}>{PRIMARY_LABEL[state]}</Button>
|
||||
{trailingActions}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { SessionItemSnapshot } from "../../reducer/session-reducer.js";
|
||||
import type {
|
||||
TranscriptEntry,
|
||||
TranscriptRequestEntry,
|
||||
} from "../../browser/transcript-model.js";
|
||||
import { RequestCard } from "../request-card.js";
|
||||
import { Badge } from "./badge.js";
|
||||
import { Message } from "./message.js";
|
||||
import { ReasoningItem } from "./reasoning-item.js";
|
||||
import { ToolItem, type ToolStatus } from "./tool-item.js";
|
||||
|
||||
const TURN_TONE = {
|
||||
completed: "success",
|
||||
failed: "danger",
|
||||
interrupted: "warning",
|
||||
cancelled: "warning",
|
||||
} as const;
|
||||
const TURN_LABEL = {
|
||||
completed: "Turn completed",
|
||||
failed: "Turn failed",
|
||||
interrupted: "Turn interrupted",
|
||||
cancelled: "Cancelled before start",
|
||||
} as const;
|
||||
|
||||
function toolStatus(entry: Extract<TranscriptEntry, { kind: "item" }>): ToolStatus {
|
||||
if (entry.interrupted) return "interrupted";
|
||||
if (entry.item.status === "failed") return "failed";
|
||||
if (entry.item.status === "running") return "running";
|
||||
return "completed";
|
||||
}
|
||||
|
||||
export interface ConversationProps {
|
||||
entries: readonly TranscriptEntry[];
|
||||
label?: string;
|
||||
unseenCount?: number;
|
||||
highlightItemId?: string | null;
|
||||
onResolveRequest?: (
|
||||
request: TranscriptRequestEntry,
|
||||
resolution: Record<string, unknown>,
|
||||
) => void;
|
||||
renderItemBody?: (item: SessionItemSnapshot) => React.ReactNode;
|
||||
renderRequestDetail?: (request: TranscriptRequestEntry) => React.ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical transcript renderer. Ordering comes from reducer-backed entries;
|
||||
* the component owns only scroll following and the accepted render seams.
|
||||
*/
|
||||
export function Conversation({
|
||||
entries,
|
||||
label = "Session transcript",
|
||||
unseenCount,
|
||||
highlightItemId = null,
|
||||
onResolveRequest,
|
||||
renderItemBody,
|
||||
renderRequestDetail,
|
||||
}: ConversationProps) {
|
||||
const viewportRef = React.useRef<HTMLDivElement>(null);
|
||||
const [following, setFollowing] = React.useState(true);
|
||||
const [seenCount, setSeenCount] = React.useState(entries.length);
|
||||
|
||||
const scrollToBottom = React.useCallback((smooth: boolean) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport === null) return;
|
||||
const reduceMotion = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
|
||||
viewport.scrollTo({
|
||||
top: viewport.scrollHeight,
|
||||
behavior: smooth && !reduceMotion ? "smooth" : "auto",
|
||||
});
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!following) return;
|
||||
scrollToBottom(true);
|
||||
setSeenCount(entries.length);
|
||||
}, [entries.length, following, scrollToBottom]);
|
||||
|
||||
function reengage() {
|
||||
setFollowing(true);
|
||||
setSeenCount(entries.length);
|
||||
scrollToBottom(true);
|
||||
}
|
||||
|
||||
const unseen = unseenCount ?? Math.max(entries.length - seenCount, 0);
|
||||
|
||||
return (
|
||||
<div data-slot="conversation" className="pcr-conversation">
|
||||
<div
|
||||
ref={viewportRef}
|
||||
role="log"
|
||||
aria-label={label}
|
||||
aria-relevant="additions"
|
||||
tabIndex={0}
|
||||
className="pcr-conversation-viewport"
|
||||
data-following={following}
|
||||
data-testid="runner-transcript"
|
||||
onScroll={() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (viewport === null) return;
|
||||
const atBottom = viewport.scrollHeight - viewport.scrollTop - viewport.clientHeight < 24;
|
||||
setFollowing(atBottom);
|
||||
if (atBottom) setSeenCount(entries.length);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
reengage();
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
setFollowing(false);
|
||||
viewportRef.current?.scrollTo({ top: 0 });
|
||||
}
|
||||
}}
|
||||
>
|
||||
{entries.length === 0 ? (
|
||||
<div className="pcr-empty-transcript" data-testid="empty-transcript">
|
||||
<p className="pcr-eyebrow">No session yet</p>
|
||||
<h3>Pick a demo chat or send a message</h3>
|
||||
<p>The browser holds no provider credential. The protocol server owns the driver and working directory.</p>
|
||||
</div>
|
||||
) : null}
|
||||
<ol className="pcr-transcript" role="list">
|
||||
{entries.map((entry) => {
|
||||
if (entry.kind === "user") {
|
||||
return <li key={entry.key}><Message role="user" text={entry.text} /></li>;
|
||||
}
|
||||
if (entry.kind === "turn") {
|
||||
return (
|
||||
<li key={entry.key} className="pcr-turn-marker" data-testid={`turn-${entry.state}`}>
|
||||
<Badge tone={TURN_TONE[entry.state]}>{TURN_LABEL[entry.state]}</Badge><span>{entry.detail}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (entry.kind === "diagnostic") {
|
||||
return <li key={entry.key}><Message role="system" text={entry.message} failed /></li>;
|
||||
}
|
||||
if (entry.kind === "request") {
|
||||
return (
|
||||
<RequestCard
|
||||
key={entry.key}
|
||||
request={entry}
|
||||
onResolve={(resolution) => onResolveRequest?.(entry, resolution)}
|
||||
{...(renderRequestDetail === undefined ? {} : { renderRequestDetail })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (entry.role === "reasoning") {
|
||||
return (
|
||||
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
|
||||
<ReasoningItem item={entry.item} streaming={entry.streaming} interrupted={entry.interrupted} {...(renderItemBody === undefined ? {} : { renderItemBody })} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
if (entry.role === "tool") {
|
||||
return (
|
||||
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
|
||||
<ToolItem item={entry.item} name={entry.toolName ?? entry.item.kind} status={toolStatus(entry)} input={entry.input} output={entry.output} failure={entry.failure} {...(renderItemBody === undefined ? {} : { renderItemBody })} />
|
||||
</li>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<li key={entry.key} data-highlighted={entry.item.itemId === highlightItemId}>
|
||||
<Message role={entry.role} item={entry.item} streaming={entry.streaming} interrupted={entry.interrupted} failed={entry.item.status === "failed"} failure={entry.failure} {...(renderItemBody === undefined ? {} : { renderItemBody })} />
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
</div>
|
||||
{following ? null : (
|
||||
<button type="button" className="pcr-jump-to-latest" data-testid="jump-to-latest" onClick={reengage}>
|
||||
Jump to latest{unseen > 0 ? ` (${unseen} new)` : ""}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Source-adapted shadcn Dialog rebuilt on the native `<dialog>` element.
|
||||
* `showModal()` supplies the focus trap, the backdrop, and Escape-to-close,
|
||||
* so no radix runtime and no focus-trap dependency are needed.
|
||||
*/
|
||||
export function Dialog({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
description,
|
||||
children,
|
||||
footer,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
footer: React.ReactNode;
|
||||
}) {
|
||||
const ref = React.useRef<HTMLDialogElement>(null);
|
||||
const invokingControlRef = React.useRef<HTMLElement | null>(null);
|
||||
const titleId = React.useId();
|
||||
const descriptionId = React.useId();
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (element === null) return;
|
||||
if (open && !element.open) {
|
||||
invokingControlRef.current = document.activeElement as HTMLElement | null;
|
||||
element.showModal();
|
||||
}
|
||||
if (!open && element.open) element.close();
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={ref}
|
||||
data-slot="dialog"
|
||||
className="pcr-dialog"
|
||||
aria-labelledby={titleId}
|
||||
{...(description === undefined ? {} : { "aria-describedby": descriptionId })}
|
||||
onCancel={(event) => {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
}}
|
||||
onClose={() => {
|
||||
onClose();
|
||||
invokingControlRef.current?.focus();
|
||||
}}
|
||||
>
|
||||
<form method="dialog" className="pcr-dialog-body">
|
||||
<h2 id={titleId} className="pcr-dialog-title">
|
||||
{title}
|
||||
</h2>
|
||||
{description === undefined ? null : (
|
||||
<p id={descriptionId} className="pcr-dialog-description">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
{children}
|
||||
<div className="pcr-dialog-footer">{footer}</div>
|
||||
</form>
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface MenuItemDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
/** Exact upstream diagnostic; exposed through aria-describedby, not hover. */
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-adapted shadcn DropdownMenu narrowed to the menu-button pattern the
|
||||
* goal surface needs. Five fixed verbs, no search, no radix portal.
|
||||
*/
|
||||
export function Menu({
|
||||
label,
|
||||
items,
|
||||
onSelect,
|
||||
disabled = false,
|
||||
disabledReason = null,
|
||||
triggerId,
|
||||
}: {
|
||||
label: string;
|
||||
items: readonly MenuItemDefinition[];
|
||||
onSelect: (id: string) => void;
|
||||
disabled?: boolean;
|
||||
disabledReason?: string | null;
|
||||
triggerId: string;
|
||||
}) {
|
||||
const [open, setOpen] = React.useState(false);
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
const triggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const descriptionId = `${triggerId}-reason`;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return undefined;
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
if (!containerRef.current?.contains(event.target as Node)) setOpen(false);
|
||||
}
|
||||
document.addEventListener("mousedown", onPointerDown);
|
||||
return () => document.removeEventListener("mousedown", onPointerDown);
|
||||
}, [open]);
|
||||
|
||||
function focusItem(index: number) {
|
||||
const buttons = containerRef.current?.querySelectorAll<HTMLButtonElement>(
|
||||
'[role="menuitem"]',
|
||||
);
|
||||
if (buttons === undefined || buttons.length === 0) return;
|
||||
buttons[(index + buttons.length) % buttons.length]?.focus();
|
||||
}
|
||||
|
||||
function onTriggerKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
|
||||
if (["ArrowDown", "Enter", " "].includes(event.key)) {
|
||||
event.preventDefault();
|
||||
setOpen(true);
|
||||
window.setTimeout(() => focusItem(0), 0);
|
||||
}
|
||||
}
|
||||
|
||||
function onMenuKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
const buttons = [
|
||||
...(containerRef.current?.querySelectorAll<HTMLButtonElement>('[role="menuitem"]') ?? []),
|
||||
];
|
||||
const index = buttons.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
} else if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
focusItem(index + 1);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
focusItem(index - 1);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
focusItem(0);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
focusItem(buttons.length - 1);
|
||||
} else if (event.key === "Tab") {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} data-slot="menu" className="pcr-menu">
|
||||
<button
|
||||
ref={triggerRef}
|
||||
id={triggerId}
|
||||
type="button"
|
||||
className="pcr-button pcr-button--quiet"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-disabled={disabled}
|
||||
{...(disabled && disabledReason !== null ? { "aria-describedby": descriptionId } : {})}
|
||||
onClick={() => {
|
||||
if (disabled) return;
|
||||
setOpen((current) => !current);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (disabled) return;
|
||||
onTriggerKeyDown(event);
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<span aria-hidden="true"> ▾</span>
|
||||
</button>
|
||||
{disabled && disabledReason !== null ? (
|
||||
<p id={descriptionId} className="pcr-menu-reason">
|
||||
{disabledReason}
|
||||
</p>
|
||||
) : null}
|
||||
{open && !disabled ? (
|
||||
<div role="menu" aria-labelledby={triggerId} className="pcr-menu-list" onKeyDown={onMenuKeyDown}>
|
||||
{items.map((item) => {
|
||||
const itemReasonId = `${triggerId}-${item.id}-reason`;
|
||||
return (
|
||||
<React.Fragment key={item.id}>
|
||||
<button
|
||||
type="button"
|
||||
role="menuitem"
|
||||
className="pcr-menu-item"
|
||||
aria-disabled={!item.enabled}
|
||||
{...(item.reason === null ? {} : { "aria-describedby": itemReasonId })}
|
||||
onClick={() => {
|
||||
if (!item.enabled) return;
|
||||
setOpen(false);
|
||||
onSelect(item.id);
|
||||
}}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
{item.reason === null ? null : (
|
||||
<p id={itemReasonId} className="pcr-menu-reason">
|
||||
{item.reason}
|
||||
</p>
|
||||
)}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { SessionItemSnapshot } from "../../reducer/session-reducer.js";
|
||||
import type { TranscriptRole } from "../../browser/transcript-model.js";
|
||||
|
||||
const ROLE_LABELS: Record<TranscriptRole, string> = {
|
||||
user: "You",
|
||||
assistant: "Assistant",
|
||||
reasoning: "Reasoning",
|
||||
tool: "Tool",
|
||||
steering: "Steering acknowledged",
|
||||
interrupt: "Interrupt acknowledged",
|
||||
goal: "Goal",
|
||||
lineage: "Child thread",
|
||||
system: "System",
|
||||
};
|
||||
|
||||
export interface MessageProps {
|
||||
role: TranscriptRole;
|
||||
item?: SessionItemSnapshot;
|
||||
text?: string;
|
||||
interrupted?: boolean;
|
||||
failed?: boolean;
|
||||
failure?: string | null;
|
||||
streaming?: boolean;
|
||||
renderItemBody?: (item: SessionItemSnapshot) => React.ReactNode;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Message({
|
||||
role,
|
||||
item,
|
||||
text,
|
||||
interrupted = false,
|
||||
failed = false,
|
||||
failure = null,
|
||||
streaming = false,
|
||||
renderItemBody,
|
||||
children,
|
||||
}: MessageProps) {
|
||||
const body = item === undefined
|
||||
? (children ?? text ?? "")
|
||||
: (renderItemBody?.(item) ?? <MessageText text={item.text} streaming={streaming} />);
|
||||
return (
|
||||
<article
|
||||
data-slot="message"
|
||||
data-role={role}
|
||||
data-state={failed ? "failed" : interrupted ? "interrupted" : streaming ? "streaming" : "settled"}
|
||||
className="pcr-message"
|
||||
>
|
||||
<p className="pcr-message-role">{ROLE_LABELS[role]}</p>
|
||||
<div className="pcr-message-body">{body}</div>
|
||||
{failure === null ? null : (
|
||||
<pre className="pcr-payload pcr-payload--danger" data-testid="item-failure">
|
||||
{failure}
|
||||
</pre>
|
||||
)}
|
||||
{interrupted ? (
|
||||
<p className="pcr-message-divider" data-testid="interrupted-divider">
|
||||
Interrupted — the text above is everything that arrived.
|
||||
</p>
|
||||
) : null}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export function MessageText({ text, streaming }: { text: string; streaming: boolean }) {
|
||||
return (
|
||||
<p className="pcr-message-text">
|
||||
{text}
|
||||
{streaming ? <span className="pcr-stream-cursor" aria-hidden="true" /> : null}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { SessionItemSnapshot } from "../../reducer/session-reducer.js";
|
||||
|
||||
export function ReasoningItem({
|
||||
item,
|
||||
streaming,
|
||||
interrupted,
|
||||
renderItemBody,
|
||||
}: {
|
||||
item: SessionItemSnapshot;
|
||||
streaming: boolean;
|
||||
interrupted: boolean;
|
||||
renderItemBody?: (item: SessionItemSnapshot) => React.ReactNode;
|
||||
}) {
|
||||
const summary = item.text.split("\n")[0]?.slice(0, 96) ?? "Reasoning";
|
||||
return (
|
||||
<details
|
||||
data-slot="reasoning-item"
|
||||
className="pcr-disclosure"
|
||||
open={streaming}
|
||||
data-testid="reasoning-item"
|
||||
>
|
||||
<summary className="pcr-disclosure-summary">
|
||||
<span>{streaming ? "Reasoning…" : "Reasoning"}</span>
|
||||
<span className="pcr-disclosure-preview">{summary}</span>
|
||||
</summary>
|
||||
{renderItemBody?.(item) ?? <pre className="pcr-payload">{item.text}</pre>}
|
||||
{interrupted ? <p className="pcr-message-divider">Interrupted while reasoning.</p> : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import * as React from "react";
|
||||
|
||||
export interface TabDefinition {
|
||||
id: string;
|
||||
label: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source-adapted shadcn Tabs rebuilt on the WAI-ARIA tabs pattern with a
|
||||
* roving tabindex. No radix runtime: arrow keys move, Home/End jump, Tab
|
||||
* enters the panel.
|
||||
*/
|
||||
export function Tabs({
|
||||
items,
|
||||
selected,
|
||||
onSelect,
|
||||
label,
|
||||
className = "",
|
||||
children,
|
||||
}: {
|
||||
items: readonly TabDefinition[];
|
||||
selected: string;
|
||||
onSelect: (value: string) => void;
|
||||
label: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const listRef = React.useRef<HTMLDivElement>(null);
|
||||
const idPrefix = React.useId();
|
||||
|
||||
function moveFocus(nextIndex: number) {
|
||||
const normalizedIndex = (nextIndex + items.length) % items.length;
|
||||
const next = items[normalizedIndex];
|
||||
if (next === undefined) return;
|
||||
onSelect(next.id);
|
||||
const button = listRef.current?.querySelectorAll<HTMLButtonElement>('[role="tab"]')[normalizedIndex];
|
||||
button?.focus();
|
||||
}
|
||||
|
||||
function onKeyDown(event: React.KeyboardEvent<HTMLDivElement>) {
|
||||
const index = items.findIndex((tab) => tab.id === selected);
|
||||
if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
moveFocus(index + 1);
|
||||
} else if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
moveFocus(index - 1);
|
||||
} else if (event.key === "Home") {
|
||||
event.preventDefault();
|
||||
moveFocus(0);
|
||||
} else if (event.key === "End") {
|
||||
event.preventDefault();
|
||||
moveFocus(items.length - 1);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-slot="tabs" className={`pcr-tabs ${className}`.trim()}>
|
||||
<div
|
||||
ref={listRef}
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className="pcr-tablist"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{items.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
data-tab-id={tab.id}
|
||||
id={`${idPrefix}-tab-${tab.id}`}
|
||||
aria-selected={tab.id === selected}
|
||||
aria-controls={`${idPrefix}-tabpanel-${tab.id}`}
|
||||
tabIndex={tab.id === selected ? 0 : -1}
|
||||
className="pcr-tab"
|
||||
onClick={() => onSelect(tab.id)}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div
|
||||
role="tabpanel"
|
||||
id={`${idPrefix}-tabpanel-${selected}`}
|
||||
aria-labelledby={`${idPrefix}-tab-${selected}`}
|
||||
tabIndex={0}
|
||||
className="pcr-tabpanel"
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import * as React from "react";
|
||||
|
||||
/** Source-adapted shadcn Textarea for editable fixture JSON. */
|
||||
export function Textarea({
|
||||
className = "",
|
||||
...props
|
||||
}: React.ComponentProps<"textarea">) {
|
||||
return (
|
||||
<textarea
|
||||
data-slot="textarea"
|
||||
className={`pcr-textarea ${className}`.trim()}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { SessionItemSnapshot } from "../../reducer/session-reducer.js";
|
||||
import { Badge } from "./badge.js";
|
||||
|
||||
export type ToolStatus = "running" | "completed" | "failed" | "interrupted";
|
||||
|
||||
const STATUS_TONE: Record<ToolStatus, "accent" | "success" | "danger" | "warning"> = {
|
||||
running: "accent",
|
||||
completed: "success",
|
||||
failed: "danger",
|
||||
interrupted: "warning",
|
||||
};
|
||||
|
||||
const STATUS_LABEL: Record<ToolStatus, string> = {
|
||||
running: "Running",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
interrupted: "Interrupted",
|
||||
};
|
||||
|
||||
function toolView(kind: string): string {
|
||||
if (["commandExecution", "command_execution"].includes(kind)) return "Terminal";
|
||||
if (["fileChange", "file_change", "patchApply"].includes(kind)) return "File change";
|
||||
if (["plan", "planUpdate", "update_plan"].includes(kind)) return "Plan";
|
||||
return "Tool";
|
||||
}
|
||||
|
||||
export function ToolItem({
|
||||
item,
|
||||
name,
|
||||
status,
|
||||
input,
|
||||
output,
|
||||
failure,
|
||||
renderItemBody,
|
||||
}: {
|
||||
item: SessionItemSnapshot;
|
||||
name: string;
|
||||
status: ToolStatus;
|
||||
input: string | null;
|
||||
output: string | null;
|
||||
failure: string | null;
|
||||
renderItemBody?: (item: SessionItemSnapshot) => React.ReactNode;
|
||||
}) {
|
||||
const view = toolView(item.kind);
|
||||
return (
|
||||
<details
|
||||
data-slot="tool-item"
|
||||
className="pcr-disclosure"
|
||||
open={status === "running" || status === "failed"}
|
||||
data-testid="tool-item"
|
||||
data-status={status}
|
||||
data-view={view.toLowerCase().replaceAll(" ", "-")}
|
||||
>
|
||||
<summary className="pcr-disclosure-summary">
|
||||
<span>{view} <code>{name}</code></span>
|
||||
<Badge tone={STATUS_TONE[status]}>{STATUS_LABEL[status]}</Badge>
|
||||
</summary>
|
||||
{renderItemBody?.(item) ?? (
|
||||
<>
|
||||
{input === null ? null : (
|
||||
<><p className="pcr-disclosure-label">Input</p><pre className="pcr-payload">{input}</pre></>
|
||||
)}
|
||||
{output === null ? null : (
|
||||
<><p className="pcr-disclosure-label">Output</p><pre className="pcr-payload">{output}</pre></>
|
||||
)}
|
||||
{input === null && output === null && item.text.length > 0
|
||||
? <pre className="pcr-payload">{item.text}</pre>
|
||||
: null}
|
||||
</>
|
||||
)}
|
||||
{failure === null ? null : (
|
||||
<><p className="pcr-disclosure-label">Diagnostic</p><pre className="pcr-payload pcr-payload--danger" data-testid="tool-failure">{failure}</pre></>
|
||||
)}
|
||||
{status === "interrupted" ? (
|
||||
<p className="pcr-message-divider" data-testid="interrupted-divider">
|
||||
Interrupted — the last known status is shown and no result was fabricated.
|
||||
</p>
|
||||
) : null}
|
||||
</details>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import * as React from "react";
|
||||
|
||||
/**
|
||||
* Source-adapted shadcn Tooltip. The content is always mirrored into a
|
||||
* described-by element, so a tooltip is never the only channel for a
|
||||
* diagnostic (interaction map A5).
|
||||
*/
|
||||
export function Tooltip({
|
||||
content,
|
||||
id,
|
||||
children,
|
||||
}: {
|
||||
content: string;
|
||||
id: string;
|
||||
children: React.ReactElement<{ "aria-describedby"?: string }>;
|
||||
}) {
|
||||
return (
|
||||
<span data-slot="tooltip" className="pcr-tooltip">
|
||||
{React.cloneElement(children, { "aria-describedby": id })}
|
||||
<span role="tooltip" id={id} className="pcr-tooltip-content">
|
||||
{content}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { ConnectionStatus } from "../browser/protocol.js";
|
||||
import { Banner, BannerActions, BannerText } from "./components/banner.js";
|
||||
import { Button } from "./components/button.js";
|
||||
|
||||
export function ConnectionBanner({
|
||||
connection,
|
||||
reconnectAttempt,
|
||||
onRetry,
|
||||
}: {
|
||||
connection: ConnectionStatus;
|
||||
reconnectAttempt: number;
|
||||
onRetry: () => void;
|
||||
}) {
|
||||
const ref = React.useRef<HTMLDivElement>(null);
|
||||
const blocking = connection === "disconnected";
|
||||
React.useEffect(() => {
|
||||
if (blocking) ref.current?.focus();
|
||||
}, [blocking]);
|
||||
if (connection !== "disconnected" && connection !== "reconnecting") return null;
|
||||
return (
|
||||
<Banner
|
||||
ref={ref}
|
||||
data-slot="connection-banner"
|
||||
tone="warning"
|
||||
assertive={blocking}
|
||||
tabIndex={blocking ? -1 : undefined}
|
||||
data-testid="reconnect-banner"
|
||||
>
|
||||
<BannerText>
|
||||
{connection === "reconnecting"
|
||||
? `Connection lost — reconnecting (attempt ${reconnectAttempt})…`
|
||||
: "Connection lost — the session is still on the server."}
|
||||
</BannerText>
|
||||
<BannerActions><Button type="button" onClick={onRetry}>Retry now</Button></BannerActions>
|
||||
</Banner>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
export { Badge } from "./components/badge.js";
|
||||
export { Banner, BannerActions, BannerText } from "./components/banner.js";
|
||||
export { Button } from "./components/button.js";
|
||||
export {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from "./components/card.js";
|
||||
export { Composer } from "./components/composer.js";
|
||||
export { Conversation, type ConversationProps } from "./components/conversation.js";
|
||||
export { Dialog } from "./components/dialog.js";
|
||||
export { Menu, type MenuItemDefinition } from "./components/menu.js";
|
||||
export { Message, type MessageProps } from "./components/message.js";
|
||||
export { ReasoningItem } from "./components/reasoning-item.js";
|
||||
export { Tabs, type TabDefinition } from "./components/tabs.js";
|
||||
export { Textarea } from "./components/textarea.js";
|
||||
export { ToolItem, type ToolStatus } from "./components/tool-item.js";
|
||||
export { Tooltip } from "./components/tooltip.js";
|
||||
export { ConnectionBanner } from "./connection-banner.js";
|
||||
export { Inspector } from "./inspector.js";
|
||||
export { ReplayControls } from "./replay-controls.js";
|
||||
export { RequestCard } from "./request-card.js";
|
||||
export { RunnerConsoleApp, type RunnerConsoleAppProps } from "./runner-console-app.js";
|
||||
export { SessionTimeline } from "./session-timeline.js";
|
||||
export {
|
||||
useRunnerConsole,
|
||||
type RunnerConsole,
|
||||
type SteeringChip,
|
||||
type SteeringChipStatus,
|
||||
} from "./use-runner-console.js";
|
||||
@@ -0,0 +1,232 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { PrpEvent } from "../protocol/phase1-contract.js";
|
||||
import type { SessionSnapshot } from "../reducer/session-reducer.js";
|
||||
import { Badge } from "./components/badge.js";
|
||||
import { Tabs } from "./components/tabs.js";
|
||||
import type { ConnectionStatus, RunnerSessionState } from "../browser/protocol.js";
|
||||
import { capabilityRows } from "../browser/transcript-model.js";
|
||||
|
||||
const TABS = [
|
||||
{ id: "events", label: "Events" },
|
||||
{ id: "requests", label: "Requests" },
|
||||
{ id: "capabilities", label: "Capabilities" },
|
||||
{ id: "session", label: "Session" },
|
||||
];
|
||||
|
||||
function CopyButton({ label, value }: { label: string; value: string }) {
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="pcr-copy"
|
||||
aria-label={label}
|
||||
onClick={() => {
|
||||
void navigator.clipboard?.writeText(value).then(
|
||||
() => setCopied(true),
|
||||
() => setCopied(false),
|
||||
);
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied" : "Copy"}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The debugging surface. Redaction is the server's job: the inspector renders
|
||||
* redaction markers verbatim and never tries to reconstruct a redacted value.
|
||||
*/
|
||||
export function Inspector({
|
||||
events,
|
||||
snapshot,
|
||||
state,
|
||||
connection,
|
||||
reconnectAttempt,
|
||||
replayParity,
|
||||
highlightItemId,
|
||||
onHighlightItem,
|
||||
}: {
|
||||
events: readonly PrpEvent[];
|
||||
snapshot: SessionSnapshot | null;
|
||||
state: RunnerSessionState | null;
|
||||
connection: ConnectionStatus;
|
||||
reconnectAttempt: number;
|
||||
replayParity: boolean | null;
|
||||
highlightItemId: string | null;
|
||||
onHighlightItem: (itemId: string | null) => void;
|
||||
}) {
|
||||
const [tab, setTab] = React.useState("events");
|
||||
const [filter, setFilter] = React.useState("");
|
||||
const [facet, setFacet] = React.useState<string | null>(null);
|
||||
const filterId = React.useId();
|
||||
|
||||
const facets = React.useMemo(
|
||||
() => [...new Set(events.map((event) => event.eventType))].sort(),
|
||||
[events],
|
||||
);
|
||||
|
||||
const visible = React.useMemo(
|
||||
() =>
|
||||
events.filter((event) => {
|
||||
if (facet !== null && event.eventType !== facet) return false;
|
||||
if (filter.length === 0) return true;
|
||||
return JSON.stringify(event).toLowerCase().includes(filter.toLowerCase());
|
||||
}),
|
||||
[events, facet, filter],
|
||||
);
|
||||
|
||||
return (
|
||||
<section data-slot="inspector" className="pcr-inspector">
|
||||
<Tabs items={TABS} selected={tab} onSelect={setTab} label="Protocol inspector">
|
||||
{tab === "events" ? (
|
||||
<div className="pcr-inspector-events">
|
||||
<label className="pcr-visually-hidden" htmlFor={filterId}>
|
||||
Filter events
|
||||
</label>
|
||||
<input
|
||||
id={filterId}
|
||||
className="pcr-inspector-filter"
|
||||
data-testid="inspector-filter"
|
||||
placeholder="Filter events"
|
||||
value={filter}
|
||||
onChange={(event) => setFilter(event.target.value)}
|
||||
/>
|
||||
<div className="pcr-facets">
|
||||
<button
|
||||
type="button"
|
||||
className="pcr-facet"
|
||||
aria-pressed={facet === null}
|
||||
onClick={() => setFacet(null)}
|
||||
>
|
||||
All
|
||||
</button>
|
||||
{facets.map((candidate) => (
|
||||
<button
|
||||
key={candidate}
|
||||
type="button"
|
||||
className="pcr-facet"
|
||||
aria-pressed={facet === candidate}
|
||||
onClick={() => setFacet(candidate)}
|
||||
>
|
||||
{candidate}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ol className="pcr-inspector-list" data-testid="inspector-events">
|
||||
{visible.map((event, index) => (
|
||||
<li
|
||||
key={`${event.sourceEventId}:${index}`}
|
||||
data-highlighted={
|
||||
highlightItemId !== null && event.itemId === highlightItemId
|
||||
}
|
||||
>
|
||||
<details
|
||||
onToggle={(toggle) =>
|
||||
onHighlightItem(
|
||||
(toggle.currentTarget as HTMLDetailsElement).open
|
||||
? (event.itemId ?? null)
|
||||
: null,
|
||||
)
|
||||
}
|
||||
>
|
||||
<summary className="pcr-inspector-row">
|
||||
<code className="pcr-seq">{event.sourceSeq}</code>
|
||||
<code>{event.eventType}</code>
|
||||
<code className="pcr-inspector-id">{event.itemId ?? event.turnId ?? "—"}</code>
|
||||
</summary>
|
||||
<pre className="pcr-payload">{JSON.stringify(event, null, 2)}</pre>
|
||||
<CopyButton
|
||||
label={`Copy event ${event.sourceSeq} payload`}
|
||||
value={JSON.stringify(event, null, 2)}
|
||||
/>
|
||||
</details>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{tab === "requests" ? (
|
||||
<dl className="pcr-inspector-grid" data-testid="inspector-requests">
|
||||
{(snapshot?.requests ?? []).map((request) => (
|
||||
<div key={request.requestId}>
|
||||
<dt>
|
||||
<code>{request.requestId}</code>
|
||||
</dt>
|
||||
<dd>
|
||||
{request.type} — {request.status}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
{(snapshot?.requests.length ?? 0) === 0 ? <p>No runtime requests yet.</p> : null}
|
||||
</dl>
|
||||
) : null}
|
||||
|
||||
{tab === "capabilities" ? (
|
||||
<ul className="pcr-capability-list" data-testid="inspector-capabilities">
|
||||
{capabilityRows(state?.capabilities ?? { resume: false, typedEvents: false, steering: false, interruption: false, structuredResult: false }).map(
|
||||
(row) => (
|
||||
<li key={row.name}>
|
||||
<span>{row.name}</span>
|
||||
<Badge tone={row.supported ? "success" : "warning"}>
|
||||
{row.supported ? "supported" : "unsupported"}
|
||||
</Badge>
|
||||
{row.diagnostic === null ? null : (
|
||||
<p className="pcr-capability-diagnostic">{row.diagnostic}</p>
|
||||
)}
|
||||
</li>
|
||||
),
|
||||
)}
|
||||
</ul>
|
||||
) : null}
|
||||
|
||||
{tab === "session" ? (
|
||||
<dl className="pcr-inspector-grid" data-testid="inspector-session">
|
||||
<div>
|
||||
<dt>Run</dt>
|
||||
<dd>{state?.runId ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Normalized session</dt>
|
||||
<dd>{state?.normalizedSessionId ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Driver session</dt>
|
||||
<dd>{state?.driverSession.driverSessionId ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Source cursors</dt>
|
||||
<dd>{JSON.stringify(snapshot?.sourceCursors ?? {})}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Integrity</dt>
|
||||
<dd>{snapshot?.integrity ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Gaps</dt>
|
||||
<dd>{snapshot?.gaps.length ?? 0}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Connection</dt>
|
||||
<dd>
|
||||
{connection}
|
||||
{connection === "reconnecting" ? ` (attempt ${reconnectAttempt})` : ""}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Credentials in browser</dt>
|
||||
<dd>{state?.credentialsExposed === true ? "yes" : "no"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Live and replay reducer</dt>
|
||||
<dd data-testid="replay-parity">
|
||||
{replayParity === null ? "not compared yet" : replayParity ? "match" : "mismatch"}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
) : null}
|
||||
</Tabs>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { SessionItemSnapshot } from "../reducer/session-reducer.js";
|
||||
import type { TranscriptEntry } from "../browser/transcript-model.js";
|
||||
import { Composer } from "./components/composer.js";
|
||||
import { Conversation } from "./components/conversation.js";
|
||||
import { RequestCard } from "./request-card.js";
|
||||
import { SessionTimeline } from "./session-timeline.js";
|
||||
|
||||
const item: SessionItemSnapshot = {
|
||||
itemId: "assistant-1",
|
||||
kind: "agentMessage",
|
||||
status: "completed",
|
||||
text: "Canonical answer",
|
||||
};
|
||||
|
||||
const entries: TranscriptEntry[] = [
|
||||
{
|
||||
kind: "item",
|
||||
key: "item:assistant-1",
|
||||
position: 1,
|
||||
role: "assistant",
|
||||
item,
|
||||
interrupted: false,
|
||||
streaming: false,
|
||||
toolName: null,
|
||||
input: null,
|
||||
output: null,
|
||||
failure: null,
|
||||
},
|
||||
];
|
||||
|
||||
describe("public React contracts", () => {
|
||||
it("renders reducer-backed transcript semantics and the item renderer seam", () => {
|
||||
const html = renderToStaticMarkup(
|
||||
<Conversation entries={entries} renderItemBody={(entry) => <mark>{entry.text}</mark>} />,
|
||||
);
|
||||
expect(html).toContain('data-slot="conversation"');
|
||||
expect(html).toContain('role="log"');
|
||||
expect(html).toContain('class="pcr-message"');
|
||||
expect(html).toContain("<mark>Canonical answer</mark>");
|
||||
});
|
||||
|
||||
it("keeps request status controls around the request-detail renderer", () => {
|
||||
const request = {
|
||||
kind: "request" as const,
|
||||
key: "request:r1",
|
||||
position: 2,
|
||||
requestId: "r1",
|
||||
requestKind: "command_approval",
|
||||
type: "approval",
|
||||
prompt: "Run command?",
|
||||
status: "pending" as const,
|
||||
actions: ["accept", "decline"],
|
||||
details: { command: "printf safe" },
|
||||
resolvedAction: null,
|
||||
resolvedAt: null,
|
||||
turnId: "turn-1",
|
||||
};
|
||||
const html = renderToStaticMarkup(
|
||||
<ol><RequestCard request={request} onResolve={() => undefined} renderRequestDetail={() => <code>custom detail</code>} /></ol>,
|
||||
);
|
||||
expect(html).toContain('data-slot="request-card"');
|
||||
expect(html).toContain("custom detail");
|
||||
expect(html).toContain("Approve");
|
||||
expect(html).toContain("Reject");
|
||||
});
|
||||
|
||||
it("exposes composer slots and canonical session timeline without a parallel model", () => {
|
||||
const composer = renderToStaticMarkup(
|
||||
<Composer state="idle" value="hello" onValueChange={() => undefined} onSubmit={() => undefined} onStop={() => undefined} leadingActions={<span>leading</span>} trailingActions={<span>trailing</span>} />,
|
||||
);
|
||||
const timeline = renderToStaticMarkup(<SessionTimeline snapshot={null} state={null} />);
|
||||
expect(composer).toContain('data-slot="composer"');
|
||||
expect(composer).toContain("leading");
|
||||
expect(composer).toContain("trailing");
|
||||
expect(timeline).toContain('data-slot="session-timeline"');
|
||||
});
|
||||
|
||||
it("keeps exact failed-item diagnostics in the rendered record", () => {
|
||||
const failedEntries: TranscriptEntry[] = [{
|
||||
...entries[0]!,
|
||||
item: { ...item, status: "failed", text: "Partial output" },
|
||||
failure: "exact upstream diagnostic",
|
||||
}];
|
||||
const html = renderToStaticMarkup(<Conversation entries={failedEntries} />);
|
||||
expect(html).toContain('data-testid="item-failure"');
|
||||
expect(html).toContain("exact upstream diagnostic");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Button } from "./components/button.js";
|
||||
import type { RunnerConsole } from "./use-runner-console.js";
|
||||
|
||||
export function ReplayControls({ replay }: { replay: RunnerConsole["replay"] }) {
|
||||
const positionId = React.useId();
|
||||
return (
|
||||
<div data-slot="replay-controls" className="pcr-replay-stepper" data-testid="replay-controls">
|
||||
<Button type="button" onClick={replay.togglePlay}>{replay.playing ? "Pause" : "Play"}</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => replay.step(-1)}>Step back</Button>
|
||||
<Button type="button" variant="ghost" onClick={() => replay.step(1)}>Step forward</Button>
|
||||
<label htmlFor={positionId}>Position</label>
|
||||
<input
|
||||
id={positionId}
|
||||
type="range"
|
||||
min={0}
|
||||
max={replay.length}
|
||||
value={replay.position}
|
||||
data-testid="replay-position"
|
||||
onChange={(event) => replay.setPosition(Number(event.target.value))}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "ArrowLeft") {
|
||||
event.preventDefault();
|
||||
replay.step(-1);
|
||||
} else if (event.key === "ArrowRight") {
|
||||
event.preventDefault();
|
||||
replay.step(1);
|
||||
} else if (event.key === " ") {
|
||||
event.preventDefault();
|
||||
replay.togglePlay();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span>{replay.position} / {replay.length}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { Badge } from "./components/badge.js";
|
||||
import { Button } from "./components/button.js";
|
||||
import { runtimeRequestSubmission, type TranscriptRequestEntry } from "../browser/transcript-model.js";
|
||||
|
||||
const KIND_LABELS: Record<string, string> = {
|
||||
command_approval: "Command",
|
||||
file_approval: "File change",
|
||||
permission_approval: "Permission",
|
||||
user_input: "Input",
|
||||
elicitation: "Input",
|
||||
};
|
||||
|
||||
const ACTION_LABELS: Record<string, string> = {
|
||||
accept: "Approve",
|
||||
accept_for_session: "Approve for session",
|
||||
decline: "Reject",
|
||||
cancel: "Cancel",
|
||||
submit: "Submit",
|
||||
};
|
||||
|
||||
const STATUS_TONE = {
|
||||
pending: "accent",
|
||||
resolved: "success",
|
||||
expired: "warning",
|
||||
cancelled: "warning",
|
||||
} as const;
|
||||
|
||||
function detailBlock(details: Record<string, unknown>): string | null {
|
||||
const command = details.command;
|
||||
if (typeof command === "string") return command;
|
||||
const diff = details.diff;
|
||||
if (typeof diff === "string") {
|
||||
const path = typeof details.path === "string" ? `${details.path}\n` : "";
|
||||
return `${path}${diff}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One card renders all five request kinds. Actions come from the upstream
|
||||
* request contract, so the card can never invent an affordance the runner
|
||||
* cannot honour, and the first click locks the row until the canonical
|
||||
* resolved event arrives.
|
||||
*/
|
||||
export function RequestCard({
|
||||
request,
|
||||
onResolve,
|
||||
focusRef,
|
||||
renderRequestDetail,
|
||||
}: {
|
||||
request: TranscriptRequestEntry;
|
||||
onResolve: (resolution: Record<string, unknown>) => void;
|
||||
focusRef?: React.Ref<HTMLLIElement>;
|
||||
renderRequestDetail?: (request: TranscriptRequestEntry) => React.ReactNode;
|
||||
}) {
|
||||
const [answer, setAnswer] = React.useState("");
|
||||
const [resolving, setResolving] = React.useState(false);
|
||||
const answerId = React.useId();
|
||||
const kindLabel = KIND_LABELS[request.requestKind] ?? "Request";
|
||||
const pending = request.status === "pending";
|
||||
const isInput = request.requestKind === "user_input" || request.requestKind === "elicitation";
|
||||
const payload = detailBlock(request.details);
|
||||
const detail = renderRequestDetail?.(request)
|
||||
?? (payload === null ? null : <pre className="pcr-payload">{payload}</pre>);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!pending) setResolving(false);
|
||||
}, [pending]);
|
||||
|
||||
function submit(resolution: Record<string, unknown>) {
|
||||
setResolving(true);
|
||||
onResolve(resolution);
|
||||
}
|
||||
|
||||
if (!pending) {
|
||||
return (
|
||||
<li
|
||||
ref={focusRef}
|
||||
data-slot="request-card"
|
||||
className="pcr-request pcr-request--settled"
|
||||
data-status={request.status}
|
||||
data-testid="request-card"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<details>
|
||||
<summary className="pcr-request-summary">
|
||||
<span>
|
||||
{kindLabel} · {request.status === "resolved"
|
||||
? `resolved — ${ACTION_LABELS[request.resolvedAction ?? ""] ?? request.resolvedAction ?? "unknown"}`
|
||||
: request.status === "expired"
|
||||
? "expired before response"
|
||||
: "cancelled"}
|
||||
</span>
|
||||
<Badge tone={STATUS_TONE[request.status]}>{request.status}</Badge>
|
||||
</summary>
|
||||
<p className="pcr-request-prompt">{request.prompt}</p>
|
||||
<p className="pcr-request-meta">
|
||||
<code>{request.requestId}</code>
|
||||
{request.resolvedAt === null ? null : <time dateTime={request.resolvedAt}>{request.resolvedAt}</time>}
|
||||
</p>
|
||||
{detail}
|
||||
</details>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<li
|
||||
ref={focusRef}
|
||||
data-slot="request-card"
|
||||
className="pcr-request"
|
||||
data-status="pending"
|
||||
data-testid="request-card"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<div className="pcr-request-header">
|
||||
<span className="pcr-request-kind">{kindLabel}</span>
|
||||
<Badge tone="accent">pending</Badge>
|
||||
</div>
|
||||
<p className="pcr-request-prompt">{request.prompt}</p>
|
||||
<p className="pcr-request-meta">
|
||||
<code>{request.requestId}</code>
|
||||
</p>
|
||||
{detail}
|
||||
{isInput ? (
|
||||
<div className="pcr-request-input">
|
||||
<label htmlFor={answerId}>Answer</label>
|
||||
<input
|
||||
id={answerId}
|
||||
data-testid="request-answer"
|
||||
value={answer}
|
||||
disabled={resolving}
|
||||
onChange={(event) => setAnswer(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="pcr-request-actions">
|
||||
{request.actions.map((action) => (
|
||||
<Button
|
||||
key={action}
|
||||
type="button"
|
||||
className={
|
||||
action === "decline"
|
||||
? "pcr-button--danger"
|
||||
: action === "cancel"
|
||||
? "pcr-button--quiet"
|
||||
: action === "accept" || action === "submit"
|
||||
? ""
|
||||
: "pcr-button--secondary"
|
||||
}
|
||||
disabled={resolving || (action === "submit" && answer.trim().length === 0)}
|
||||
data-testid={`request-action-${action}`}
|
||||
onClick={() =>
|
||||
submit(
|
||||
action === "submit"
|
||||
? runtimeRequestSubmission(request.requestKind, answer)
|
||||
: { action },
|
||||
)
|
||||
}
|
||||
>
|
||||
{ACTION_LABELS[action] ?? action}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
{resolving ? (
|
||||
<p className="pcr-request-resolving" role="status">
|
||||
Resolving — waiting for the canonical resolved event.
|
||||
</p>
|
||||
) : null}
|
||||
</li>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { RunnerClientOptions } from "../browser/client.js";
|
||||
import type { SessionItemSnapshot } from "../reducer/session-reducer.js";
|
||||
import type { TranscriptRequestEntry } from "../browser/transcript-model.js";
|
||||
import { Badge } from "./components/badge.js";
|
||||
import { Banner, BannerActions, BannerText } from "./components/banner.js";
|
||||
import { Button } from "./components/button.js";
|
||||
import { Composer } from "./components/composer.js";
|
||||
import { Conversation } from "./components/conversation.js";
|
||||
import { Dialog } from "./components/dialog.js";
|
||||
import { Tabs } from "./components/tabs.js";
|
||||
import { ConnectionBanner } from "./connection-banner.js";
|
||||
import { Inspector } from "./inspector.js";
|
||||
import { ReplayControls } from "./replay-controls.js";
|
||||
import { ConnectionBlock, GoalBlock, LineageTree, ManifestList } from "./session-timeline.js";
|
||||
import { useRunnerConsole } from "./use-runner-console.js";
|
||||
|
||||
const MOBILE_SEGMENTS = [
|
||||
{ id: "chat", label: "Chat" },
|
||||
{ id: "session", label: "Session" },
|
||||
{ id: "inspector", label: "Inspector" },
|
||||
];
|
||||
const COMPACT_QUERY = "(max-width: 56.25rem)";
|
||||
|
||||
function useCompactLayout(): boolean {
|
||||
const [compact, setCompact] = React.useState(
|
||||
() => typeof window !== "undefined" && window.matchMedia?.(COMPACT_QUERY).matches === true,
|
||||
);
|
||||
React.useEffect(() => {
|
||||
const query = window.matchMedia?.(COMPACT_QUERY);
|
||||
if (query === undefined) return undefined;
|
||||
const onChange = () => setCompact(query.matches);
|
||||
onChange();
|
||||
query.addEventListener("change", onChange);
|
||||
return () => query.removeEventListener("change", onChange);
|
||||
}, []);
|
||||
return compact;
|
||||
}
|
||||
|
||||
export interface RunnerConsoleAppProps extends RunnerClientOptions {
|
||||
renderItemBody?: (item: SessionItemSnapshot) => React.ReactNode;
|
||||
renderRequestDetail?: (request: TranscriptRequestEntry) => React.ReactNode;
|
||||
}
|
||||
|
||||
export function RunnerConsoleApp({
|
||||
baseUrl,
|
||||
fetchImpl,
|
||||
eventSourceFactory,
|
||||
renderItemBody,
|
||||
renderRequestDetail,
|
||||
}: RunnerConsoleAppProps = {}) {
|
||||
const console_ = useRunnerConsole({ baseUrl, fetchImpl, eventSourceFactory });
|
||||
const [draft, setDraft] = React.useState("");
|
||||
const [inspectorOpen, setInspectorOpen] = React.useState(false);
|
||||
const [segment, setSegment] = React.useState("chat");
|
||||
const [resetOpen, setResetOpen] = React.useState(false);
|
||||
const [highlightItemId, setHighlightItemId] = React.useState<string | null>(null);
|
||||
const [outcomes, setOutcomes] = React.useState<Record<string, string>>({});
|
||||
const compact = useCompactLayout();
|
||||
const manifestId = console_.state?.manifest ?? console_.selectedManifestId;
|
||||
const activeManifest = console_.manifests.find((entry) => entry.id === manifestId);
|
||||
const pendingRequests = console_.transcript.filter(
|
||||
(entry): entry is TranscriptRequestEntry => entry.kind === "request" && entry.status === "pending",
|
||||
);
|
||||
const viewingChild = console_.selectedThreadId !== null;
|
||||
|
||||
React.useEffect(() => {
|
||||
const stored = window.localStorage.getItem("paperclip-runner.sdk.v1.inspector");
|
||||
if (stored === "open") setInspectorOpen(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
window.localStorage.setItem(
|
||||
"paperclip-runner.sdk.v1.inspector",
|
||||
inspectorOpen ? "open" : "closed",
|
||||
);
|
||||
}, [inspectorOpen]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const snapshot = console_.snapshot;
|
||||
if (snapshot === null || console_.state === null) return;
|
||||
if (!["completed", "failed", "interrupted", "cancelled"].includes(snapshot.turnState)) return;
|
||||
setOutcomes((current) => ({
|
||||
...current,
|
||||
[console_.state!.manifest ?? "unknown"]: snapshot.turnState === "completed" ? "passed observations" : snapshot.turnState,
|
||||
}));
|
||||
}, [console_.snapshot, console_.state]);
|
||||
|
||||
function submit() {
|
||||
const text = draft.trim();
|
||||
if (text.length === 0) return;
|
||||
setDraft("");
|
||||
if (console_.composer === "active-turn") void console_.steer(text);
|
||||
else void console_.send(text);
|
||||
}
|
||||
|
||||
function runManifest(id: string) {
|
||||
const manifest = console_.manifests.find((entry) => entry.id === id);
|
||||
void console_.start({ manifestId: id, message: manifest?.prompt ?? "Run the demo." });
|
||||
}
|
||||
|
||||
const transcript = (
|
||||
<>
|
||||
<Conversation
|
||||
entries={console_.transcript}
|
||||
highlightItemId={highlightItemId}
|
||||
onResolveRequest={(request, resolution) => void console_.resolve(request.requestId, request.turnId ?? "", resolution)}
|
||||
{...(renderItemBody === undefined ? {} : { renderItemBody })}
|
||||
{...(renderRequestDetail === undefined ? {} : { renderRequestDetail })}
|
||||
/>
|
||||
<ul className="pcr-steering-list" aria-label="Steering messages">
|
||||
{console_.steeringChips.map((chip) => (
|
||||
<li key={chip.id} className="pcr-steering-chip" data-status={chip.status} data-testid="steering-chip">
|
||||
<Badge tone={chip.status === "acknowledged" ? "success" : chip.status === "pending" ? "accent" : "danger"}>{chip.status}</Badge>
|
||||
<span>{chip.text}</span>
|
||||
{chip.status === "rejected" || chip.status === "failed" ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
data-testid="steer-as-new-message"
|
||||
onClick={() => {
|
||||
console_.dismissSteeringChip(chip.id);
|
||||
void console_.send(chip.text);
|
||||
}}
|
||||
>
|
||||
Send as new message
|
||||
</Button>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
);
|
||||
const composer = (
|
||||
<div className="pcr-composer-region">
|
||||
<ConnectionBanner connection={console_.connection} reconnectAttempt={console_.reconnectAttempt} onRetry={console_.retryNow} />
|
||||
{pendingRequests.length > 0 ? (
|
||||
<Banner tone="accent" assertive data-testid="pending-request-banner">
|
||||
<BannerText>{pendingRequests.length} request waiting — Review</BannerText>
|
||||
<BannerActions><Button type="button" onClick={() => document.querySelector<HTMLElement>('[data-slot="request-card"]')?.focus()}>Review</Button></BannerActions>
|
||||
</Banner>
|
||||
) : null}
|
||||
<Composer
|
||||
state={console_.composer}
|
||||
value={draft}
|
||||
onValueChange={setDraft}
|
||||
onSubmit={submit}
|
||||
onStop={() => void console_.interrupt()}
|
||||
disabledReason={viewingChild
|
||||
? "Direct steering of child threads is not supported by this app-server."
|
||||
: null}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
const sessionRail = (
|
||||
<>
|
||||
<ManifestList manifests={console_.manifests} selectedId={console_.selectedManifestId} onSelect={console_.selectManifest} onRun={runManifest} lastOutcome={outcomes} disabled={console_.busy} />
|
||||
<ConnectionBlock connection={console_.connection} reconnectAttempt={console_.reconnectAttempt} state={console_.state} pendingRequestCount={pendingRequests.length} onDrop={console_.dropConnection} onRetry={console_.retryNow} onScrollToOldestRequest={() => document.querySelector<HTMLElement>('[data-slot="request-card"]')?.focus()} />
|
||||
<GoalBlock state={console_.state} onGoal={(operation, body) => void console_.goal(operation, body)} />
|
||||
<LineageTree lineage={console_.state?.lineage ?? []} selectedThreadId={console_.selectedThreadId} onSelect={console_.selectThread} />
|
||||
<section data-slot="observation-checklist" className="pcr-panel">
|
||||
<h2 className="pcr-panel-title">Demo state</h2>
|
||||
<Button type="button" variant="ghost" onClick={() => setResetOpen(true)}>Reset demo state</Button>
|
||||
{activeManifest === undefined ? null : (
|
||||
<ol className="pcr-observation-list" data-testid="observation-checklist">
|
||||
{activeManifest.expectedObservations.map((observation) => (
|
||||
<li key={observation}>
|
||||
<label><input type="checkbox" /> {observation}</label>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
const inspector = (
|
||||
<Inspector events={console_.events} snapshot={console_.snapshot} state={console_.state} connection={console_.connection} reconnectAttempt={console_.reconnectAttempt} replayParity={console_.replayParity} highlightItemId={highlightItemId} onHighlightItem={setHighlightItemId} />
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="pcr-root pcr-console" data-slot="runner-console-app" data-inspector={inspectorOpen}>
|
||||
<header className="pcr-console-header">
|
||||
<div className="pcr-console-title">
|
||||
{console_.replay.active ? <Badge tone="warning" data-testid="replay-badge">REPLAY</Badge> : null}
|
||||
<h1>{activeManifest?.name ?? "Runner reference console"}</h1>
|
||||
{viewingChild ? (
|
||||
<p className="pcr-breadcrumb" data-testid="breadcrumb">
|
||||
root ▸ {console_.selectedThreadId}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="pcr-console-header-actions">
|
||||
<Button type="button" variant="ghost" data-testid="toggle-inspector" aria-pressed={inspectorOpen} onClick={() => setInspectorOpen((current) => !current)}>Protocol inspector</Button>
|
||||
<Button type="button" variant="ghost" data-testid="toggle-replay" aria-pressed={console_.replay.active} disabled={console_.events.length === 0} onClick={() => console_.replay.active ? console_.replay.exit() : console_.replay.enter()}>{console_.replay.active ? "Exit replay" : "Replay"}</Button>
|
||||
</div>
|
||||
</header>
|
||||
<p className="pcr-visually-hidden" aria-live="polite" data-testid="runner-announcement">{console_.announcement}</p>
|
||||
{console_.error === null ? null : <Banner tone="danger" assertive><BannerText>{console_.error}</BannerText></Banner>}
|
||||
{console_.replay.active ? <ReplayControls replay={console_.replay} /> : null}
|
||||
{compact ? (
|
||||
<div className="pcr-console-mobile">
|
||||
<Tabs items={MOBILE_SEGMENTS} selected={segment} onSelect={setSegment} label="Console sections">
|
||||
{segment === "chat" ? <>{transcript}{composer}</> : null}
|
||||
{segment === "session" ? sessionRail : null}
|
||||
{segment === "inspector" ? inspector : null}
|
||||
</Tabs>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pcr-console-desktop">
|
||||
<aside className="pcr-console-rail">{sessionRail}</aside>
|
||||
<main className="pcr-console-center">{transcript}{composer}</main>
|
||||
{inspectorOpen ? <aside className="pcr-console-inspector" aria-label="Protocol inspector">{inspector}</aside> : null}
|
||||
</div>
|
||||
)}
|
||||
<Dialog open={resetOpen} onClose={() => setResetOpen(false)} title="Reset demo state" description="Discards the current live session transcript. Recorded evidence files are not touched." footer={<><Button type="button" variant="ghost" onClick={() => setResetOpen(false)}>Keep the session</Button><Button type="button" variant="danger" data-testid="reset-confirm" onClick={() => { setResetOpen(false); setDraft(""); void console_.reset(); }}>Reset demo state</Button></>} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import * as React from "react";
|
||||
|
||||
import type { SessionSnapshot } from "../reducer/session-reducer.js";
|
||||
import { Badge } from "./components/badge.js";
|
||||
import { Button } from "./components/button.js";
|
||||
import { Dialog } from "./components/dialog.js";
|
||||
import { Menu } from "./components/menu.js";
|
||||
import type {
|
||||
GoalOperation,
|
||||
ConnectionStatus,
|
||||
RunnerLineageEntry,
|
||||
ManifestSummary,
|
||||
RunnerSessionState,
|
||||
} from "../browser/protocol.js";
|
||||
import { GOAL_OPERATION_LABELS } from "../browser/protocol.js";
|
||||
import { goalAvailability } from "../browser/transcript-model.js";
|
||||
|
||||
const CONNECTION_TONE = {
|
||||
idle: "neutral",
|
||||
connecting: "accent",
|
||||
connected: "success",
|
||||
reconnecting: "warning",
|
||||
disconnected: "danger",
|
||||
terminal: "neutral",
|
||||
} as const;
|
||||
|
||||
const THREAD_STATE_LABEL: Record<string, string> = {
|
||||
starting: "Starting",
|
||||
running: "Running",
|
||||
completed: "Completed",
|
||||
failed: "Failed",
|
||||
};
|
||||
|
||||
/** Reducer-authoritative timeline, lineage, and goal summary. */
|
||||
export function SessionTimeline({
|
||||
snapshot,
|
||||
state,
|
||||
}: {
|
||||
snapshot: SessionSnapshot | null;
|
||||
state: RunnerSessionState | null;
|
||||
}) {
|
||||
const headingId = React.useId();
|
||||
return (
|
||||
<section data-slot="session-timeline" className="pcr-panel" aria-labelledby={headingId}>
|
||||
<h2 id={headingId} className="pcr-panel-title">Session timeline</h2>
|
||||
<dl className="pcr-identity-grid">
|
||||
<div><dt>Goal</dt><dd>{state?.goal?.objective ?? "No goal set"}</dd></div>
|
||||
<div><dt>State</dt><dd>{snapshot?.sessionState ?? "not started"}</dd></div>
|
||||
</dl>
|
||||
<ol className="pcr-timeline" data-testid="session-timeline">
|
||||
{(snapshot?.timeline ?? []).map((entry) => (
|
||||
<li key={entry.sourceEventId}>
|
||||
<code>{entry.sourceSeq}</code><span>{entry.summary}</span>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ManifestList({
|
||||
manifests,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onRun,
|
||||
lastOutcome,
|
||||
disabled,
|
||||
}: {
|
||||
manifests: readonly ManifestSummary[];
|
||||
selectedId: string;
|
||||
onSelect: (id: string) => void;
|
||||
onRun: (id: string) => void;
|
||||
lastOutcome: Record<string, string>;
|
||||
disabled: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section data-slot="manifest-list" className="pcr-panel" aria-labelledby="manifest-heading">
|
||||
<h2 id="manifest-heading" className="pcr-panel-title">
|
||||
Demo chats
|
||||
</h2>
|
||||
<ul className="pcr-manifest-list" data-testid="manifest-list">
|
||||
{manifests.map((manifest) => (
|
||||
<li key={manifest.id} data-selected={manifest.id === selectedId}>
|
||||
<button
|
||||
type="button"
|
||||
className="pcr-manifest-row"
|
||||
aria-pressed={manifest.id === selectedId}
|
||||
data-testid={`manifest-${manifest.id}`}
|
||||
onClick={() => onSelect(manifest.id)}
|
||||
>
|
||||
<span className="pcr-manifest-name">{manifest.name}</span>
|
||||
<span className="pcr-manifest-purpose">{manifest.purpose}</span>
|
||||
<span className="pcr-manifest-badges">
|
||||
{manifest.scenarios.map((scenario) => (
|
||||
<Badge key={scenario}>{scenario}</Badge>
|
||||
))}
|
||||
<Badge tone={lastOutcome[manifest.id] === undefined ? "neutral" : "success"}>
|
||||
{lastOutcome[manifest.id] ?? "not run"}
|
||||
</Badge>
|
||||
</span>
|
||||
</button>
|
||||
{manifest.id === selectedId ? (
|
||||
<>
|
||||
<details className="pcr-disclosure" data-testid="expected-observations">
|
||||
<summary className="pcr-disclosure-summary">Expected observations</summary>
|
||||
<ol className="pcr-observation-list">
|
||||
{manifest.expectedObservations.map((observation) => (
|
||||
<li key={observation}>{observation}</li>
|
||||
))}
|
||||
</ol>
|
||||
</details>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-testid="run-manifest"
|
||||
onClick={() => onRun(manifest.id)}
|
||||
>
|
||||
Run this demo chat
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function ConnectionBlock({
|
||||
connection,
|
||||
reconnectAttempt,
|
||||
state,
|
||||
pendingRequestCount,
|
||||
onDrop,
|
||||
onRetry,
|
||||
onScrollToOldestRequest,
|
||||
}: {
|
||||
connection: ConnectionStatus;
|
||||
reconnectAttempt: number;
|
||||
state: RunnerSessionState | null;
|
||||
pendingRequestCount: number;
|
||||
onDrop: () => void;
|
||||
onRetry: () => void;
|
||||
onScrollToOldestRequest: () => void;
|
||||
}) {
|
||||
return (
|
||||
<section data-slot="connection-block" className="pcr-panel" aria-labelledby="connection-heading">
|
||||
<h2 id="connection-heading" className="pcr-panel-title">
|
||||
Session
|
||||
</h2>
|
||||
<div className="pcr-status-row">
|
||||
<span>Connection</span>
|
||||
<Badge tone={CONNECTION_TONE[connection]} data-testid="connection-status">
|
||||
{connection === "reconnecting" ? `reconnecting (attempt ${reconnectAttempt})` : connection}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="pcr-status-row">
|
||||
<span>Pending requests</span>
|
||||
<button
|
||||
type="button"
|
||||
className="pcr-chip"
|
||||
data-testid="pending-request-chip"
|
||||
disabled={pendingRequestCount === 0}
|
||||
onClick={onScrollToOldestRequest}
|
||||
>
|
||||
{pendingRequestCount}
|
||||
</button>
|
||||
</div>
|
||||
<div className="pcr-status-row">
|
||||
<span>Credentials in browser</span>
|
||||
<Badge tone={state?.credentialsExposed === true ? "danger" : "success"}>
|
||||
{state?.credentialsExposed === true ? "exposed" : "none"}
|
||||
</Badge>
|
||||
</div>
|
||||
<dl className="pcr-identity-grid">
|
||||
<div>
|
||||
<dt>Run</dt>
|
||||
<dd>{state?.runId ?? "—"}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Session</dt>
|
||||
<dd>{state?.normalizedSessionId ?? "—"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="pcr-panel-actions">
|
||||
<Button
|
||||
type="button"
|
||||
className="pcr-button--quiet"
|
||||
data-testid="drop-connection"
|
||||
disabled={state === null || connection === "disconnected"}
|
||||
onClick={onDrop}
|
||||
>
|
||||
Simulate connection loss
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
className="pcr-button--quiet"
|
||||
data-testid="resume-session"
|
||||
disabled={state === null}
|
||||
onClick={onRetry}
|
||||
>
|
||||
Resume this session
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function GoalBlock({
|
||||
state,
|
||||
onGoal,
|
||||
}: {
|
||||
state: RunnerSessionState | null;
|
||||
onGoal: (operation: GoalOperation, body?: Record<string, unknown>) => void;
|
||||
}) {
|
||||
const [dialogOpen, setDialogOpen] = React.useState(false);
|
||||
const [objective, setObjective] = React.useState("");
|
||||
const objectiveId = React.useId();
|
||||
const triggerId = React.useId();
|
||||
const capabilities = state?.capabilities ?? {
|
||||
resume: false,
|
||||
typedEvents: false,
|
||||
steering: false,
|
||||
interruption: false,
|
||||
structuredResult: false,
|
||||
};
|
||||
const availability = goalAvailability(capabilities, state?.goal?.status ?? null);
|
||||
const unsupported = capabilities.goals !== true;
|
||||
const unsupportedReason = availability[0]?.reason ?? null;
|
||||
|
||||
return (
|
||||
<section data-slot="goal-block" className="pcr-panel" aria-labelledby="goal-heading">
|
||||
<div className="pcr-panel-header">
|
||||
<h2 id="goal-heading" className="pcr-panel-title">
|
||||
Goal
|
||||
</h2>
|
||||
<Menu
|
||||
triggerId={triggerId}
|
||||
label="Goal"
|
||||
disabled={unsupported || state === null}
|
||||
disabledReason={
|
||||
state === null ? "Start a session before changing the goal." : unsupportedReason
|
||||
}
|
||||
items={availability.map((entry) => ({
|
||||
id: entry.operation,
|
||||
label: GOAL_OPERATION_LABELS[entry.operation],
|
||||
enabled: entry.enabled,
|
||||
reason: entry.reason,
|
||||
}))}
|
||||
onSelect={(id) => {
|
||||
if (id === "set") setDialogOpen(true);
|
||||
else onGoal(id as GoalOperation);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{state === null ? null : state.goal === null ? (
|
||||
<p className="pcr-goal-empty" data-testid="goal-banner">
|
||||
{unsupported ? "Goal operations are unsupported here." : "No goal set"}
|
||||
</p>
|
||||
) : (
|
||||
<div className="pcr-goal-banner" data-testid="goal-banner">
|
||||
<p className="pcr-goal-objective">{state.goal.objective}</p>
|
||||
<div className="pcr-status-row">
|
||||
<Badge tone={state.goal.status === "active" ? "success" : "warning"}>
|
||||
{state.goal.status}
|
||||
</Badge>
|
||||
<time dateTime={new Date(state.goal.updatedAt).toISOString()}>
|
||||
{new Date(state.goal.updatedAt).toISOString().slice(11, 19)}
|
||||
</time>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<Dialog
|
||||
open={dialogOpen}
|
||||
onClose={() => setDialogOpen(false)}
|
||||
title="Set goal"
|
||||
description="The goal is durable on the thread and every change is also a transcript event."
|
||||
footer={
|
||||
<>
|
||||
<Button type="button" className="pcr-button--quiet" onClick={() => setDialogOpen(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
data-testid="goal-set-confirm"
|
||||
disabled={objective.trim().length === 0}
|
||||
onClick={() => {
|
||||
onGoal("set", { objective: objective.trim() });
|
||||
setObjective("");
|
||||
setDialogOpen(false);
|
||||
}}
|
||||
>
|
||||
Set goal
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<label htmlFor={objectiveId}>Objective</label>
|
||||
<textarea
|
||||
id={objectiveId}
|
||||
className="pcr-dialog-input"
|
||||
data-testid="goal-objective"
|
||||
rows={3}
|
||||
value={objective}
|
||||
onChange={(event) => setObjective(event.target.value)}
|
||||
/>
|
||||
</Dialog>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineageTree({
|
||||
lineage,
|
||||
selectedThreadId,
|
||||
onSelect,
|
||||
}: {
|
||||
lineage: readonly RunnerLineageEntry[];
|
||||
selectedThreadId: string | null;
|
||||
onSelect: (threadId: string | null) => void;
|
||||
}) {
|
||||
const rootId = lineage.find((entry) => entry.depth === 0)?.threadId ?? null;
|
||||
const current = selectedThreadId ?? rootId;
|
||||
const treeRef = React.useRef<HTMLUListElement>(null);
|
||||
|
||||
function onKeyDown(event: React.KeyboardEvent<HTMLUListElement>) {
|
||||
const rows = [
|
||||
...(treeRef.current?.querySelectorAll<HTMLButtonElement>('[role="treeitem"]') ?? []),
|
||||
];
|
||||
const index = rows.indexOf(document.activeElement as HTMLButtonElement);
|
||||
if (event.key === "ArrowDown") {
|
||||
event.preventDefault();
|
||||
rows[Math.min(index + 1, rows.length - 1)]?.focus();
|
||||
} else if (event.key === "ArrowUp") {
|
||||
event.preventDefault();
|
||||
rows[Math.max(index - 1, 0)]?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section data-slot="lineage-tree" className="pcr-panel" aria-labelledby="lineage-heading">
|
||||
<h2 id="lineage-heading" className="pcr-panel-title">
|
||||
Threads
|
||||
</h2>
|
||||
<ul
|
||||
ref={treeRef}
|
||||
role="tree"
|
||||
aria-label="Session thread lineage"
|
||||
className="pcr-tree"
|
||||
data-testid="lineage-tree"
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{lineage.length === 0 ? <li className="pcr-tree-empty">No threads yet.</li> : null}
|
||||
{lineage.map((entry) => (
|
||||
<li key={entry.threadId} role="none" data-depth={entry.depth}>
|
||||
<button
|
||||
type="button"
|
||||
role="treeitem"
|
||||
aria-current={entry.threadId === current}
|
||||
aria-selected={entry.threadId === current}
|
||||
className="pcr-tree-row"
|
||||
data-testid={`thread-${entry.threadId}`}
|
||||
onClick={() => onSelect(entry.depth === 0 ? null : entry.threadId)}
|
||||
>
|
||||
<span className="pcr-state-dot" data-state={entry.status} aria-hidden="true" />
|
||||
<code>{entry.nickname ?? entry.threadId}</code>
|
||||
<span className="pcr-tree-state">
|
||||
{THREAD_STATE_LABEL[entry.status] ?? entry.status}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,594 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import type { PrpEvent } from "../protocol/phase1-contract.js";
|
||||
import {
|
||||
createSessionSnapshotFromMetadata,
|
||||
reduceSessionEvents,
|
||||
type SessionSnapshot,
|
||||
} from "../reducer/session-reducer.js";
|
||||
import {
|
||||
createRunnerClient,
|
||||
RunnerClientError,
|
||||
type EventStreamHandle,
|
||||
type RunnerClientOptions,
|
||||
} from "../browser/client.js";
|
||||
import type {
|
||||
GoalOperation,
|
||||
ConnectionStatus,
|
||||
ManifestSummary,
|
||||
RunnerSessionState,
|
||||
} from "../browser/protocol.js";
|
||||
import {
|
||||
buildTranscript,
|
||||
composerState,
|
||||
type ComposerState,
|
||||
type TranscriptEntry,
|
||||
} from "../browser/transcript-model.js";
|
||||
|
||||
const SESSION_STORAGE_KEY = "paperclip-runner.sdk.v1.session";
|
||||
const RECONNECT_DELAY_MS = 1500;
|
||||
const MAX_RECONNECT_ATTEMPTS = 5;
|
||||
|
||||
export type SteeringChipStatus = "pending" | "acknowledged" | "rejected" | "failed";
|
||||
|
||||
export interface SteeringChip {
|
||||
id: string;
|
||||
text: string;
|
||||
expectedTurnId: string;
|
||||
status: SteeringChipStatus;
|
||||
detail: string | null;
|
||||
}
|
||||
|
||||
function reduceEvents(
|
||||
state: RunnerSessionState | null,
|
||||
events: readonly PrpEvent[],
|
||||
): SessionSnapshot | null {
|
||||
if (state === null) return null;
|
||||
const seed = createSessionSnapshotFromMetadata({
|
||||
fixtureName: state.snapshot.fixtureName,
|
||||
identity: state.snapshot.identity,
|
||||
capabilities: state.snapshot.capabilities,
|
||||
});
|
||||
return reduceSessionEvents(seed, events);
|
||||
}
|
||||
|
||||
function errorMessage(cause: unknown): string {
|
||||
if (cause instanceof RunnerClientError) return cause.message;
|
||||
return cause instanceof Error ? cause.message : String(cause);
|
||||
}
|
||||
|
||||
export interface RunnerConsole {
|
||||
manifests: ManifestSummary[];
|
||||
selectedManifestId: string;
|
||||
selectManifest: (id: string) => void;
|
||||
state: RunnerSessionState | null;
|
||||
events: PrpEvent[];
|
||||
snapshot: SessionSnapshot | null;
|
||||
transcript: TranscriptEntry[];
|
||||
connection: ConnectionStatus;
|
||||
reconnectAttempt: number;
|
||||
replayParity: boolean | null;
|
||||
composer: ComposerState;
|
||||
steeringChips: SteeringChip[];
|
||||
announcement: string;
|
||||
error: string | null;
|
||||
busy: boolean;
|
||||
selectedThreadId: string | null;
|
||||
selectThread: (threadId: string | null) => void;
|
||||
replay: {
|
||||
active: boolean;
|
||||
position: number;
|
||||
length: number;
|
||||
playing: boolean;
|
||||
enter: () => void;
|
||||
exit: () => void;
|
||||
setPosition: (position: number) => void;
|
||||
togglePlay: () => void;
|
||||
step: (delta: number) => void;
|
||||
};
|
||||
start: (input: { manifestId: string; message: string }) => Promise<void>;
|
||||
send: (text: string) => Promise<void>;
|
||||
steer: (text: string) => Promise<void>;
|
||||
interrupt: () => Promise<void>;
|
||||
resolve: (
|
||||
requestId: string,
|
||||
turnId: string,
|
||||
resolution: Record<string, unknown>,
|
||||
) => Promise<void>;
|
||||
goal: (operation: GoalOperation, body?: Record<string, unknown>) => Promise<void>;
|
||||
dropConnection: () => void;
|
||||
retryNow: () => void;
|
||||
reset: () => Promise<void>;
|
||||
dismissSteeringChip: (id: string) => void;
|
||||
}
|
||||
|
||||
export function useRunnerConsole(options: RunnerClientOptions = {}): RunnerConsole {
|
||||
const runnerClient = useMemo(
|
||||
() => createRunnerClient(options),
|
||||
[options.baseUrl, options.eventSourceFactory, options.fetchImpl],
|
||||
);
|
||||
const [manifests, setManifests] = useState<ManifestSummary[]>([]);
|
||||
const [selectedManifestId, setSelectedManifestId] = useState("completion");
|
||||
const [state, setState] = useState<RunnerSessionState | null>(null);
|
||||
const [events, setEvents] = useState<PrpEvent[]>([]);
|
||||
const [connection, setConnection] = useState<ConnectionStatus>("idle");
|
||||
const [reconnectAttempt, setReconnectAttempt] = useState(0);
|
||||
const [steeringChips, setSteeringChips] = useState<SteeringChip[]>([]);
|
||||
const [announcement, setAnnouncement] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [interrupting, setInterrupting] = useState(false);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [selectedThreadId, setSelectedThreadId] = useState<string | null>(null);
|
||||
const [replayActive, setReplayActive] = useState(false);
|
||||
const [replayPosition, setReplayPosition] = useState(0);
|
||||
const [replayPlaying, setReplayPlaying] = useState(false);
|
||||
|
||||
const streamRef = useRef<EventStreamHandle | null>(null);
|
||||
const streamGenerationRef = useRef(0);
|
||||
const cursorRef = useRef(0);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const closeStream = useCallback(() => {
|
||||
streamGenerationRef.current += 1;
|
||||
streamRef.current?.close();
|
||||
streamRef.current = null;
|
||||
if (retryTimerRef.current !== null) {
|
||||
clearTimeout(retryTimerRef.current);
|
||||
retryTimerRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const subscribe = useCallback(
|
||||
(sessionId: string, attempt = 0) => {
|
||||
closeStream();
|
||||
const generation = streamGenerationRef.current;
|
||||
setConnection(attempt === 0 ? "connecting" : "reconnecting");
|
||||
setReconnectAttempt(attempt);
|
||||
const open = () => {
|
||||
if (streamGenerationRef.current !== generation) return;
|
||||
streamRef.current = runnerClient.openEventStream(sessionId, cursorRef.current, {
|
||||
onOpen: () => {
|
||||
setConnection("connected");
|
||||
setReconnectAttempt(0);
|
||||
setAnnouncement(attempt === 0 ? "Connected" : "Reconnected with gap recovery");
|
||||
},
|
||||
onEvent: (event) => {
|
||||
cursorRef.current += 1;
|
||||
// Preserve the exact canonical stream, including deliberate
|
||||
// duplicate ids. The shared reducer, not the hook, owns duplicate
|
||||
// detection and replay integrity.
|
||||
setEvents((current) => [...current, event]);
|
||||
},
|
||||
onError: () => {
|
||||
closeStream();
|
||||
if (attempt >= MAX_RECONNECT_ATTEMPTS) {
|
||||
setConnection("disconnected");
|
||||
setAnnouncement("Disconnected");
|
||||
return;
|
||||
}
|
||||
setConnection("reconnecting");
|
||||
setReconnectAttempt(attempt + 1);
|
||||
setAnnouncement(`Reconnecting, attempt ${attempt + 1}`);
|
||||
retryTimerRef.current = setTimeout(
|
||||
() => subscribe(sessionId, attempt + 1),
|
||||
RECONNECT_DELAY_MS,
|
||||
);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
if (attempt === 0) {
|
||||
open();
|
||||
return;
|
||||
}
|
||||
void runnerClient.readEvents(sessionId, cursorRef.current).then((history) => {
|
||||
if (streamGenerationRef.current !== generation) return;
|
||||
setEvents((current) => [...current, ...history.events]);
|
||||
cursorRef.current = history.cursor;
|
||||
open();
|
||||
}).catch(open);
|
||||
},
|
||||
[closeStream, runnerClient],
|
||||
);
|
||||
|
||||
const adopt = useCallback(
|
||||
(next: RunnerSessionState) => {
|
||||
setState(next);
|
||||
setError(null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const attach = useCallback(
|
||||
async (sessionId: string, resume: boolean) => {
|
||||
const [next, history] = await Promise.all([
|
||||
runnerClient.readSession(sessionId),
|
||||
runnerClient.readEvents(sessionId, 0),
|
||||
]);
|
||||
sessionIdRef.current = sessionId;
|
||||
window.sessionStorage.setItem(SESSION_STORAGE_KEY, sessionId);
|
||||
adopt(next);
|
||||
setEvents(history.events);
|
||||
cursorRef.current = history.cursor;
|
||||
if (resume) setAnnouncement("Replayed the durable transcript");
|
||||
subscribe(sessionId, 0);
|
||||
},
|
||||
[adopt, runnerClient, subscribe],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void runnerClient
|
||||
.fetchManifests()
|
||||
.then((list) => {
|
||||
if (cancelled) return;
|
||||
setManifests(list);
|
||||
if (list[0] !== undefined) setSelectedManifestId((current) => current || list[0]!.id);
|
||||
})
|
||||
.catch((cause) => {
|
||||
if (!cancelled) setError(errorMessage(cause));
|
||||
});
|
||||
const stored = window.sessionStorage.getItem(SESSION_STORAGE_KEY);
|
||||
if (stored !== null) {
|
||||
void attach(stored, true).catch(() => {
|
||||
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
});
|
||||
}
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
// Runs once: session restoration is a mount concern.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => () => closeStream(), [closeStream]);
|
||||
|
||||
// Lineage, goal, pending requests, and the server cursor live in the public
|
||||
// state, so a stream that only carries events would leave them stale.
|
||||
useEffect(() => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null || events.length === 0) return undefined;
|
||||
const timer = setTimeout(() => {
|
||||
void runnerClient
|
||||
.readSession(sessionId)
|
||||
.then((next) => setState(next))
|
||||
.catch(() => undefined);
|
||||
}, 150);
|
||||
return () => clearTimeout(timer);
|
||||
}, [events.length, runnerClient]);
|
||||
|
||||
const liveSnapshot = useMemo(() => reduceEvents(state, events), [state, events]);
|
||||
const visibleEvents = useMemo(
|
||||
() => (replayActive ? events.slice(0, replayPosition) : events),
|
||||
[events, replayActive, replayPosition],
|
||||
);
|
||||
const snapshot = useMemo(
|
||||
() => (replayActive ? reduceEvents(state, visibleEvents) : liveSnapshot),
|
||||
[replayActive, state, visibleEvents, liveSnapshot],
|
||||
);
|
||||
|
||||
const replayParity = useMemo(() => {
|
||||
if (state === null || liveSnapshot === null) return null;
|
||||
if (state.cursor !== events.length) return null;
|
||||
return (
|
||||
JSON.stringify(liveSnapshot.timeline) === JSON.stringify(state.snapshot.timeline) &&
|
||||
liveSnapshot.integrity === state.snapshot.integrity
|
||||
);
|
||||
}, [state, liveSnapshot, events.length]);
|
||||
|
||||
const transcript = useMemo(
|
||||
() => (snapshot === null ? [] : buildTranscript(snapshot, visibleEvents)),
|
||||
[snapshot, visibleEvents],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!replayPlaying) return undefined;
|
||||
if (replayPosition >= events.length) {
|
||||
setReplayPlaying(false);
|
||||
return undefined;
|
||||
}
|
||||
const timer = setTimeout(() => setReplayPosition((current) => current + 1), 140);
|
||||
return () => clearTimeout(timer);
|
||||
}, [replayPlaying, replayPosition, events.length]);
|
||||
|
||||
// Steering chips resolve against canonical acknowledgements only.
|
||||
useEffect(() => {
|
||||
setSteeringChips((chips) => {
|
||||
if (chips.every((chip) => chip.status !== "pending")) return chips;
|
||||
const acknowledged = events.filter(
|
||||
(event) =>
|
||||
event.eventType === "item.completed" &&
|
||||
(event.payload as { kind?: string }).kind === "steering_acknowledgement",
|
||||
);
|
||||
let index = 0;
|
||||
return chips.map((chip) => {
|
||||
if (chip.status !== "pending") return chip;
|
||||
const match = acknowledged[index];
|
||||
index += 1;
|
||||
return match === undefined
|
||||
? chip
|
||||
: { ...chip, status: "acknowledged" as const, detail: null };
|
||||
});
|
||||
});
|
||||
}, [events]);
|
||||
|
||||
// The reducer is authoritative for the active turn. Falling back to the
|
||||
// last public-state read would keep a finished turn alive in the composer.
|
||||
const activeTurnId = snapshot === null ? (state?.activeTurnId ?? null) : snapshot.activeTurnId;
|
||||
|
||||
// A submitted turn that has not been accepted yet is a protocol fact, not a
|
||||
// local flag: it is what makes interrupt-before-start reachable.
|
||||
const awaitingTurnStart = useMemo(() => {
|
||||
let pending = false;
|
||||
for (const entry of snapshot?.timeline ?? []) {
|
||||
if (entry.eventType === "turn.submitted") pending = true;
|
||||
else if (entry.eventType.startsWith("turn.")) pending = false;
|
||||
}
|
||||
return pending;
|
||||
}, [snapshot]);
|
||||
|
||||
const composer = composerState({
|
||||
connection,
|
||||
submitting: submitting || awaitingTurnStart,
|
||||
interrupting,
|
||||
activeTurnId,
|
||||
sessionState: snapshot?.sessionState ?? "not_started",
|
||||
});
|
||||
|
||||
const guard = useCallback(async (task: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
try {
|
||||
await task();
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause));
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const start = useCallback(
|
||||
async (input: { manifestId: string; message: string }) => {
|
||||
await guard(async () => {
|
||||
closeStream();
|
||||
setEvents([]);
|
||||
cursorRef.current = 0;
|
||||
setSteeringChips([]);
|
||||
setReplayActive(false);
|
||||
setReplayPosition(0);
|
||||
setSelectedThreadId(null);
|
||||
setSubmitting(true);
|
||||
const manifest = manifests.find((entry) => entry.id === input.manifestId);
|
||||
try {
|
||||
const created = await runnerClient.createSession({
|
||||
manifest: input.manifestId,
|
||||
objective: manifest?.objective ?? "Run the runner console demo.",
|
||||
message: input.message,
|
||||
});
|
||||
sessionIdRef.current = created.sessionId;
|
||||
window.sessionStorage.setItem(SESSION_STORAGE_KEY, created.sessionId);
|
||||
adopt(created);
|
||||
setAnnouncement("Turn running");
|
||||
subscribe(created.sessionId, 0);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[adopt, closeStream, guard, manifests, runnerClient, subscribe],
|
||||
);
|
||||
|
||||
const refreshState = useCallback(async () => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) return;
|
||||
adopt(await runnerClient.readSession(sessionId));
|
||||
}, [adopt, runnerClient]);
|
||||
|
||||
const send = useCallback(
|
||||
async (text: string) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) {
|
||||
await start({ manifestId: selectedManifestId, message: text });
|
||||
return;
|
||||
}
|
||||
await guard(async () => {
|
||||
setSubmitting(true);
|
||||
try {
|
||||
adopt(await runnerClient.startTurn(sessionId, text));
|
||||
setAnnouncement("Turn running");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
});
|
||||
},
|
||||
[adopt, guard, runnerClient, selectedManifestId, start],
|
||||
);
|
||||
|
||||
const steer = useCallback(
|
||||
async (text: string) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
const expectedTurnId = activeTurnId;
|
||||
if (sessionId === null || expectedTurnId === null) return;
|
||||
const chipId = `steer-${Date.now()}-${steeringChips.length}`;
|
||||
setSteeringChips((chips) => [
|
||||
...chips,
|
||||
{ id: chipId, text, expectedTurnId, status: "pending", detail: null },
|
||||
]);
|
||||
try {
|
||||
adopt(await runnerClient.steerTurn(sessionId, expectedTurnId, text));
|
||||
setAnnouncement("Steering sent");
|
||||
} catch (cause) {
|
||||
const stale =
|
||||
cause instanceof RunnerClientError &&
|
||||
(cause.code === "stale_turn" || cause.code === "already_terminal");
|
||||
setSteeringChips((chips) =>
|
||||
chips.map((chip) =>
|
||||
chip.id === chipId
|
||||
? {
|
||||
...chip,
|
||||
status: stale ? "rejected" : "failed",
|
||||
detail: stale ? "turn already ended" : errorMessage(cause),
|
||||
}
|
||||
: chip,
|
||||
),
|
||||
);
|
||||
setAnnouncement(stale ? "Steering rejected: turn already ended" : "Steering failed");
|
||||
await refreshState().catch(() => undefined);
|
||||
}
|
||||
},
|
||||
[activeTurnId, adopt, refreshState, runnerClient, steeringChips.length],
|
||||
);
|
||||
|
||||
const interrupt = useCallback(async () => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) return;
|
||||
setInterrupting(true);
|
||||
setAnnouncement("Stopping");
|
||||
try {
|
||||
adopt(await runnerClient.interruptTurn(sessionId, activeTurnId));
|
||||
setAnnouncement("Turn stopped");
|
||||
} catch (cause) {
|
||||
setError(errorMessage(cause));
|
||||
} finally {
|
||||
setInterrupting(false);
|
||||
}
|
||||
}, [activeTurnId, adopt, runnerClient]);
|
||||
|
||||
const resolve = useCallback(
|
||||
async (requestId: string, turnId: string, resolution: Record<string, unknown>) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) return;
|
||||
await guard(async () => {
|
||||
adopt(await runnerClient.resolveRequest(sessionId, requestId, turnId, resolution));
|
||||
setAnnouncement("Request resolved");
|
||||
});
|
||||
},
|
||||
[adopt, guard, runnerClient],
|
||||
);
|
||||
|
||||
const goal = useCallback(
|
||||
async (operation: GoalOperation, body: Record<string, unknown> = {}) => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) return;
|
||||
await guard(async () => {
|
||||
adopt(await runnerClient.goalOperation(sessionId, operation, body));
|
||||
setAnnouncement(`Goal ${operation}`);
|
||||
});
|
||||
},
|
||||
[adopt, guard, runnerClient],
|
||||
);
|
||||
|
||||
const dropConnection = useCallback(() => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
closeStream();
|
||||
setConnection("reconnecting");
|
||||
setReconnectAttempt(1);
|
||||
setAnnouncement("Connection lost; automatic gap recovery scheduled");
|
||||
if (sessionId !== null) {
|
||||
retryTimerRef.current = setTimeout(
|
||||
() => subscribe(sessionId, 1),
|
||||
RECONNECT_DELAY_MS,
|
||||
);
|
||||
}
|
||||
}, [closeStream, subscribe]);
|
||||
|
||||
const retryNow = useCallback(() => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
if (sessionId === null) return;
|
||||
void guard(async () => {
|
||||
const resumed = await runnerClient.reconnectSession(sessionId);
|
||||
adopt(resumed);
|
||||
const history = await runnerClient.readEvents(sessionId, 0);
|
||||
setEvents(history.events);
|
||||
cursorRef.current = history.cursor;
|
||||
subscribe(sessionId, 0);
|
||||
setAnnouncement("Resumed the same session");
|
||||
});
|
||||
}, [adopt, guard, runnerClient, subscribe]);
|
||||
|
||||
const reset = useCallback(async () => {
|
||||
const sessionId = sessionIdRef.current;
|
||||
closeStream();
|
||||
if (sessionId !== null) {
|
||||
try {
|
||||
await runnerClient.closeSession(sessionId);
|
||||
} catch (cause) {
|
||||
setConnection("disconnected");
|
||||
setError(errorMessage(cause));
|
||||
setAnnouncement("Demo reset failed");
|
||||
return;
|
||||
}
|
||||
}
|
||||
window.sessionStorage.removeItem(SESSION_STORAGE_KEY);
|
||||
sessionIdRef.current = null;
|
||||
cursorRef.current = 0;
|
||||
setState(null);
|
||||
setEvents([]);
|
||||
setSteeringChips([]);
|
||||
setConnection("idle");
|
||||
setReplayActive(false);
|
||||
setReplayPosition(0);
|
||||
setSelectedThreadId(null);
|
||||
setError(null);
|
||||
setAnnouncement("Demo state reset");
|
||||
}, [closeStream, runnerClient]);
|
||||
|
||||
const replay = useMemo(
|
||||
() => ({
|
||||
active: replayActive,
|
||||
position: replayPosition,
|
||||
length: events.length,
|
||||
playing: replayPlaying,
|
||||
enter: () => {
|
||||
setReplayActive(true);
|
||||
setReplayPosition(0);
|
||||
setReplayPlaying(false);
|
||||
},
|
||||
exit: () => {
|
||||
setReplayActive(false);
|
||||
setReplayPlaying(false);
|
||||
},
|
||||
setPosition: (position: number) =>
|
||||
setReplayPosition(Math.min(Math.max(position, 0), events.length)),
|
||||
togglePlay: () => setReplayPlaying((current) => !current),
|
||||
step: (delta: number) =>
|
||||
setReplayPosition((current) =>
|
||||
Math.min(Math.max(current + delta, 0), events.length),
|
||||
),
|
||||
}),
|
||||
[events.length, replayActive, replayPlaying, replayPosition],
|
||||
);
|
||||
|
||||
return {
|
||||
manifests,
|
||||
selectedManifestId,
|
||||
selectManifest: setSelectedManifestId,
|
||||
state,
|
||||
events,
|
||||
snapshot,
|
||||
transcript,
|
||||
connection,
|
||||
reconnectAttempt,
|
||||
replayParity,
|
||||
composer,
|
||||
steeringChips,
|
||||
announcement,
|
||||
error,
|
||||
busy,
|
||||
selectedThreadId,
|
||||
selectThread: setSelectedThreadId,
|
||||
replay,
|
||||
start,
|
||||
send,
|
||||
steer,
|
||||
interrupt,
|
||||
resolve,
|
||||
goal,
|
||||
dropConnection,
|
||||
retryNow,
|
||||
reset,
|
||||
dismissSteeringChip: (id: string) =>
|
||||
setSteeringChips((chips) => chips.filter((chip) => chip.id !== id)),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { useRunnerConsole } from "../../../../src/react/use-runner-console";
|
||||
|
||||
void useRunnerConsole;
|
||||
@@ -6,7 +6,18 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"rootDir": ".",
|
||||
"types": ["vite/client"]
|
||||
"types": ["vite/client", "node"],
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@paperclipai/paperclip-runner/browser": ["src/browser/index.ts"],
|
||||
"@paperclipai/paperclip-runner/react": ["src/react/index.ts"],
|
||||
"@paperclipai/paperclip-runner/styles.css": ["styles.css"]
|
||||
}
|
||||
},
|
||||
"include": ["devtools/browser/src/**/*.ts", "devtools/browser/src/**/*.tsx"]
|
||||
"include": [
|
||||
"devtools/browser/src/**/*.ts",
|
||||
"devtools/browser/src/**/*.tsx",
|
||||
"examples/*/src/**/*.ts",
|
||||
"examples/*/src/**/*.tsx"
|
||||
]
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"jsx": "react-jsx",
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"outDir": "dist",
|
||||
"rootDir": "src"
|
||||
},
|
||||
"include": ["src/**/*.ts"],
|
||||
"exclude": ["src/**/*.test.ts"]
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vite";
|
||||
|
||||
import { phase4bBrowserServerPlugin } from "./scripts/phase4b-browser-server.mjs";
|
||||
|
||||
const packageRoot = fileURLToPath(new URL(".", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
root: resolve(packageRoot, "examples"),
|
||||
plugins: [react(), phase4bBrowserServerPlugin()],
|
||||
resolve: {
|
||||
alias: [
|
||||
{ find: "@paperclipai/paperclip-runner/browser", replacement: resolve(packageRoot, "src/browser/index.ts") },
|
||||
{ find: "@paperclipai/paperclip-runner/react", replacement: resolve(packageRoot, "src/react/index.ts") },
|
||||
{ find: "@paperclipai/paperclip-runner/styles.css", replacement: resolve(packageRoot, "styles.css") },
|
||||
],
|
||||
},
|
||||
server: { host: "127.0.0.1" },
|
||||
preview: { host: "127.0.0.1" },
|
||||
build: {
|
||||
outDir: resolve(packageRoot, "dist-phase5"),
|
||||
emptyOutDir: true,
|
||||
target: "esnext",
|
||||
rollupOptions: {
|
||||
input: {
|
||||
reference: resolve(packageRoot, "examples/reference-console/index.html"),
|
||||
mini: resolve(packageRoot, "examples/mini-consumer/index.html"),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -5,6 +5,7 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
include: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/*.test.tsx",
|
||||
"devtools/browser/src/**/*.test.ts",
|
||||
"scripts/phase2-browser-server.test.mjs",
|
||||
"scripts/phase4b-browser-server.test.mjs",
|
||||
|
||||