Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
c52e472
docs(plans): stage route (live-stage plan 2 of 3)
blove Sep 6, 2026
f2740c6
feat(examples/chat): stage recording types with beat, action and hist…
blove Sep 6, 2026
e71d169
fix(examples/chat): stage recording validator types its kinds and che…
blove Sep 6, 2026
afc3d63
feat(examples/chat): stage timeline lays recorded runs end to end wit…
blove Sep 6, 2026
bc0000a
fix(examples/chat): stage timeline floors the reload window; validato…
blove Sep 6, 2026
e881521
feat(examples/chat): StageReplayTransport gates recorded events behin…
blove Sep 6, 2026
ef0cdc5
fix(examples/chat): StageReplayTransport captures its generation befo…
blove Sep 6, 2026
21dc0dc
feat(examples/chat): StageController drives the agent to any recorded…
blove Sep 6, 2026
ea2ec96
fix(examples/chat): stage seek resolves once applied; settle observes…
blove Sep 6, 2026
62e0b42
feat(examples/chat): stage record-mode transport and the four-beat sc…
blove Sep 6, 2026
5ece8b6
chore(examples/chat): stage recorder publishes per run and refuses a …
blove Sep 6, 2026
d4e01a6
feat(examples/chat): /stage route — real chat beside real devtools, s…
blove Sep 6, 2026
a9b64f6
fix(examples/chat): /stage ships chat-debug in its own chunk, keeps t…
blove Sep 6, 2026
320464d
feat(examples/chat): record the four-beat stage fixture live
blove Sep 6, 2026
60eb90c
feat(examples/chat): stage replay e2e and the still recorder for the …
blove Sep 6, 2026
76096bc
fix(examples/chat): stage pin re-arms, stills look beats up by name, …
blove Sep 6, 2026
947bd37
test(examples/chat): stage pin specs step a stubbed animation frame i…
blove Sep 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/website/public/screenshots/stage-render.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added apps/website/public/screenshots/stage-stream.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1,590 changes: 1,590 additions & 0 deletions docs/superpowers/plans/2026-09-05-stage-route.md

Large diffs are not rendered by default.

35 changes: 35 additions & 0 deletions examples/chat/angular/e2e/record-stage-fixture.record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* NOT a test. Drives /stage?record=1 (the four-beat script against the live
* agent, wrapped in StageRecordingTransport) and writes public/stage-replay.json.
* Run through record-stage-live.config.ts with the backend on :2024 and the
* dev server on :4200 already up. Takes vary; commit a complete one and never
* edit its prose.
*/
import { test, expect } from '@playwright/test';
import { writeFileSync } from 'node:fs';
import { resolve } from 'node:path';

const OUT = resolve(__dirname, '../public/stage-replay.json');
/* eslint-disable @typescript-eslint/no-explicit-any */

test('record stage fixture', async ({ page }) => {
page.on('console', (m) => { console.log(`[browser:${m.type()}]`, m.text()); });
await page.goto('/stage?record=1');
await expect.poll(async () => page.evaluate(() => (window as any).__stageRecording?.runs.length ?? 0), { timeout: 400_000 }).toBe(7);
let last = -1;
for (let i = 0; i < 40; i++) {
const n = await page.evaluate(() => (window as any).__stageRecording?.runs.at(-1)?.events.length ?? 0);
if (n > 0 && n === last) break;
last = n;
await page.waitForTimeout(3000);
}
// Let the final run-close history refresh land before reading.
await page.waitForTimeout(2000);
const rec = await page.evaluate(() => (window as any).__stageRecording);
expect(rec.runs.map((r: any) => `${r.beat}:${r.action.kind}`)).toEqual([
'stream:submit', 'persist:reload', 'persist:submit', 'persist:submit', 'approve:submit', 'approve:resume', 'render:submit',
]);
expect(rec.threadId).toBeTruthy();
writeFileSync(OUT, JSON.stringify(rec, null, 2) + '\n');
console.log(`wrote ${OUT}`);
});
52 changes: 52 additions & 0 deletions examples/chat/angular/e2e/record-stage-live.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
/**
* Playwright config for recording the stage walkthrough fixture against the
* REAL model, rather than the aimock replay `record-stage.config.ts` uses.
*
* `public/stage-replay.json` is shipped behind a pill that reads "Replaying a
* recorded LangGraph run", so the words in it have to be words a model actually
* said. Recording through aimock makes the stream events real but the prose
* authored, which the pill then misrepresents.
*
* Unlike the other configs this one starts NOTHING — bring your own servers, so
* that the OpenAI key stays in your shell and never reaches a committed file:
*
* # 1. backend on :2024, pointed at the real API
* cd examples/chat/python && \
* export OPENAI_API_KEY=$(grep -E '^OPENAI_API_KEY=' ../../../.env | cut -d= -f2-) && \
* uv run langgraph dev --port 2024 --no-browser
*
* # 2. the demo on :4200 (dev build — /stage?record=1 is inert in production)
* npx nx serve examples-chat-angular --port 4200
*
* # 3. one take
* npx playwright test -c examples/chat/angular/e2e/record-stage-live.config.ts record-stage-fixture
*
* Takes vary: the model is free to answer how it likes, so record several and
* commit the best COMPLETE one (the recorder's own assertions — seven runs in
* the exact beat order, a truthy threadId — reject incomplete takes for you).
* Picking a take is the same latitude a demo video has. Editing what the model
* said is not: that is what this config exists to stop.
*
* The timeout is far longer than the replay config's because a real run streams
* at model speed, with reasoning, instead of at aimock speed.
*
* In a worktree, `../../../.env` (relative to examples/chat/python) does not
* exist — the `.env` lives in the primary checkout, so point the export at
* that path instead.
*/
import { defineConfig } from '@playwright/test';

