Skip to content

Commit 418036f

Browse files
bloveclaude
andauthored
feat(website): finish the medium switcher — Render, Ship, and live tabs (#833)
Completes the switcher across all four homepage sections. Render and Ship gain video and code tabs, and every section gains a Live tab that frames the real demo opened on that section's own scenario. Two new clips, recorded against aimock fixtures so a recut reproduces frame-for-frame: - Render — the agent emits a spec and a real contact form mounts as Angular components, `render_a2ui_surface` visible in the tool call. - Ship — a rich markdown answer, then a page reload, and it all comes back. Durability was initially judged unwatchable; it is exactly watchable, and it is the claim that section makes. The live tab uses `?featured=` (merged in #832) so each section opens the demo on its own curated scenario instead of the same empty chat under four headings. `section-media.spec.ts` gains the guard that matters here: it reads the DEMO's actual suggestion list off disk and asserts every `live.featured` id exists there. `?featured=` falls back silently for an unknown id, so a typo would quietly turn a live tab back into the generic demo with nothing else failing. Mutation-tested — a one-character typo fails with `approve -> approve-before-a-destructiv: expected [...] to include`. The active-pane-only guarantee holds at full scale, which was the spec's main technical risk. The built homepage carries 4 tablists, **4** `<video>` elements rather than 12, and **0** iframes — the live iframe is not requested until its tab is selected. Verified in a browser too: clicking Live takes iframes 0 -> 1 and videos 5 -> 4 as the video pane unmounts. Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent 4a865a0 commit 418036f

7 files changed

Lines changed: 218 additions & 29 deletions

File tree

apps/website/next-env.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
/// <reference types="next" />
22
/// <reference types="next/image-types/global" />
3-
import "./../../dist/apps/website/.next/types/routes.d.ts";
3+
import "./.next/dev/types/routes.d.ts";
44

55
// NOTE: This file should not be edited
66
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

apps/website/src/app/page.tsx

Lines changed: 35 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -80,12 +80,43 @@ async function buildPanes(media: SectionMedia, clipUrl: string): Promise<MediumP
8080
});
8181
});
8282

83+
if (media.live) {
84+
const mode = media.live.mode ?? 'embed';
85+
panes.push({
86+
id: 'live',
87+
key: 'live',
88+
label: 'Live',
89+
content: (
90+
<BrowserFrame url={clipUrl} elevation="lg">
91+
<div style={{ position: 'relative', width: '100%', aspectRatio: '16 / 10', background: '#15161f' }}>
92+
{/*
93+
Mounted only when its tab is selected — `MediumSwitcher` renders
94+
one pane at a time, so this iframe is never requested on page load.
95+
`?featured=` opens the demo on this section's own scenario; the id
96+
is a key into the demo's curated list, so an unknown one falls back
97+
rather than rendering anything this URL supplies.
98+
*/}
99+
<iframe
100+
src={`https://demo.threadplane.ai/${mode}?featured=${encodeURIComponent(media.live.featured)}`}
101+
title="Threadplane live demo"
102+
loading="lazy"
103+
style={{ width: '100%', height: '100%', border: 'none', display: 'block' }}
104+
/>
105+
</div>
106+
</BrowserFrame>
107+
),
108+
});
109+
}
110+
83111
return panes;
84112
}
85113

