diff --git a/apps/website/content/docs/ag-ui/api/fake-agent.mdx b/apps/website/content/docs/ag-ui/api/fake-agent.mdx new file mode 100644 index 000000000..1fe1776f7 --- /dev/null +++ b/apps/website/content/docs/ag-ui/api/fake-agent.mdx @@ -0,0 +1,123 @@ +# FakeAgent + +`FakeAgent` is an in-process AG-UI test double that emits a canned streaming response without a real backend. Use it for offline development, CI, and component tests. + +## provideFakeAgent() + +`provideFakeAgent()` is the DI-friendly entry point. It is a drop-in replacement for `provideAgent({ url })` when no backend is available. + +```ts +import { bootstrapApplication } from '@angular/platform-browser'; +import { provideFakeAgent } from '@threadplane/ag-ui'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, { + providers: [ + provideFakeAgent(), + ], +}); +``` + +Pass a `FakeAgentConfig` to customize the canned response: + +```ts +provideFakeAgent({ + tokens: ['Hello', ' world', '!'], + delayMs: 40, +}) +``` + +### FakeAgentConfig + +| Option | Type | Description | +|--------|------|-------------| +| `tokens` | `string[]` | Assistant reply streamed token-by-token. Defaults to a fixed placeholder message. | +| `reasoningTokens` | `string[]` | Optional reasoning chunks emitted before the text reply. | +| `delayMs` | `number` | Milliseconds between successive token emissions. Defaults to `60`. | + +## FakeAgent class + +Construct a `FakeAgent` directly when you need lower-level control, for example to pass it to `toAgent()` in a test harness. + +```ts +import { FakeAgent } from '@threadplane/ag-ui'; +import { toAgent } from '@threadplane/ag-ui'; + +const agent = toAgent(new FakeAgent({ + tokens: ['Thinking', '...', ' done.'], + reasoningTokens: ['Step 1', ': evaluate'], + delayMs: 30, +})); +``` + +`FakeAgent` extends `AbstractAgent` from `@ag-ui/client`. Its `run()` method returns an `Observable` that emits the full event sequence — `RUN_STARTED`, optional reasoning events, `TEXT_MESSAGE_START` / `TEXT_MESSAGE_CONTENT` tokens, `TEXT_MESSAGE_END`, `RUN_FINISHED` — then completes. + +## TestBed example + +```ts +import { TestBed } from '@angular/core/testing'; +import { provideFakeAgent, injectAgent } from '@threadplane/ag-ui'; +import { Component } from '@angular/core'; + +@Component({ template: '' }) +class ChatComponent { + readonly chat = injectAgent(); +} + +describe('ChatComponent', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ChatComponent], + providers: [ + provideFakeAgent({ tokens: ['Hello', ' from', ' fake'] }), + ], + }); + }); + + it('streams a canned reply', async () => { + const fixture = TestBed.createComponent(ChatComponent); + fixture.detectChanges(); + + await fixture.componentInstance.chat.submit({ message: 'Hi' }); + fixture.detectChanges(); + + expect(fixture.componentInstance.chat.messages().at(-1)?.content) + .toBe('Hello from fake'); + }); +}); +``` + + + `FakeAgent` and `provideFakeAgent()` are intended for development and tests + only. Do not use them in production builds. + + +See also: [Fake Agent guide](/docs/ag-ui/guides/fake-agent) for practical offline-development patterns, and [Testing guide](/docs/ag-ui/guides/testing) for full component-testing recipes. + +## What's Next + + + + Practical patterns for offline demos and rapid prototyping with FakeAgent. + + + Full testing patterns for components that use `injectAgent()`. + + + The production provider `provideFakeAgent()` replaces in tests. + + + +{/* Auto-rendered from api-docs.json — see page component */} diff --git a/apps/website/content/docs/ag-ui/api/inject-agent.mdx b/apps/website/content/docs/ag-ui/api/inject-agent.mdx new file mode 100644 index 000000000..cd437ff88 --- /dev/null +++ b/apps/website/content/docs/ag-ui/api/inject-agent.mdx @@ -0,0 +1,121 @@ +# injectAgent() + +`injectAgent()` retrieves the AG-UI agent from Angular's dependency injection container. Call it in an Angular injection context — typically as a component field initializer. The returned object exposes Angular Signals for reactive UI state and async methods for user actions. + +Configuration is supplied globally via [provideAgent()](/docs/ag-ui/api/provide-agent) — `injectAgent()` itself takes no arguments. + +```ts +import { injectAgent } from '@threadplane/ag-ui'; + +readonly chat = injectAgent(); + +await this.chat.submit({ message: 'Hello' }); +``` + +Pair it with `provideAgent()` at bootstrap to configure the agent endpoint: + +```ts +import { bootstrapApplication } from '@angular/platform-browser'; +import { provideAgent } from '@threadplane/ag-ui'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, { + providers: [ + provideAgent({ url: 'http://localhost:8000/my-agent' }), + ], +}); +``` + +## Runtime-neutral surface + +These fields are stable across runtime adapters and are what chat components consume. + +| Field | Type | Description | +|-------|------|-------------| +| `messages()` | `Message[]` | Chat messages with `role`, `content`, optional `toolCallIds`, citations, and reasoning. | +| `status()` | `'idle' \| 'running' \| 'error'` | UI lifecycle status. | +| `isLoading()` | `boolean` | Convenience signal for active streaming. | +| `error()` | `unknown` | Latest runtime error, when present. | +| `toolCalls()` | `ToolCall[]` | Tool calls projected into the chat contract. | +| `state()` | `Record` | Latest agent state projected as a plain object. | +| `interrupt()` | `AgentInterrupt \| undefined` | Current interrupt, when the backend pauses for human input. | +| `submit(input, opts?)` | `Promise` | Submit a user message or resume payload. | +| `stop()` | `Promise` | Abort the active run. | +| `regenerate(index)` | `Promise` | Remove the assistant message at `index` and rerun from the preceding user message. | + +## AG-UI-specific surface + +The AG-UI adapter extends the neutral `Agent` contract with one additional signal: + +| Field | Type | Description | +|-------|------|-------------| +| `customEvents()` | `CustomStreamEvent[]` | Custom events emitted by the backend during a run. Accumulates per run; resets on each new `submit()`. | + +`injectAgent()` is typed as the neutral `Agent` interface; to access `customEvents` you must cast to `AgUiAgent`: + +```ts +import { injectAgent, type AgUiAgent } from '@threadplane/ag-ui'; + +const chat = injectAgent() as AgUiAgent; +chat.customEvents(); // Signal +``` + +The chat a2ui bridge reads `customEvents` to light up live generative-UI streaming when your backend emits `a2ui-partial` events. See the [Custom Events guide](/docs/ag-ui/guides/custom-events) for backend wiring details. + +## Submit and resume + +Use the runtime-neutral submit shape for normal chat input: + +```ts +await chat.submit({ message: 'Summarize this document' }); +``` + +Resume an interrupt by passing a `resume` payload: + +```ts +await chat.submit({ resume: { approved: true } }); +``` + +## Regenerate semantics + +`regenerate(assistantMessageIndex)` has replace semantics: it keeps the user message before the selected assistant message, removes the selected assistant message and all later messages, syncs the rollback to the AG-UI source, then reruns with no new user message appended. + +```ts +await chat.regenerate(3); +``` + +The method throws when the selected index is not an assistant message, when no preceding user message exists, or while another response is already loading. + + + `injectAgent()` must be called during construction, inside an injection context + (e.g. a component constructor, field initializer, or a function passed to + `runInInjectionContext`). Calling it outside an injection context will throw. + + +## What's Next + + + + Configure the endpoint URL, headers, and telemetry for the agent provider. + + + Wire `customEvents` to live generative-UI streaming from any AG-UI backend. + + + Test components that call `injectAgent()` with the in-process FakeAgent. + + + +{/* Auto-rendered from api-docs.json — see page component */} diff --git a/apps/website/content/docs/ag-ui/api/provide-agent.mdx b/apps/website/content/docs/ag-ui/api/provide-agent.mdx new file mode 100644 index 000000000..9f1c30c18 --- /dev/null +++ b/apps/website/content/docs/ag-ui/api/provide-agent.mdx @@ -0,0 +1,83 @@ +# provideAgent() + +`provideAgent()` registers the singleton AG-UI agent configuration for every [injectAgent()](/docs/ag-ui/api/inject-agent) call in an Angular application. Call it once in `bootstrapApplication` or an `ApplicationConfig` to wire up the endpoint URL, optional identifiers, custom headers, and telemetry. + +`injectAgent()` itself takes no arguments — all configuration flows through `provideAgent()`. + +```ts +import { bootstrapApplication } from '@angular/platform-browser'; +import { provideAgent } from '@threadplane/ag-ui'; +import { AppComponent } from './app/app.component'; + +bootstrapApplication(AppComponent, { + providers: [ + provideAgent({ + url: 'http://localhost:8000/my-agent', + }), + ], +}); +``` + +## Configuration options + +| Option | Type | Description | +|--------|------|-------------| +| `url` | `string` | HTTP endpoint for the AG-UI backend agent. Required. | +| `agentId` | `string` | Optional agent identifier forwarded to the backend. | +| `threadId` | `string` | Optional thread identifier for session continuity. | +| `headers` | `Record` | Optional custom HTTP headers included on every request. | +| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. | + +## Static vs factory config + +Pass a plain `AgentConfig` object when the URL is known up front. Pass a `() => AgentConfig` factory when the config depends on runtime DI state — the factory runs inside an Angular injection context, so it may call `inject()` to read services or environment tokens. + +```ts +// Factory form — reads an environment token at runtime +provideAgent(() => { + const env = inject(APP_ENV); + return { + url: env.agentUrl, + headers: { Authorization: `Bearer ${env.apiKey}` }, + }; +}); +``` + +## Singleton model + +A single `provideAgent({...})` call configures the entire application. Every `injectAgent()` call resolves to the same configured agent. + +```ts +provideAgent({ url: 'https://api.example.com/agent' }); + +// Elsewhere, inside an injection context: +const chat = injectAgent(); +``` + +## What's Next + + + + The primitive you call inside components after registering a provider. + + + End-to-end setup connecting a real AG-UI backend in minutes. + + + Swap the live backend for an in-process test double during development. + + + +{/* Auto-rendered from api-docs.json — see page component */} diff --git a/apps/website/content/docs/ag-ui/api/to-agent.mdx b/apps/website/content/docs/ag-ui/api/to-agent.mdx new file mode 100644 index 000000000..651c4f8fd --- /dev/null +++ b/apps/website/content/docs/ag-ui/api/to-agent.mdx @@ -0,0 +1,80 @@ +# toAgent() + +`toAgent()` is the lower-level adapter function that wraps a raw AG-UI `AbstractAgent` into the runtime-neutral `Agent` contract used by `@threadplane/chat` components. + +```ts +toAgent(source: AbstractAgent, options?: ToAgentOptions): AgUiAgent +``` + +Most applications should use [`provideAgent()`](/docs/ag-ui/api/provide-agent) instead — it constructs the `HttpAgent` source and calls `toAgent()` internally. Reach for `toAgent()` directly when you instantiate or customize the `AbstractAgent` yourself, for example to integrate a non-HTTP AG-UI transport. + +```ts +import { HttpAgent } from '@ag-ui/client'; +import { toAgent } from '@threadplane/ag-ui'; + +const source = new HttpAgent({ url: 'http://localhost:8000/my-agent' }); +const agent = toAgent(source, { telemetry: myTelemetrySink }); +``` + +## ToAgentOptions + +| Option | Type | Description | +|--------|------|-------------| +| `telemetry` | `AgentRuntimeTelemetrySink \| false` | Optional app-owned telemetry sink. No telemetry is emitted unless this is provided. | + +## AgUiAgent + +`toAgent()` returns an `AgUiAgent`, which extends the neutral `Agent` contract with one additional signal: + +| Field | Type | Description | +|-------|------|-------------| +| `customEvents()` | `Signal` | Custom events accumulated during a run. Resets at the start of each new run. | + +The standard `Agent` signals (`messages`, `status`, `isLoading`, `error`, `toolCalls`, `state`, `interrupt`) and actions (`submit`, `stop`, `regenerate`) are all present. + +## CustomStreamEvent + +`CustomStreamEvent` is the element type of `AgUiAgent.customEvents`: + +```ts +interface CustomStreamEvent { + /** Event name set by the backend (e.g. 'a2ui-partial', 'state_update'). */ + name: string; + /** Arbitrary payload from the backend (JSON-string values are parsed). */ + data: unknown; +} +``` + +Custom events are surfaced from AG-UI `CUSTOM` protocol events whose `name` is not `on_interrupt`. The `on_interrupt` event is routed to the `interrupt` signal instead. + +## Lifecycle note + +The returned `AgUiAgent` does not manage its own lifetime. When using DI via `provideAgent()`, the provider's destroy hook handles cleanup. When calling `toAgent()` directly, treat the returned agent's lifecycle as tied to the `AbstractAgent` instance you constructed. + +## What's Next + + + + The DI-friendly wrapper around `toAgent()` for most Angular apps. + + + How the AG-UI adapter fits into the broader `@threadplane/chat` design. + + + Which AG-UI protocol events map to which signals and actions. + + + +{/* Auto-rendered from api-docs.json — see page component */} diff --git a/apps/website/content/docs/ag-ui/getting-started/introduction.mdx b/apps/website/content/docs/ag-ui/getting-started/introduction.mdx index f376b29e7..a567ccfb6 100644 --- a/apps/website/content/docs/ag-ui/getting-started/introduction.mdx +++ b/apps/website/content/docs/ag-ui/getting-started/introduction.mdx @@ -8,6 +8,10 @@ AG-UI is the open agent-to-UI protocol from the CopilotKit ecosystem. It standardizes how agent runtimes stream events (messages, tool calls, state updates) to a frontend. Used by **CrewAI**, **Mastra**, **Microsoft Agent Framework**, **AG2**, **Pydantic AI**, **AWS Strands**, and the **CopilotKit runtime**. One adapter unlocks all of them. + +The [AG-UI demo](https://ag-ui.threadplane.ai) runs this exact chat surface against an AG-UI backend — streaming, tool calls, and generative UI included. Compare it side by side with the [LangGraph demo](https://demo.threadplane.ai). + + ## How it fits ```text diff --git a/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx b/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx index be1059135..72dd3c823 100644 --- a/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx +++ b/apps/website/content/docs/ag-ui/getting-started/quickstart.mdx @@ -6,6 +6,10 @@ Let's bind `` from `@threadplane/chat` to an AG-UI backend in 5 minutes. Angular 20+ project with Node.js 22+. If you need setup help, see the [Installation](/docs/ag-ui/getting-started/installation) guide. + +Want to see the finished result before you build? Open the live [AG-UI demo](https://ag-ui.threadplane.ai). + + diff --git a/apps/website/content/docs/ag-ui/guides/citations.mdx b/apps/website/content/docs/ag-ui/guides/citations.mdx index cf20972a6..108788e5e 100644 --- a/apps/website/content/docs/ag-ui/guides/citations.mdx +++ b/apps/website/content/docs/ag-ui/guides/citations.mdx @@ -117,7 +117,7 @@ const nextMessages = bridgeCitationsState( ); ``` -Most apps should not need this directly. The built-in AG-UI reducer already calls it for state snapshots and deltas. +Most apps should not need this directly. The built-in AG-UI reducer already calls it for state snapshots and deltas. The `bridgeCitationsState(thread, messages)` helper merges thread-level citation state onto the message list; import it from `@threadplane/ag-ui` when you assemble messages yourself outside the standard adapter pipeline. ## Gotchas diff --git a/apps/website/content/docs/ag-ui/guides/custom-events.mdx b/apps/website/content/docs/ag-ui/guides/custom-events.mdx new file mode 100644 index 000000000..1fe6be66b --- /dev/null +++ b/apps/website/content/docs/ag-ui/guides/custom-events.mdx @@ -0,0 +1,138 @@ +# Custom Events + +AG-UI `CUSTOM` events let a backend node push arbitrary data to the Angular client while a run is in progress. The adapter accumulates these events into the `customEvents` signal on the `AgUiAgent` returned by `injectAgent()`. + +```ts +interface CustomStreamEvent { + name: string; + data: unknown; +} +``` + +`customEvents` is a `Signal`. The list is reset to `[]` when `RUN_STARTED` arrives, so it only ever contains events from the current run. + + +The special `CUSTOM` event with `name: "on_interrupt"` is handled separately — it populates `agent.interrupt` and does **not** appear in `customEvents`. See the [Interrupts guide](/docs/ag-ui/guides/interrupts). + + +## Where Custom Events Come From + +A LangGraph node emits a custom event by writing to the stream writer with `stream_mode='custom'`: + +```python +from langchain_core.runnables import RunnableConfig +from langgraph.config import get_stream_writer + +def analysis_node(state: State, config: RunnableConfig) -> State: + writer = get_stream_writer() + + # Emit a partial result as the node runs + writer({"name": "analysis_progress", "data": {"step": "scoring", "pct": 42}}) + + # ... do more work ... + + writer({"name": "analysis_progress", "data": {"step": "scoring", "pct": 100}}) + return state +``` + +The `ag-ui-langgraph` package surfaces this as an AG-UI `CUSTOM` event on the wire: + +```json +{ + "type": "CUSTOM", + "name": "analysis_progress", + "value": { "step": "scoring", "pct": 42 } +} +``` + +The adapter JSON-parses `value` when it arrives as a string, so consumers always receive the structured object. The event is appended to `customEvents` as `{ name: "analysis_progress", data: { step: "scoring", pct: 42 } }`. + +## Reading Custom Events in Angular + +`injectAgent()` returns the neutral `Agent` type; cast it to `AgUiAgent` (exported from `@threadplane/ag-ui`) to reach the `customEvents` signal. + +### Reactive effect + +Use an `effect` to react every time new events arrive: + +```typescript +import { Component, ChangeDetectionStrategy, effect, signal } from '@angular/core'; +import { ChatComponent } from '@threadplane/chat'; +import { injectAgent, type AgUiAgent } from '@threadplane/ag-ui'; + +@Component({ + standalone: true, + imports: [ChatComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + @if (progress() !== null) { + + } + `, +}) +export class AnalysisComponent { + protected readonly agent = injectAgent() as AgUiAgent; + protected readonly progress = signal(null); + + constructor() { + effect(() => { + const events = this.agent.customEvents(); + const last = [...events] + .reverse() + .find((e) => e.name === 'analysis_progress'); + this.progress.set( + last ? (last.data as { pct: number }).pct : null, + ); + }); + } +} +``` + +### Computed signal + +When you only need to derive a value, `computed` is more concise: + +```typescript +import { Component, ChangeDetectionStrategy, computed } from '@angular/core'; +import { ChatComponent } from '@threadplane/chat'; +import { injectAgent, type AgUiAgent } from '@threadplane/ag-ui'; +import type { CustomStreamEvent } from '@threadplane/ag-ui'; + +@Component({ + standalone: true, + imports: [ChatComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + `, +}) +export class AnalysisComponent { + protected readonly agent = injectAgent() as AgUiAgent; + + protected readonly progressEvents = computed(() => + this.agent.customEvents().filter( + (e): e is CustomStreamEvent => e.name === 'analysis_progress', + ), + ); +} +``` + +Both patterns are zoneless-safe: Angular's signal graph tracks the `customEvents()` read and re-evaluates the derived value automatically. + + +`customEvents` is the mechanism the chat composition uses for progressive a2ui surface updates — partial argument events accumulate here during a tool call and drive live rendering before the call completes. If you are building a custom a2ui integration over AG-UI, read `agent.customEvents()` the same way. + + +## Relation to Interrupts + +`CUSTOM` events named `on_interrupt` follow a separate path: the adapter routes them to `agent.interrupt` (a `Signal`) and they never enter `customEvents`. This keeps the two signals purpose-distinct — `interrupt` drives human-in-the-loop approval flows, while `customEvents` carries all other backend-pushed data. + +See the [Interrupts guide](/docs/ag-ui/guides/interrupts) for the full interrupt lifecycle including `` and `submit({ resume })`. + +## See Also + +- [Architecture](/docs/ag-ui/concepts/architecture) — how the adapter reduces protocol events into Angular signals +- [Event Mapping](/docs/ag-ui/reference/event-mapping) — full table of AG-UI event types and the agent fields they populate +- [injectAgent()](/docs/ag-ui/api/inject-agent) — the injection function that returns `AgUiAgent` diff --git a/apps/website/src/app/page.tsx b/apps/website/src/app/page.tsx index 015d3e808..daa0f5cf2 100644 --- a/apps/website/src/app/page.tsx +++ b/apps/website/src/app/page.tsx @@ -3,13 +3,15 @@ import { EcosystemStrip } from '../components/landing/EcosystemStrip'; import { Differentiator } from '../components/landing/Differentiator'; import { FeatureBlock } from '../components/landing/FeatureBlock'; import { BrowserFrame } from '../components/ui/BrowserFrame'; -import { LiveDemoFrame } from '../components/landing/LiveDemoFrame'; +import { DemoShowcase } from '../components/landing/DemoShowcase'; import { PilotBlock } from '../components/landing/PilotBlock'; import { WhitePaperBlock } from '../components/landing/WhitePaperBlock'; import { Promises } from '../components/landing/Promises'; import { HomeFAQ } from '../components/landing/HomeFAQ'; import { FinalCTA } from '../components/landing/FinalCTA'; import { RecentArticles } from '../components/landing/RecentArticles'; +import { Section } from '../components/ui/Section'; +import { Container } from '../components/ui/Container'; import { tokens } from '@threadplane/design-tokens'; import { createPageMetadata, LONG_SUBHEAD, PRIMARY_TAGLINE } from '../lib/site-metadata'; @@ -27,6 +29,13 @@ export default async function HomePage() { + {/* Interactive demo showcase */} +
+ + + +
+ {/* Stream */} } + visual={ + + Threadplane chat rendering a live generative-UI dashboard + + } /> diff --git a/apps/website/src/components/docs/DocsSidebar.tsx b/apps/website/src/components/docs/DocsSidebar.tsx index 9adf50440..9f593f721 100644 --- a/apps/website/src/components/docs/DocsSidebar.tsx +++ b/apps/website/src/components/docs/DocsSidebar.tsx @@ -217,6 +217,17 @@ export function DocsSidebar({ activeLibrary, activeSection, activeSlug }: Props) + {libConfig?.demoUrl && ( + + )} + {libConfig?.sections.map((section) => ( + {DEMOS.map((demo, i) => ( + + ))} + + ); +} diff --git a/apps/website/src/components/landing/DemoShowcase.tsx b/apps/website/src/components/landing/DemoShowcase.tsx new file mode 100644 index 000000000..9a698275e --- /dev/null +++ b/apps/website/src/components/landing/DemoShowcase.tsx @@ -0,0 +1,90 @@ +'use client'; +import { useState } from 'react'; +import { tokens } from '@threadplane/design-tokens'; +import { BrowserFrame } from '../ui/BrowserFrame'; +import { Button } from '../ui/Button'; +import { DemoCtaPair } from './DemoCtaPair'; +import { DEMOS } from '../../lib/demos'; + +type TabKey = (typeof DEMOS)[number]['key']; + +interface DemoMedia { + key: TabKey; + tabLabel: string; + url: string; + videoMp4: string; + videoWebm: string; + poster: string; + href: string; +} + +const MEDIA: DemoMedia[] = [ + { key: 'langgraph', tabLabel: 'LangGraph', url: 'demo.threadplane.ai', videoMp4: '/demo/langgraph-demo.mp4', videoWebm: '/demo/langgraph-demo.webm', poster: '/demo/langgraph-demo-poster.webp', href: DEMOS.find((d) => d.key === 'langgraph')!.href }, + { key: 'ag-ui', tabLabel: 'AG-UI', url: 'ag-ui.threadplane.ai', videoMp4: '/demo/ag-ui-demo.mp4', videoWebm: '/demo/ag-ui-demo.webm', poster: '/demo/ag-ui-demo-poster.webp', href: DEMOS.find((d) => d.key === 'ag-ui')!.href }, +]; + +export function DemoShowcase() { + const [active, setActive] = useState('langgraph'); + const [launched, setLaunched] = useState>(new Set()); + const media = MEDIA.find((m) => m.key === active)!; + const isLaunched = launched.has(active); + const launch = () => setLaunched((prev) => new Set(prev).add(active)); + + return ( +
+

See it running

+

+ One chat UI. Two runtimes. Same code. +

+

+ The identical Threadplane chat surface, running live against a LangGraph backend and an AG-UI backend. Switch tabs to compare — the front end never changes. +

+ +
+ {MEDIA.map((m) => { + const on = m.key === active; + return ( + + ); + })} +
+ + +
+ {isLaunched ? ( +