export default defineConfig({
testDir: '.',
testMatch: '**/record-stage-fixture.record.ts',
fullyParallel: false,
workers: 1,
retries: 0,
reporter: 'list',
timeout: 600_000,
use: {
baseURL: 'http://localhost:4200',
viewport: { width: 1200, height: 720 },
},
outputDir: './.record-output',
});
84 changes: 84 additions & 0 deletions examples/chat/angular/e2e/record-stage-stills.record.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
/**
* NOT a test. Captures one still per beat from the committed stage replay, at
* desktop and phone widths, for the website's non-pinned fallback (spec §8).
* npx playwright test --config examples/chat/angular/e2e/record-stage.config.ts record-stage-stills
*
* Each still is taken at a SETTLED moment of its beat (the beat's end, or the
* middle of the authored hold for approve), read from `window.__stageTimeline`
* so a re-recorded fixture moves the capture points with it. The recorder
* waits on `window.__stageApplied.t` reaching the target: the controller
* clamps a seek to `[0, totalMs]` and every target here is inside that range,
* so equality is the right wait.
*
* Geometry: the desktop frame is the 1200x720 the stage is authored for, with
* the devtools docked right. Below 768px the stage renders NO devtools (see
* `readStageDock` — the phone path is chat only, a docked panel would eat the
* transcript), so the phone still is the chat at 390x650 (3:5, the phone
* ratio the hero poster uses). `deviceScaleFactor: 2` applies to BOTH sizes:
* the desktop still is a 2400-wide raster downscaled to 1200, and the phone
* still a 780-wide raster downscaled to 585 — crisp downscales rather than a
* 1x raster shipped as-is or a 1.5x upscale of a 390px one.
*/
import { expect, test } from '@playwright/test';
import { resolve } from 'node:path';
import sharp from 'sharp';
import type { StageBeat } from '../src/app/stage/stage-recording.types';
import type { StageTimeline } from '../src/app/stage/stage-timeline';

const OUT_DIR = resolve(__dirname, '../../../../apps/website/public/screenshots');
const SIZES = [
{ suffix: '', width: 1200, height: 720, ship: 1200 },
{ suffix: '-mobile', width: 390, height: 650, ship: 585 },
];
/** The website's budget per still; the hero poster ships well under it too. */
const MAX_BYTES = 120 * 1024;

test.use({ deviceScaleFactor: 2 });

test('capture stage stills', async ({ page }) => {
await page.goto('/stage?t=0');
await page.waitForFunction(() => !!window.__stageTimeline);
const tl = await page.evaluate(() => window.__stageTimeline as StageTimeline);
const endOf = (b: StageBeat) => {
const hit = tl.beats.find((x) => x.beat === b);
if (!hit) throw new Error(`recording has no "${b}" beat`);
return hit.endMs;
};
const settle: Record<string, number> = {
stream: endOf('stream'),
persist: endOf('persist'),
approve: tl.hold.startMs + Math.round((tl.hold.endMs - tl.hold.startMs) / 2),
render: tl.totalMs,
};
// Collected, not asserted per file: a throw mid-loop would leave the set
// half rewritten. Every offender is listed once after both loops.
const oversized: string[] = [];
for (const size of SIZES) {
await page.setViewportSize({ width: size.width, height: size.height });
for (const [beat, t] of Object.entries(settle)) {
await page.goto(`/stage?t=${t}`);
await page.waitForFunction((target) => window.__stageApplied?.t === target, t, {
timeout: 60_000,
});
await page.waitForTimeout(400);
// What keeps a mistimed capture from shipping silently.
if (beat === 'approve') {
await expect(page.locator('chat-interrupt-panel')).toBeAttached();
await expect(page.locator('app-backup-table [data-state="rows"]')).toBeAttached();
}
if (beat === 'render') {
await expect(page.locator('a2ui-surface').first()).toBeAttached();
await expect(page.locator('chat-interrupt-panel')).toHaveCount(0);
}
const png = await page.screenshot({ type: 'png', fullPage: false });
const out = resolve(OUT_DIR, `stage-${beat}${size.suffix}.webp`);
const info = await sharp(png)
.resize({ width: size.ship })
.webp({ quality: 60, effort: 6 })
.toFile(out);
if (info.size > MAX_BYTES) oversized.push(`${out} (${info.size} bytes)`);
console.log(`wrote ${out} (${Math.round(info.size / 1024)} KB)`);
}
}
expect(oversized, `stills over ${MAX_BYTES} bytes:\n${oversized.join('\n')}`).toEqual([]);
});
33 changes: 33 additions & 0 deletions examples/chat/angular/e2e/record-stage.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
/**
* Playwright config for the stage STILL recorder. Mirrors
* `record-hero.config.ts` — the same aimock-backed global setup, so the demo
* boots without an API key — and captures one still per beat from the
* committed `public/stage-replay.json` (the replay itself needs no model).
*
* The stills are the website's non-pinned fallback: where the live stage
* iframe is not pinned and scrubbed (phones, reduced motion, no JS), the site
* shows these frames instead.
*
* `record-stage-fixture.record.ts` is NOT matched here on purpose: the fixture
* is recorded against the real model via `record-stage-live.config.ts`.
*
* npx playwright test --config examples/chat/angular/e2e/record-stage.config.ts record-stage-stills
*/
import { defineConfig } from '@playwright/test';