86114
export default async function HomePage() {
87-
const streamPanes = await buildPanes(SECTION_MEDIA.stream, SECTION_MEDIA.stream.video?.url ?? '');
88-
const approvePanes = await buildPanes(SECTION_MEDIA.approve, SECTION_MEDIA.approve.video?.url ?? '');
115+
const [streamPanes, renderPanes, shipPanes, approvePanes] = await Promise.all(
116+
(['stream', 'render', 'ship', 'approve'] as const).map((key) =>
117+
buildPanes(SECTION_MEDIA[key], SECTION_MEDIA[key].video?.url ?? ''),
118+
),
119+
);
89120

90121
return (
91122
<>
@@ -144,17 +175,7 @@ export default async function HomePage() {
144175
]}
145176
cta={{ label: 'See @threadplane/render', href: '/render' }}
146177
visualLeft
147-
visual={
148-
<BrowserFrame url="cockpit.threadplane.ai" elevation="md">
149-
<img
150-
src="/screenshots/cockpit-api.webp"
151-
alt="Cockpit reference app — API reference rendered as structured cards"
152-
style={{ display: 'block', width: '100%', height: 'auto' }}
153-
loading="lazy"
154-
decoding="async"
155-
/>
156-
</BrowserFrame>
157-
}
178+
visual={<MediumSwitcher sectionId="render" panes={renderPanes} />}
158179
/>
159180

160181
{/* Ship — the live demo */}
@@ -175,17 +196,7 @@ export default async function HomePage() {
175196
{ title: 'thread persistence', description: 'Restore conversations across sessions.' },
176197
]}
177198
cta={{ label: 'Production patterns', href: '/docs/langgraph/guides/deployment' }}
178-
visual={
179-
<BrowserFrame url="demo.threadplane.ai" elevation="lg">
180-
<img
181-
src="/screenshots/canonical-demo-generative-ui.webp"
182-
alt="Threadplane chat rendering a live generative-UI dashboard"
183-
style={{ display: 'block', width: '100%', height: 'auto' }}
184-
loading="lazy"
185-
decoding="async"
186-
/>
187-
</BrowserFrame>
188-
}
199+
visual={<MediumSwitcher sectionId="ship" panes={shipPanes} />}
189200
/>
190201

191202
{/*

apps/website/src/lib/demo-media.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,3 +51,31 @@ export const LANGGRAPH_CLIP: DemoClip = {
5151
videoWebm: `${DEMO_CDN}/langgraph-demo.webm`,
5252
poster: `${DEMO_CDN}/langgraph-demo-poster.webp`,
5353
};
54+
55+
/**
56+
* Generative UI: the agent emits a json-render spec and the demo mounts it as
57+
* real Angular components. Recorded by
58+
* `examples/chat/angular/e2e/record-render.record.ts`.
59+
*/
60+
export const RENDER_CLIP: DemoClip = {
61+
caption:
62+
'The agent emits a spec and your own components render it — this form is Angular, not a screenshot.',
63+
url: 'demo.threadplane.ai',
64+
videoMp4: `${DEMO_CDN}/render-demo.mp4`,
65+
videoWebm: `${DEMO_CDN}/render-demo.webm`,
66+
poster: `${DEMO_CDN}/render-demo-poster.webp`,
67+
};
68+
69+
/**
70+
* Durability: send a message, reload the page, and the conversation is still
71+
* there — the thread lives in a checkpoint, not in component state. Recorded by
72+
* `examples/chat/angular/e2e/record-ship.record.ts`.
73+
*/
74+
export const SHIP_CLIP: DemoClip = {
75+
caption:
76+
'Reload the page mid-conversation and nothing is lost — the thread is restored from its checkpoint.',
77+
url: 'demo.threadplane.ai',
78+
videoMp4: `${DEMO_CDN}/ship-demo.mp4`,
79+
videoWebm: `${DEMO_CDN}/ship-demo.webm`,
80+
poster: `${DEMO_CDN}/ship-demo-poster.webp`,
81+
};

apps/website/src/lib/section-media.spec.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
// SPDX-License-Identifier: MIT
2+
import { readFileSync } from 'node:fs';
3+
import { join } from 'node:path';
24
import { describe, expect, it } from 'vitest';
35
import { SECTION_MEDIA } from './section-media';
46
import { DEMO_CDN } from './demo-media';
@@ -29,4 +31,24 @@ describe('SECTION_MEDIA', () => {
2931
}
3032
}
3133
});
34+
35+
it('points every live tab at a suggestion the demo actually has', () => {
36+
// Cross-project on purpose. `?featured=` falls back silently for an id it
37+
// does not recognise, so a typo here would quietly turn a live tab back
38+
// into the generic empty demo — the exact thing the live tab exists to
39+
// avoid — and nothing else would fail. Read the demo's real list rather
40+
// than duplicating the ids, so renaming one there breaks this test instead
41+
// of breaking the homepage in production.
42+
const source = readFileSync(
43+
join(__dirname, '../../../../examples/chat/angular/src/app/modes/welcome-suggestions.ts'),
44+
'utf8',
45+
);
46+
const known = new Set(Array.from(source.matchAll(/id: '([^']+)'/g), (m) => m[1]));
47+
expect(known.size).toBeGreaterThan(0);
48+
49+
for (const [key, media] of Object.entries(SECTION_MEDIA)) {
50+
if (!media.live) continue;
51+
expect(known, `${key} -> ${media.live.featured}`).toContain(media.live.featured);
52+
}
53+
});
3254
});

