Skip to content

Commit 471855d

Browse files
bloveclaude
andauthored
feat(examples/chat): /hero route — replayed LangGraph walkthrough with live takeover (#976)
* docs(specs): homepage rebuild design — category hero, live take-over demo, install dialog Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(specs): homepage rebuild — pin /hero route design to verified adapter and demo APIs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * docs(plans): homepage rebuild — hero demo route and website implementation plans Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(examples/chat): hero recording types and validator Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(examples/chat): HeroReplayTransport plays recorded runs with clamped pacing Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(examples/chat): HeroRecordingTransport captures live runs for the hero fixture Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(examples/chat): avoid require-yield lint error in HeroRecordingTransport joinStream's no-op fallback used an empty async generator, which trips @typescript-eslint's require-yield rule. Replace it with a plain AsyncIterable object instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(examples/chat): HeroScriptRunner drives the walkthrough through a host interface Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(examples/chat): hero postMessage bridge with parent-origin allowlist Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(examples/chat): hero cursor component Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * refactor(examples/chat): HeroReplayTransport needs no Injectable decorator Constructor params are interface/function typed, so DI metadata is inert; the class is provided via useValue/useFactory instead. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(examples/chat): avoid empty-function lint error in hero-script spec Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(examples/chat): register lazy /hero route and hero agent refs * fix(examples/chat): hero transports — retry failed fixture loads, register runs lazily, reject empty runs Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(examples/chat): HeroMode route — replay agent, scripted cursor, takeover to live LangGraph Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(examples/chat): hero script runner generation token, timeouts and error path; bridge source check and preview origins Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(examples/chat): record and commit the hero walkthrough fixture Adds the recording harness (record-hero.config.ts + record-hero-fixture.record.ts) that drives /hero?record=1 against the aimock-backed LangGraph backend and writes public/hero-replay.json, plus a spec that guards the committed fixture. Two blockers the recording surfaced: - HeroMode.boot() bailed out before starting the script runner whenever the replay fixture failed to load. In record mode the fixture is the artifact being produced, so it is always missing on the first pass — record mode now falls through and drives the live agent. - The interrupt-approval aimock fixture had no `hasToolResult: true` entry, so the post-resume turn re-matched the tool-call fixture and the approval looped forever. Adds the narration entry ahead of it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(examples/chat): hero mode — takeover gating, visibility sources, per-instance agents, DOM host extraction and tests Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * test(examples/chat): e2e for the hero walkthrough and takeover Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * feat(website): hero walkthrough poster captured from /hero Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(examples/chat): hero mode — honest initial frame state, guarded replay stop, clear composer on takeover Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(examples/chat): hero bridge learns the parent origin from the first allowlisted message Under a strict Referrer-Policy the referrer is empty and the frame could never post ready. The bridge now also learns the origin from the first allowlisted inbound message and replays the latest state; the website plan posts visibility on iframe load so that handshake happens. Also fixes the poster script's documented command and the poster alt text in the website plan. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> * fix(examples/chat): type the hero recording validator callbacks for strict consumers The packaged-consumer smoke compiles the demo app under noImplicitAny; the Array.isArray narrowing left two forEach parameters implicitly any. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 0aaed36 commit 471855d

30 files changed

Lines changed: 14238 additions & 0 deletions
36.8 KB
Loading

‎docs/superpowers/plans/2026-09-02-hero-demo-route.md‎

Lines changed: 1844 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/superpowers/plans/2026-09-02-homepage-rebuild.md‎

Lines changed: 2604 additions & 0 deletions
Large diffs are not rendered by default.

‎docs/superpowers/specs/2026-09-02-homepage-rebuild-design.md‎

Lines changed: 254 additions & 0 deletions
Large diffs are not rendered by default.

‎examples/chat/angular/e2e/fixtures/interrupt-approval.json‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
{
22
"fixtures": [
3+
{
4+
"match": {
5+
"userMessage": "I want to clean up old database backups older than 90 days. Walk me through what you would delete, and call request_approval before doing anything destructive so I can review your plan.",
6+
"hasToolResult": true
7+
},
8+
"response": {
9+
"content": "Approved. Here is the cleanup I would run:\n\n1. **Inventory** every backup location (S3, GCS, Azure, RDS and EBS snapshots, and the local backup table).\n2. **Dry run** a listing of everything older than 90 days so you can scan it before anything is touched.\n3. **Delete** the matched objects and snapshots, moving anything tagged `retain` to the archive bucket instead, and write an audit record for each deletion.\n\nNothing has been deleted yet \u2014 the dry-run listing comes first."
10+
}
11+
},
312
{
413
"match": {
514
"userMessage": "I want to clean up old database backups older than 90 days. Walk me through what you would delete, and call request_approval before doing anything destructive so I can review your plan."
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
// SPDX-License-Identifier: MIT
2+
import { test, expect } from '@playwright/test';
3+
import { attachBrowserHygiene } from './test-helpers';
4+
5+
test.describe('hero walkthrough', () => {
6+
test('replays to the interrupt, takes over to live, and can replay again', async ({ page }) => {
7+
const hygiene = attachBrowserHygiene(page);
8+
await page.goto('/hero');
9+
const pill = page.locator('[data-hero-pill]');
10+
await expect(pill).toContainText(/recorded LangGraph run/i);
11+
// The script types and sends; the replayed run pauses on the interrupt.
12+
await expect(page.locator('hero-cursor')).toHaveAttribute('data-visible', 'true', {
13+
timeout: 15_000,
14+
});
15+
await expect(page.locator('chat-interrupt-panel')).toBeAttached({ timeout: 60_000 });
16+
// Takeover via the pill.
17+
await page.getByRole('button', { name: /take control/i }).click();
18+
await expect(pill).toContainText(/Live · LangGraph/);
19+
await expect(page.locator('[data-hero-banner]')).toContainText(/walkthrough was a recording/i);
20+
await expect(page.locator('hero-cursor')).toHaveAttribute('data-visible', 'false');
21+
// Back to replay.
22+
await page.getByRole('button', { name: /replay walkthrough/i }).click();
23+
await expect(pill).toContainText(/recorded LangGraph run/i);
24+
expect(hygiene.consoleErrors).toEqual([]);
25+
});
26+
27+
test('the full walkthrough reaches the generated UI without taking control of itself', async ({
28+
page,
29+
}) => {
30+
const hygiene = attachBrowserHygiene(page);
31+
await page.goto('/hero');
32+
const pill = page.locator('[data-hero-pill]');
33+
await expect(page.locator('chat-interrupt-panel')).toBeAttached({ timeout: 60_000 });
34+
// Accept is pressed by the script; the A2UI surface from run 3 renders.
35+
await expect(page.locator('a2ui-surface').first()).toBeAttached({ timeout: 90_000 });
36+
await expect(pill).toContainText(/recorded LangGraph run/i);
37+
expect(hygiene.consoleErrors).toEqual([]);
38+
});
39+
40+
test('focusing the composer takes over', async ({ page }) => {
41+
const hygiene = attachBrowserHygiene(page);
42+
await page.goto('/hero');
43+
await page.locator('[data-hero-surface] textarea').focus();
44+
await expect(page.locator('[data-hero-pill]')).toContainText(/Live · LangGraph/);
45+
expect(hygiene.consoleErrors).toEqual([]);
46+
});
47+
});
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// SPDX-License-Identifier: MIT
2+
/**
3+
* NOT a test. Drives /hero?record=1 (the scripted walkthrough runs against the
4+
* live agent wrapped in HeroRecordingTransport) and writes the captured runs
5+
* to public/hero-replay.json. Run through record-hero.config.ts.
6+
*/
7+
import { test, expect } from '@playwright/test';
8+
import { writeFileSync } from 'node:fs';
9+
import { resolve } from 'node:path';
10+
11+
interface RecordingShape {
12+
version: 1;
13+
recordedAt: string;
14+
runs: { label: string; events: { tMs: number; event: unknown }[] }[];
15+
}
16+
/* eslint-disable @typescript-eslint/no-explicit-any */
17+
const readRecording = () => (window as any).__heroRecording as RecordingShape | undefined;
18+
19+
const OUT = resolve(__dirname, '../public/hero-replay.json');
20+
21+
test('record hero walkthrough fixture', async ({ page }) => {
22+
page.on('console', (m) => {
23+
if (m.type() === 'error') console.log('[browser]', m.text());
24+
});
25+
await page.goto('/hero?record=1');
26+
await expect
27+
.poll(async () => page.evaluate(() => (window as any).__heroRecording?.runs.length ?? 0), {
28+
timeout: 200_000,
29+
})
30+
.toBe(3);
31+
// Let the last run drain: wait until the genui run has stopped growing for 3s.
32+
let last = -1;
33+
for (let i = 0; i < 40; i++) {
34+
const n = await page.evaluate(() => (window as any).__heroRecording?.runs[2]?.events.length ?? 0);
35+
if (n > 0 && n === last) break;
36+
last = n;
37+
await page.waitForTimeout(3000);
38+
}
39+
const rec = (await page.evaluate(readRecording)) as RecordingShape | undefined;
40+
expect(rec?.runs.map((r) => r.label)).toEqual(['prompt', 'resume', 'genui']);
41+
expect(JSON.stringify(rec?.runs[0].events)).toMatch(/approval_request/);
42+
expect(JSON.stringify(rec?.runs[2].events)).toMatch(/a2ui/i);
43+
writeFileSync(OUT, JSON.stringify(rec, null, 2) + '\n');
44+
console.log(`wrote ${OUT}`);
45+
});
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// SPDX-License-Identifier: MIT
2+
/**
3+
* NOT a test. Captures a frame of the hero walkthrough as the website's
4+
* server-rendered poster (1200x720, webp). Run through record-hero.config.ts:
5+
* npx playwright test --config examples/chat/angular/e2e/record-hero.config.ts record-hero-poster
6+
*
7+
* The beat is the FIRST STREAMED REPLY: the approval interrupt has just been
8+
* accepted by the scripted cursor and the answer is on screen. The earlier
9+
* "typing the first prompt" frame is mostly empty canvas; this one shows the
10+
* user turn, the tool call, the rendered answer and the cursor heading back to
11+
* the composer — it reads as a product, not as a blank chat box.
12+
*/
13+
import { test } from '@playwright/test';
14+
import { resolve } from 'node:path';
15+
import sharp from 'sharp';
16+
17+
const OUT = resolve(__dirname, '../../../../apps/website/public/screenshots/hero-walkthrough-poster.webp');
18+
19+
test('capture hero poster', async ({ page }) => {
20+
await page.goto('/hero');
21+
// The scripted cursor types prompt 1, sends, and the replay pauses on the
22+
// approval interrupt; the script then presses Accept.
23+
const interruptPanel = page.locator('chat-interrupt-panel');
24+
await interruptPanel.waitFor({ timeout: 60_000 });
25+
await interruptPanel.waitFor({ state: 'detached', timeout: 60_000 });
26+
await page.waitForTimeout(1500);
27+
const png = await page.screenshot({ type: 'png', fullPage: false });
28+
await sharp(png).webp({ quality: 82 }).toFile(OUT);
29+
console.log(`wrote ${OUT}`);
30+
});
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
// SPDX-License-Identifier: MIT
2+
/**
3+
* Playwright config for recording the hero walkthrough fixture. Mirrors
4+
* `record-demo.config.ts` — same aimock-backed global setup — but captures no
5+
* video: the artifact is `public/hero-replay.json`, not a clip.
6+
*
7+
* `testMatch` picks up only the hero record script, so recording never runs in
8+
* CI and the e2e suite never records.
9+
*
10+
* npx playwright test --config examples/chat/angular/e2e/record-hero.config.ts record-hero-fixture
11+
*/
12+
import { defineConfig } from '@playwright/test';
13+
14+
export default defineConfig({
15+
testDir: '.',
16+
testMatch: '**/record-hero-*.record.ts',
17+
fullyParallel: false,
18+
workers: 1,
19+
retries: 0,
20+
reporter: 'list',
21+
timeout: 240_000,
22+
use: {
23+
baseURL: 'http://localhost:4200',
24+
viewport: { width: 1200, height: 720 },
25+
},
26+
globalSetup: './global-setup.ts',
27+
globalTeardown: './global-teardown.ts',
28+
outputDir: './.record-output',
29+
});

0 commit comments

Comments
 (0)