export default defineConfig({
testDir: '.',
testMatch: '**/record-stage-stills.record.ts',
fullyParallel: false,
workers: 1,
retries: 0,
reporter: 'list',
timeout: 240_000,
use: {
baseURL: 'http://localhost:4200',
viewport: { width: 1200, height: 720 },
},
globalSetup: './global-setup.ts',
globalTeardown: './global-teardown.ts',
outputDir: './.record-output',
});
11 changes: 11 additions & 0 deletions examples/chat/angular/e2e/stage-globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { StageState } from '../src/app/stage/stage-bridge';
import type { StageTimeline } from '../src/app/stage/stage-timeline';

// Mirrors the augmentation in stage-mode.component.ts: the e2e tsconfig does
// not compile src/, so the stage specs and the still recorder share this copy.
declare global {
interface Window {
__stageTimeline?: StageTimeline;
__stageApplied?: StageState;
}
}
60 changes: 60 additions & 0 deletions examples/chat/angular/e2e/stage.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import { test, expect, type Page } from '@playwright/test';
import { attachBrowserHygiene } from './test-helpers';
import type { StageTimeline } from '../src/app/stage/stage-timeline';

/**
* The /stage route replays the committed `public/stage-replay.json` beside the
* real devtools, seekable by `?t=<ms>`. The page publishes its timeline on
* `window.__stageTimeline` once the replay is ready, so the specs read the
* beat boundaries from the recording rather than hard-coding milliseconds
* that would drift on the next re-record.
*/
async function timeline(page: Page): Promise<StageTimeline> {
await page.goto('/stage?t=0');
await expect
.poll(() => page.evaluate(() => !!window.__stageTimeline), { timeout: 30_000 })
.toBe(true);
return page.evaluate(() => window.__stageTimeline as StageTimeline);
}

test.describe('stage replay', () => {
test.describe.configure({ timeout: 120_000 });

test('renders the chat beside the devtools and seeks to the approve hold', async ({ page }) => {
const hygiene = attachBrowserHygiene(page);
const tl = await timeline(page);
await expect(page.getByRole('region', { name: 'Chat devtools' })).toBeVisible();
// +1: strictly inside the hold. The boundary instant still belongs to the
// outgoing run (phaseReachedAt in stage-timeline.ts renders t minus an epsilon).
await page.goto(`/stage?t=${tl.hold.startMs + 1}`);
await expect(page.locator('chat-interrupt-panel')).toBeAttached({ timeout: 60_000 });
// Guards the transcript pin: the panel and the newest content sit in view.
await expect(page.locator('chat-interrupt-panel')).toBeInViewport({ timeout: 60_000 });
// The pause comes from delete_backups, after list_backups has rendered its
// registered tool view — the inventory the visitor is being asked about.
await expect(page.locator('app-backup-table [data-state="rows"]')).toBeAttached();
// Replay is a recording, not a control surface: the panel shows but cannot
// be clicked, so a visitor cannot desync the transcript from t.
expect(
await page
.locator('[data-stage-interrupt]')
.evaluate((el) => getComputedStyle(el).pointerEvents),
).toBe('none');
expect(hygiene.consoleErrors).toEqual([]);
});

test('the end of the recording mounts the generated form and the devtools shows the thread', async ({
page,
}) => {
const hygiene = attachBrowserHygiene(page);
const tl = await timeline(page);
await page.goto(`/stage?t=${tl.totalMs}`);
await expect(page.locator('a2ui-surface').first()).toBeAttached({ timeout: 90_000 });
// Guards the transcript pin: the generated form is the newest content.
await expect(page.locator('a2ui-surface').first()).toBeInViewport({ timeout: 90_000 });
await expect(page.locator('chat-interrupt-panel')).toHaveCount(0);
await page.getByRole('tab', { name: 'Timeline' }).click();
await expect(page.getByRole('region', { name: 'Chat devtools' })).toContainText(/checkpoint/i);
expect(hygiene.consoleErrors).toEqual([]);
});
});
1 change: 0 additions & 1 deletion examples/chat/angular/project.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
"define": {
"THREADPLANE_CHAT_DEBUG": "false"
},
"externalDependencies": ["@threadplane/chat/debug"],
"budgets": [
{
"type": "initial",
Expand Down
Loading
Loading