apps/website/src/lib/section-media.ts

Lines changed: 53 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
// SPDX-License-Identifier: MIT
2-
import { HITL_CLIP, LANGGRAPH_CLIP, type DemoClip } from './demo-media';
2+
import { HITL_CLIP, LANGGRAPH_CLIP, RENDER_CLIP, SHIP_CLIP, type DemoClip } from './demo-media';
33
import type { SolutionCodeBlocks } from './solutions-data';
44

55
/**
@@ -11,13 +11,22 @@ import type { SolutionCodeBlocks } from './solutions-data';
1111
export interface SectionMedia {
1212
video?: DemoClip;
1313
code?: SolutionCodeBlocks;
14-
/** Phase 2. Declared now so the switcher's shape does not change later. */
15-
live?: { prompt: string; mode?: 'embed' | 'popup' | 'sidebar' };
14+
/**
15+
* Opens the live demo on a curated scenario via `?featured=`.
16+
*
17+
* A KEY into `examples/chat`'s suggestion list, never free text — the demo
18+
* falls back to its default for an id it does not recognise, so a link can
19+
* never put arbitrary words inside the demo UI. Renaming a suggestion's id
20+
* there breaks this link, which is why those ids are explicit rather than
21+
* derived from labels.
22+
*/
23+
live?: { featured: string; mode?: 'embed' | 'popup' | 'sidebar' };
1624
}
1725

18-
export const SECTION_MEDIA: Record<'stream' | 'approve', SectionMedia> = {
26+
export const SECTION_MEDIA: Record<'stream' | 'render' | 'ship' | 'approve', SectionMedia> = {
1927
stream: {
2028
video: LANGGRAPH_CLIP,
29+
live: { featured: 'tell-me-about-coral' },
2130
code: [
2231
{
2332
label: 'chat.component.ts — headless streaming',
@@ -36,8 +45,48 @@ export const SECTION_MEDIA: Record<'stream' | 'approve', SectionMedia> = {
3645
},
3746
],
3847
},
48+
render: {
49+
video: RENDER_CLIP,
50+
live: { featured: 'generative-ui-contact-form' },
51+
code: [
52+
{
53+
label: 'dashboard.component.ts — the view catalog',
54+
language: 'typescript',
55+
source: `import { ChatComponent, views } from '@threadplane/chat';
56+
57+
// Your components, keyed by the name the agent uses in its spec.
58+
const catalog = views({
59+
contact_form: ContactFormComponent,
60+
bar_chart: BarChartComponent,
61+
kpi_card: KpiCardComponent,
62+
});
63+
64+
// <chat [agent]="agent" [views]="catalog" />
65+
// ChatComponent detects a JSON spec in the AI message and renders it through
66+
// the catalog, mounting partial specs as they stream.`,
67+
},
68+
],
69+
},
70+
ship: {
71+
video: SHIP_CLIP,
72+
live: { featured: 'tell-me-about-coral' },
73+
code: [
74+
{
75+
label: 'app.config.ts — durable threads',
76+
language: 'typescript',
77+
source: `provideAgent(DEMO_AGENT, {
78+
apiUrl: 'https://your-deployment.langgraph.app',
79+
// The thread id is the durability boundary. Persist it (URL, storage,
80+
// your own records) and the conversation survives a reload, a new tab,
81+
// or a different device — the messages live in the checkpoint, not here.
82+
threadId: signal(threadIdFromUrl()),
83+
});`,
84+
},
85+
],
86+
},
3987
approve: {
4088
video: HITL_CLIP,
89+
live: { featured: 'approve-before-a-destructive' },
4190
code: [
4291
{
4392
label: 'approval.component.ts — the gate',
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// SPDX-License-Identifier: MIT
2+
/**
3+
* Records generative UI for the website's Render section: the agent emits a
4+
* json-render spec and the demo mounts it as real Angular components.
5+
*
6+
* NOT a test — `.record.ts`, which the e2e config's `**\/*.spec.ts` never picks
7+
* up. Run with `record-demo.config.ts`, which boots the same aimock backend the
8+
* e2e suite uses, so the take is deterministic and needs no API key.
9+
*
10+
* See apps/website/scripts/upload-demo-media.md for encoding and upload.
11+
*/
12+
import { test, expect } from '@playwright/test';
13+
import { openDemo } from './test-helpers';
14+
15+
test.describe.configure({ retries: 0 });
16+
17+
// VERBATIM from FEATURED_SUGGESTIONS[0] in modes/welcome-suggestions.ts, which
18+
// is also what the contact-form fixture matches. Reword it and the agent never
19+
// emits a spec, so there is nothing to record.
20+
const PROMPT =
21+
'Show me a contact form with fields for name, email address, subject, and a ' +
22+
'multi-line message, plus a Send button.';
23+
24+
test('generative ui render', async ({ page }) => {
25+
await openDemo(page, '/embed');
26+
await page.waitForTimeout(1500);
27+
28+
const input = page.getByRole('textbox', { name: /type a message|message|prompt/i });
29+
await input.click();
30+
await input.pressSequentially(PROMPT, { delay: 26 });
31+
await page.waitForTimeout(600);
32+
await page.getByRole('button', { name: /send/i }).click();
33+
34+
// The spec streams in and mounts as components — the beat worth showing.
35+
await expect(page.locator('chat-message[data-role="assistant"]')).toBeVisible({ timeout: 90_000 });
36+
await page.waitForTimeout(6000);
37+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// SPDX-License-Identifier: MIT
2+
/**
3+
* Records durability for the website's Ship section: send a message, reload the
4+
* page, and watch the conversation come back. The thread lives in a LangGraph
5+
* checkpoint, not in component state, so a reload restores it rather than
6+
* clearing it.
7+
*
8+
* NOT a test — `.record.ts`, which the e2e config's `**\/*.spec.ts` never picks
9+
* up. Run with `record-demo.config.ts`.
10+
*
11+
* See apps/website/scripts/upload-demo-media.md for encoding and upload.
12+
*/
13+
import { test, expect } from '@playwright/test';
14+
import { openDemo } from './test-helpers';
15+
16+
test.describe.configure({ retries: 0 });
17+
18+
// Matches the markdown fixture — a response with enough structure that its
19+
// return after reload is unmistakable on camera.
20+
const PROMPT = 'respond with the markdown checklist kitchen sink';
21+
22+
test('thread survives reload', async ({ page }) => {
23+
await openDemo(page, '/embed');
24+
await page.waitForTimeout(1200);
25+
26+
const input = page.getByRole('textbox', { name: /type a message|message|prompt/i });
27+
await input.click();
28+
await input.pressSequentially(PROMPT, { delay: 30 });
29+
await page.waitForTimeout(400);
30+
await page.getByRole('button', { name: /send/i }).click();
31+
32+
const assistant = page.locator('chat-message[data-role="assistant"]');
33+
await expect(assistant).toBeVisible({ timeout: 90_000 });
34+
await page.waitForTimeout(2500);
35+
36+
// The URL now carries the thread id. Reload it — this is the whole point.
37+
await page.reload();
38+
39+
// Same content, restored from the checkpoint rather than re-sent.
40+
await expect(assistant).toBeVisible({ timeout: 60_000 });
41+
await page.waitForTimeout(3500);
42+
});

0 commit comments

Comments
 (0)