diff --git a/docs/superpowers/plans/2026-06-11-ag-ui-demo-toolbar.md b/docs/superpowers/plans/2026-06-11-ag-ui-demo-toolbar.md new file mode 100644 index 000000000..594b11880 --- /dev/null +++ b/docs/superpowers/plans/2026-06-11-ag-ui-demo-toolbar.md @@ -0,0 +1,622 @@ +# examples/ag-ui Canonical Toolbar Parity — Phase 1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the AG-UI example's static header with the canonical demo toolbar (Embed/Popup/Sidebar modes, Model/Effort/Gen-UI/Theme selects, dark/light toggle, URL+localStorage persistence), powered by a new `input.state` forwarding feature in the `@threadplane/ag-ui` adapter. + +**Architecture:** One small adapter feature (`submit()` forwards `AgentSubmitInput.state` onto the AG-UI run input) + a fresh ~300-line `AgUiShell` component in the example that reuses the canonical toolbar markup/CSS and provides the agent via an injection token to verbatim-copied mode components. Examples stay standalone — copy, never import across examples. + +**Tech Stack:** Angular 20 (signals, standalone components, router), `@threadplane/chat` (ChatComponent/ChatPopup/ChatSidebar, ChatSelect, ChatInterruptPanel), `@threadplane/ag-ui`, Playwright + aimock e2e, vitest (`nx test ag-ui`). + +**Reference spec:** `docs/superpowers/specs/2026-06-11-ag-ui-demo-toolbar-design.md` (Phase 1 of the campaign; the capability audit is Phase 2, executed after this plan's PR merges). + +**Working dir:** worktree `.claude/worktrees/ag-ui-demo-toolbar` (branch `worktree-ag-ui-demo-toolbar`). + +**Verification gates:** `npx nx lint ag-ui && npx nx test ag-ui` for the adapter; `npx nx lint examples-ag-ui-angular && npx nx build examples-ag-ui-angular` for the example (generate the license key first if builds fail on `license-public-key.generated`: `node libs/licensing/scripts/generate-public-key.mjs`); `npx nx e2e examples-ag-ui-angular --skip-nx-cache` for e2e (kill stale :8000/:4201 listeners first; use `NX_DAEMON=false` if "another nx process" appears). Commit after each task. + +--- + +## Task 1: Spike — confirm `RunAgentInput.state` reaches the graph + +No code changes. Determines whether Task 2's mechanism works end-to-end. + +- [ ] **Step 1: Start the real backend** + +```bash +cd examples/ag-ui/python +OPENAI_API_KEY=$(grep '^OPENAI_API_KEY=' ../../../.env | cut -d= -f2- | tr -d '"') \ + uv run uvicorn src.server:app --port 8000 & +sleep 5 && curl -s http://localhost:8000/ok # {"ok":true} +``` + +- [ ] **Step 2: Send a run whose `state` flips the gen-UI tool** + +The graph picks `render_a2ui_surface` when `state.gen_ui_mode` is `a2ui` (default) and `generate_json_render_spec` when it is `json-render`. Send the same prompt with `state: {"gen_ui_mode": "json-render"}`: + +```bash +curl -sN -X POST http://localhost:8000/agent -H 'Content-Type: application/json' -d '{ + "threadId": "spike-1", "runId": "spike-run-1", + "state": {"gen_ui_mode": "json-render"}, + "messages": [{"id": "u1", "role": "user", "content": "Build me an interactive feedback form with a name field and a Submit button."}], + "tools": [], "context": [], "forwardedProps": {} +}' | grep -o 'generate_json_render_spec\|render_a2ui_surface' | sort -u +``` + +Expected: `generate_json_render_spec` appears (state honored). If instead `render_a2ui_surface` appears, client state is NOT applied → **fallback**: in Task 2 ALSO send the patch as `forwardedProps.state`, and add a server-side merge in `examples/ag-ui/python/src/server.py` (read `forwarded_props["state"]` and merge into the run input). Record the outcome in the commit message of Task 2. + +- [ ] **Step 3: Stop the backend** (`kill %1` or kill the :8000 listener). + +--- + +## Task 2: Adapter — forward `input.state` (`libs/ag-ui`) + +**Files:** +- Modify: `libs/ag-ui/src/lib/to-agent.ts` (the `submit` implementation) +- Test: `libs/ag-ui/src/lib/to-agent.spec.ts` (add cases beside existing ones; match the existing mock-source pattern in that file) + +- [ ] **Step 1: Add a state-merge helper + call it on both submit paths** + +In `to-agent.ts`, inside `toAgent(...)` (above the returned object), add: + +```ts + /** Forward a neutral-contract state patch onto the AG-UI run input. + * Mirrors the canonical demo's `input.state` mechanism: the patch is + * merged into the source agent's client state (carried on + * RunAgentInput.state) and reflected optimistically in the local + * state signal — the server's next STATE_SNAPSHOT stays authoritative. */ + const applyStatePatch = (patch: Record | undefined): void => { + if (!patch || Object.keys(patch).length === 0) return; + source.state = { ...((source.state as Record) ?? {}), ...patch }; + store.state.update((prev) => ({ ...prev, ...patch })); + }; +``` + +In `submit`, FIRST line of the resume branch (before `store.interrupt.set(undefined)`), add `applyStatePatch(input.state);`. In the normal path, add `applyStatePatch(input.state);` immediately before the optimistic user-message append. (If Task 1 chose the fallback, additionally include `...(input.state ? { state: input.state } : {})` inside the `forwardedProps` object on both `runAgent` calls, and implement the `server.py` merge.) + +- [ ] **Step 2: Add unit tests** + +In `to-agent.spec.ts`, following the file's existing mock-source conventions, add a `describe('input.state forwarding', ...)` with four cases: + +```ts +it('merges input.state into the source agent state before running', async () => { + // arrange mock source with state = { a: 1 } + await agent.submit({ message: 'hi', state: { gen_ui_mode: 'json-render' } }); + expect(source.state).toEqual({ a: 1, gen_ui_mode: 'json-render' }); +}); + +it('reflects the patch in the local state() signal optimistically', async () => { + await agent.submit({ message: 'hi', state: { model: 'gpt-5-nano' } }); + expect(agent.state()['model']).toBe('gpt-5-nano'); +}); + +it('forwards state on the resume path too', async () => { + // arrange an active interrupt on the store first (per existing interrupt tests) + await agent.submit({ resume: 'approved', state: { reasoning_effort: 'high' } }); + expect(source.state).toMatchObject({ reasoning_effort: 'high' }); +}); + +it('leaves source state untouched when input.state is absent', async () => { + await agent.submit({ message: 'hi' }); + expect(source.state).toEqual({ a: 1 }); +}); +``` + +Adapt arrangement code to the spec file's existing helpers — assertions stay as written. + +- [ ] **Step 3: Verify** + +Run: `npx nx lint ag-ui && npx nx test ag-ui --skip-nx-cache` +Expected: PASS (new tests green, existing green). + +- [ ] **Step 4: Commit** + +```bash +git add libs/ag-ui/src/lib/to-agent.ts libs/ag-ui/src/lib/to-agent.spec.ts +git commit -m "feat(ag-ui): submit() forwards input.state onto the run input" +``` + +--- + +## Task 3: Example scaffold — token, persistence, routes, modes + +**Files:** +- Create: `examples/ag-ui/angular/src/app/shell/palette-persistence.service.ts` +- Create: `examples/ag-ui/angular/src/app/app.routes.ts` +- Create (copies): `examples/ag-ui/angular/src/app/modes/{embed-mode,popup-mode,sidebar-mode,welcome-suggestions}.component.ts`, `examples/ag-ui/angular/src/app/modes/welcome-suggestions.ts` +- Modify: `examples/ag-ui/angular/src/app/app.config.ts` (add router) + +**Agent-sharing decision (differs from canonical):** the canonical demo hands modes the agent via a `DEMO_AGENT` injection token. This example skips the token — mode components read the shell's wrapped agent directly via `inject(AgUiShell).agent` (the routed components live inside the shell's injector, and `AgUiShell` is the component class, injectable like any ancestor). No `shell-tokens.ts` is created. + +- [ ] **Step 1: palette-persistence.service.ts** + +Copy `examples/chat/angular/src/app/shell/palette-persistence.service.ts` verbatim, then: change the storage key to `'threadplane-ag-ui-demo:palette'` and trim the `PaletteState` interface to exactly: + +```ts +interface PaletteState { + model: string; + effort: string; + genUiMode: string; + theme: string; + colorScheme: string; +} +``` + +(Delete thread/project/sidenav keys; keep the class/read/write logic unchanged.) + +- [ ] **Step 3: app.routes.ts** + +```ts +// SPDX-License-Identifier: MIT +import { Routes } from '@angular/router'; +import { EmbedMode } from './modes/embed-mode.component'; +import { PopupMode } from './modes/popup-mode.component'; +import { SidebarMode } from './modes/sidebar-mode.component'; + +export const routes: Routes = [ + { path: 'embed', component: EmbedMode }, + { path: 'popup', component: PopupMode }, + { path: 'sidebar', component: SidebarMode }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, +]; +``` + +(No thread-id URL matcher — this example has no threads.) + +- [ ] **Step 4: copy mode components + suggestions** + +```bash +cp examples/chat/angular/src/app/modes/embed-mode.component.ts \ + examples/chat/angular/src/app/modes/popup-mode.component.ts \ + examples/chat/angular/src/app/modes/sidebar-mode.component.ts \ + examples/chat/angular/src/app/modes/welcome-suggestions.component.ts \ + examples/chat/angular/src/app/modes/welcome-suggestions.ts \ + examples/ag-ui/angular/src/app/modes/ +``` + +Then in each of the three mode components apply exactly these edits: +- `import { DemoShell } from '../shell/demo-shell.component';` → `import { AgUiShell } from '../shell/ag-ui-shell.component';` +- Remove the `DEMO_AGENT` import (`../shell/shell-tokens`). +- `inject(DemoShell)` → `inject(AgUiShell)` (field stays named `shell`). +- `protected readonly agent = inject(DEMO_AGENT);` → `protected readonly agent = inject(AgUiShell).agent;` +- Any `[selectedModel]`/`[modelOptions]`/`(selectedModelChange)` bindings remain — `AgUiShell` exposes the same members (Task 4). +- If a mode component references threads/popup launcher props that don't exist over AG-UI, leave the chat-composition bindings that compile and delete only bindings referencing shell members Task 4 doesn't define (`currentThreadTitle`, thread handlers). Report any such deletion in the task summary. + +`welcome-suggestions.component.ts` / `welcome-suggestions.ts` need no edits (they only emit prompt strings). + +- [ ] **Step 5: app.config.ts — add the router** + +Add to the providers in `examples/ag-ui/angular/src/app/app.config.ts`: + +```ts +import { provideRouter } from '@angular/router'; +import { routes } from './app.routes'; +// in providers array: +provideRouter(routes), +``` + +(Keep the existing `provideAgent({...})` and other providers unchanged.) + +- [ ] **Step 6: Verify compile** (the app still renders the OLD header until Task 4 swaps it; modes aren't routed-to yet from the template, so build only): + +Run: `npx nx lint examples-ag-ui-angular` +Expected: PASS apart from temporarily-unused imports — if lint flags unused, it is acceptable to wire Task 4 first and lint then; in that case note it and defer this step's lint to Task 4 Step 5. + +- [ ] **Step 7: Commit** + +```bash +git add examples/ag-ui/angular/src/app +git commit -m "feat(examples/ag-ui): scaffold router, modes, suggestions, persistence" +``` + +--- + +## Task 4: `AgUiShell` — toolbar, submit wrapper, persistence, theme + +**Files:** +- Create: `examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts` +- Create: `examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html` +- Create (copy): `examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css` +- Modify: `examples/ag-ui/angular/src/app/app.ts`, `app.html` + +- [ ] **Step 1: CSS** + +Copy `examples/chat/angular/src/app/shell/demo-shell.component.css` to `ag-ui-shell.component.css` unchanged, then rename the root class prefix `demo-shell` → `ag-ui-shell` throughout (`sed -i '' 's/demo-shell/ag-ui-shell/g'`). Unused sidenav/palette selectors are harmless; do not hand-prune. + +- [ ] **Step 2: Template (`ag-ui-shell.component.html`)** + +```html +
+ + +
+ + @if (agent.interrupt && agent.interrupt()) { +
+ +
+ } +
+
+``` + +Add a small rule to the CSS for the relocated toggle (append at end of file): + +```css +.ag-ui-shell__theme-toggle--toolbar { margin-left: auto; } +``` + +- [ ] **Step 3: Component (`ag-ui-shell.component.ts`) — full code** + +```ts +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + DOCUMENT, + effect, + inject, + signal, +} from '@angular/core'; +import { ActivatedRoute, Router, RouterOutlet } from '@angular/router'; +import { injectAgent } from '@threadplane/ag-ui'; +import { + ChatInterruptPanelComponent, + ChatSelectComponent, + type InterruptAction, +} from '@threadplane/chat'; +import { PalettePersistence } from './palette-persistence.service'; + +export type DemoMode = 'embed' | 'popup' | 'sidebar'; +const MODES: readonly DemoMode[] = ['embed', 'popup', 'sidebar'] as const; + +/** Default knob values — omitted from the URL when active. */ +const DEFAULTS = { + model: 'gpt-5-mini', + effort: 'minimal', + genui: 'a2ui', + theme: 'default-dark', + scheme: 'dark', +} as const; + +@Component({ + selector: 'ag-ui-shell', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterOutlet, ChatSelectComponent, ChatInterruptPanelComponent], + templateUrl: './ag-ui-shell.component.html', + styleUrl: './ag-ui-shell.component.css', + providers: [PalettePersistence], +}) +export class AgUiShell { + private readonly router = inject(Router); + private readonly route = inject(ActivatedRoute); + private readonly document = inject(DOCUMENT); + protected readonly persistence = inject(PalettePersistence); + + // ── Knob signals: URL > localStorage > default ─────────────────────────── + private urlKnob(name: string): string | null { + const v = this.route.snapshot.queryParamMap.get(name); + return v && v.length > 0 ? v : null; + } + + readonly model = signal(this.urlKnob('model') ?? this.persistence.read('model') ?? DEFAULTS.model); + readonly effort = signal(this.urlKnob('effort') ?? this.persistence.read('effort') ?? DEFAULTS.effort); + readonly genUiMode = signal(this.urlKnob('genui') ?? this.persistence.read('genUiMode') ?? DEFAULTS.genui); + readonly theme = signal(this.urlKnob('theme') ?? this.persistence.read('theme') ?? DEFAULTS.theme); + readonly colorScheme = signal<'light' | 'dark'>( + ((this.urlKnob('scheme') ?? this.persistence.read('colorScheme')) as 'light' | 'dark' | null) ?? DEFAULTS.scheme, + ); + + // ── Mode from the active route ─────────────────────────────────────────── + readonly mode = signal(this.parseMode(this.router.url)); + protected readonly modeOptions: readonly { value: DemoMode; label: string }[] = [ + { value: 'embed', label: 'Embed' }, + { value: 'popup', label: 'Popup' }, + { value: 'sidebar', label: 'Sidebar' }, + ]; + + private parseMode(url: string): DemoMode { + const seg = url.split('?')[0].split('/').filter(Boolean)[0]; + return (MODES as readonly string[]).includes(seg) ? (seg as DemoMode) : 'embed'; + } + + // ── Select options (canonical lists) ───────────────────────────────────── + protected readonly modelOptions = signal([ + { value: 'gpt-5-mini', label: 'gpt-5-mini' }, + { value: 'gpt-5-nano', label: 'gpt-5-nano' }, + ]); + protected readonly effortOptions = signal([ + { value: 'minimal', label: 'minimal (fast)' }, + { value: 'low', label: 'low' }, + { value: 'medium', label: 'medium' }, + { value: 'high', label: 'high (visible reasoning)' }, + ]); + protected readonly genUiOptions = signal([ + { value: 'a2ui', label: 'A2UI' }, + { value: 'json-render', label: 'json-render' }, + ]); + protected readonly themeOptions = signal([ + { value: 'default-dark', label: 'Default dark' }, + { value: 'default-light', label: 'Default light' }, + { value: 'material-dark', label: 'Material dark' }, + { value: 'material-light', label: 'Material light' }, + ]); + + // ── Shared agent: submit wrapper merges the knobs into input.state ────── + readonly agent = (() => { + const a = injectAgent(); + const orig = a.submit.bind(a); + (a as { submit: typeof a.submit }).submit = (async ( + input: Parameters[0], + opts?: Parameters[1], + ) => { + return orig( + { + ...(input ?? {}), + state: { + ...((input as { state?: Record })?.state ?? {}), + model: this.model(), + reasoning_effort: this.effort(), + gen_ui_mode: this.genUiMode(), + }, + }, + opts, + ); + }) as typeof a.submit; + return a; + })(); + + constructor() { + // Routed mode components read the shared wrapped agent via + // `inject(AgUiShell).agent` — no token needed (see Task 3 note). + // Keep mode() in sync with navigation. + this.router.events.subscribe(() => { + const m = this.parseMode(this.router.url); + if (m !== this.mode()) this.mode.set(m); + }); + + // Reflect theme + scheme onto exactly like the canonical shell. + effect(() => { + const html = this.document.documentElement; + html.setAttribute('data-theme', this.theme()); + const scheme = this.colorScheme(); + html.setAttribute('data-threadplane-chat-theme', scheme); + const t = this.theme(); + if (t === 'default-dark' || t === 'default-light') { + const next = scheme === 'light' ? 'default-light' : 'default-dark'; + if (next !== t) this.theme.set(next); + } + }); + + // Persist + sync knobs to the URL (defaults omitted). + effect(() => { + const q: Record = { + model: this.model() === DEFAULTS.model ? null : this.model(), + effort: this.effort() === DEFAULTS.effort ? null : this.effort(), + genui: this.genUiMode() === DEFAULTS.genui ? null : this.genUiMode(), + theme: this.theme() === DEFAULTS.theme ? null : this.theme(), + scheme: this.colorScheme() === DEFAULTS.scheme ? null : this.colorScheme(), + }; + void this.router.navigate([], { queryParams: q, queryParamsHandling: 'merge', replaceUrl: true }); + }); + } + + protected onModeChange(next: DemoMode | string): void { + if (!(MODES as readonly string[]).includes(next as string)) return; + void this.router.navigate(['/', next], { queryParamsHandling: 'preserve' }); + } + protected onModelChange(v: string): void { this.model.set(v); this.persistence.write('model', v); } + protected onEffortChange(v: string): void { this.effort.set(v); this.persistence.write('effort', v); } + protected onGenUiModeChange(v: string): void { this.genUiMode.set(v); this.persistence.write('genUiMode', v); } + protected onThemeChange(v: string): void { this.theme.set(v); this.persistence.write('theme', v); } + protected onColorSchemeChange(v: 'light' | 'dark' | string): void { + if (v !== 'light' && v !== 'dark') return; + this.colorScheme.set(v); + this.persistence.write('colorScheme', v); + } + + /** Same four-action vocabulary as the canonical shell; resumes via + * AG-UI's submit({ resume }) path (forwardedProps.command.resume). */ + protected async onInterruptAction(action: InterruptAction): Promise { + const interrupt = this.agent.interrupt?.(); + if (!interrupt) return; + let resume: unknown; + switch (action) { + case 'accept': resume = 'approved'; break; + case 'edit': { + const reason = (interrupt.value as { reason?: string })?.reason ?? ''; + const edited = window.prompt(`Edit your response (current proposal: "${reason}"):`, 'approved'); + if (edited == null) return; + resume = edited; break; + } + case 'respond': { + const text = window.prompt('Respond to the agent:', ''); + if (text == null) return; + resume = text; break; + } + case 'ignore': resume = 'denied'; break; + } + await this.agent.submit({ resume }); + } +} +``` + +- [ ] **Step 4: Host the shell** + +`app.html` becomes: + +```html + +``` + +`app.ts` becomes: + +```ts +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { AgUiShell } from './shell/ag-ui-shell.component'; + +@Component({ + selector: 'app-root', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [AgUiShell], + templateUrl: './app.html', +}) +export class App {} +``` + +(The old header, interrupt block, and `onInterruptAction` move into the shell; `styles.css`'s `.ag-ui-demo__*` rules can stay — they're inert — but delete the `.ag-ui-demo__interrupt` rule if it conflicts.) + +- [ ] **Step 5: Verify** + +Run: `npx nx lint examples-ag-ui-angular && npx nx build examples-ag-ui-angular --skip-nx-cache` +Expected: PASS. Then serve locally (backend + `nx serve examples-ag-ui-angular --port 4201`) and confirm: toolbar renders; mode buttons navigate; selects change + persist across reload; URL knobs appear for non-defaults; dark/light toggles; sending still streams. + +- [ ] **Step 6: Commit** + +```bash +git add examples/ag-ui/angular/src +git commit -m "feat(examples/ag-ui): canonical demo toolbar (modes, knobs, theme) via AgUiShell" +``` + +--- + +## Task 5: E2E — keep 10 green, add 3 toolbar specs + +**Files:** +- Create: `examples/ag-ui/angular/e2e/toolbar.spec.ts` +- Existing specs must pass unchanged (the `/`→`/embed` redirect preserves `openDemo(page, '/')`). + +- [ ] **Step 1: Write `toolbar.spec.ts`** + +```ts +// SPDX-License-Identifier: MIT +import { test, expect } from '@playwright/test'; +import { openDemo } from './test-helpers'; + +test('modes: segmented control switches embed → popup → sidebar compositions', async ({ page }) => { + await openDemo(page); + await expect(page).toHaveURL(/\/embed/); + + await page.getByRole('button', { name: 'Popup' }).click(); + await expect(page).toHaveURL(/\/popup/); + + await page.getByRole('button', { name: 'Sidebar' }).click(); + await expect(page).toHaveURL(/\/sidebar/); + + await page.getByRole('button', { name: 'Embed' }).click(); + await expect(page).toHaveURL(/\/embed/); + await expect(page.getByRole('textbox', { name: /message|prompt/i })).toBeVisible(); +}); + +test('knobs: effort select reflects ?effort=high and persists a change', async ({ page }) => { + await openDemo(page, '/embed?effort=high'); + const effortField = page.locator('[data-field="effort"]'); + await expect(effortField).toContainText(/high/i); +}); + +test('toolbar submit still streams (state merge does not break runs)', async ({ page }) => { + await openDemo(page); + const input = page.getByRole('textbox', { name: /message|prompt/i }); + await input.fill('hi'); + await page.getByRole('button', { name: /send/i }).click(); + const assistant = page.locator('chat-message').filter({ hasText: /./ }).last(); + await expect(assistant).toBeVisible({ timeout: 30_000 }); +}); +``` + +Adjust locators to match the actual DOM if `chat-select`'s trigger renders differently (use the pattern from `examples/chat`'s `toolbarSelect()` helper in its `test-helpers.ts` if needed — copy that helper into this example's `test-helpers.ts` rather than importing across examples). + +- [ ] **Step 2: Run the suite** + +Clean stale listeners first, then: + +```bash +NX_DAEMON=false npx nx e2e examples-ag-ui-angular --skip-nx-cache +``` + +Expected: 13/13 (10 existing + 3 new). The aimock fixture replay is prompt-matched, so the added `state` keys must not break matching — if a fixture mismatch appears, check whether aimock matches on messages only (expected) and report otherwise. + +- [ ] **Step 3: Commit** + +```bash +git add examples/ag-ui/angular/e2e +git commit -m "test(examples/ag-ui): toolbar e2e — modes, URL knobs, state-merge smoke" +``` + +--- + +## Task 6: Local verification + PR + +- [ ] **Step 1: Full local gates** + +```bash +npx nx lint ag-ui && npx nx test ag-ui --skip-nx-cache +npx nx lint examples-ag-ui-angular && npx nx build examples-ag-ui-angular --skip-nx-cache +NX_DAEMON=false npx nx e2e examples-ag-ui-angular --skip-nx-cache +``` + +All green. + +- [ ] **Step 2: Push + PR** + +```bash +git push -u origin worktree-ag-ui-demo-toolbar +gh pr create --base main --title "feat(examples/ag-ui): canonical demo toolbar parity (modes + knobs over input.state)" --body "" +``` + +Auto-merge on green (`gh pr merge --auto --squash`; if GraphQL 401s, use the REST endpoint). Post-merge, confirm the `examples/ag-ui — e2e` and `ag-ui demo → Vercel` CI jobs are green. + +**Phase 2 (not in this plan):** Chrome MCP capability audit per the spec's Part-4 matrix against local servers, findings report committed, gap-closure phases planned with the user. + +--- + +## Final self-check against the spec +- Adapter `input.state` on both paths + optimistic local state ✓ (Task 2) +- Spike with explicit fallback path ✓ (Task 1) +- Toolbar: modes, 4 selects, dark/light in toolbar ✓ (Task 4) +- Routing with `/`→`/embed` ✓ (Task 3) +- Welcome suggestions ported ✓ (Task 3) +- Persistence URL > stored > default; defaults omitted from URL ✓ (Task 4) +- Trimmed: sidenav/palette/projects/subagents/debug ✓ (absent by construction) +- E2E: 10 green + 3 new ✓ (Task 5) diff --git a/docs/superpowers/specs/2026-06-11-ag-ui-demo-toolbar-design.md b/docs/superpowers/specs/2026-06-11-ag-ui-demo-toolbar-design.md new file mode 100644 index 000000000..2f74bb886 --- /dev/null +++ b/docs/superpowers/specs/2026-06-11-ag-ui-demo-toolbar-design.md @@ -0,0 +1,117 @@ +# examples/ag-ui — Canonical Demo Toolbar Parity + +**Date:** 2026-06-11 +**Status:** Design / spec +**Scope:** `examples/ag-ui/angular` (frontend port), `libs/ag-ui` (one small adapter feature). No python/graph changes expected (verify step included). Approach A from brainstorming: port the canonical demo-shell pattern, trimmed; examples stay standalone (duplicate, don't share). + +--- + +## Goal + +Replace the AG-UI example's static header ("AG-UI Chat / The Threadplane chat UI over the AG-UI transport") with the **canonical demo's toolbar**, at full functional parity where the transport allows: + +- **Mode segmented control** — Embed / Popup / Sidebar (each a route rendering a different chat composition). +- **Model / Effort / Gen-UI / Theme selects** (`chat-select`), identical options to the canonical demo. +- **Dark/light toggle** — relocated into the toolbar's right side (canonical hosts it in the threads-sidenav footer, which this example doesn't have). +- **URL knobs + localStorage persistence** for all surviving controls. +- **Welcome-suggestion chips** in each mode's empty state (the canonical prompt list — every prompt works against this example's graph, which is a copy of chat's). + +## Why this is now small + +The canonical demo does NOT use LangGraph-specific config for the Model/Effort/Gen-UI knobs. It wraps `agent.submit` and merges `{model, reasoning_effort, gen_ui_mode}` into the **neutral contract's `input.state`** (`AgentSubmitInput.state`), and the graph reads those keys from **state** (`state.get("reasoning_effort")`, etc.). The AG-UI example's python graph is a copy of the same graph — it already reads these keys. The only missing plumbing is that the AG-UI adapter's `submit()` currently ignores `input.state`. + +--- + +## Part 1 — Adapter: forward `input.state` (`libs/ag-ui`) + +`libs/ag-ui/src/lib/to-agent.ts` `submit()`: + +- When `input.state` is present, merge it into the AG-UI source agent's client state so it is carried on the run input (AG-UI `RunAgentInput.state` — the same channel the shared-state/json-render examples use server→client, used client→server here). +- Apply on **both** paths: the normal submit path and the resume path (`input.resume` + `input.state` may be combined per the contract). +- Also optimistically merge into the local `store.state` signal so `agent.state()` reflects what was sent (the server's next `STATE_SNAPSHOT` remains authoritative). + +Unit tests beside the existing adapter specs: state forwarded on submit; state forwarded on resume; no state → unchanged behavior; local `state()` reflects the merge. + +**Verify-first spike (before frontend work):** against the local uvicorn backend, send `input.state = { reasoning_effort: 'high' }` and confirm the graph observes it (e.g. via the run's behavior or a debug log). Expected to work because `ag-ui-langgraph` merges `RunAgentInput.state` into graph input. **Fallback if it doesn't:** keep the adapter API the same but carry the patch via `forwardedProps.state` and map it into graph input in `examples/ag-ui/python/src/server.py` — contained in the same PR. + +## Part 2 — Frontend: `ag-ui-shell` (examples/ag-ui/angular) + +Copy-and-trim the canonical `examples/chat/angular/src/app/shell/demo-shell.component.*` into `examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.*` (house rule: examples are standalone; no cross-example imports): + +**Toolbar (replaces the current header):** +- Segmented Mode control (Embed/Popup/Sidebar) — same markup/classes as canonical. +- `chat-select` fields: Model, Effort, Gen UI, Theme — same option lists as canonical (`modelOptions`, `effortOptions`, `genUiOptions`, `themeOptions`). +- Dark/light scheme toggle button (sun/moon SVG, same as canonical's) placed at the toolbar's right edge. + +**Routing (new — the app currently has none):** +- Add the Angular router. Routes: `''` → redirect `/embed`; `/embed`, `/popup`, `/sidebar` each render a ported mode component (copied from `examples/chat/angular/src/app/modes/`), bound to `[agent]` and `[views]`. +- Port `welcome-suggestions.ts` + `WelcomeSuggestionsComponent` unchanged. +- `/` redirecting to `/embed` keeps the existing e2e helper (`openDemo(page, '/')`) and all 10 current specs working without changes. + +**Agent wiring:** +- Keep `injectAgent()` + `a2uiBasicCatalog()`. Wrap `submit` exactly like the canonical shell: merge `{ model, reasoning_effort, gen_ui_mode }` from the toolbar signals into `input.state` on every send. +- The existing interrupt-panel region (`@if (agent.interrupt && agent.interrupt())` → ``) moves into the shell's main region, unchanged. + +**Persistence:** +- Port the canonical localStorage persistence service and URL-knob sync, trimmed to the surviving keys: `mode` (via route), `model`, `effort`, `genui`, `theme`, color scheme. Same precedence as canonical (URL > stored > default; defaults omitted from the URL). +- Theme reflection: set `data-theme` / `data-threadplane-chat-theme` on `` exactly as canonical does (including the default-light/dark auto-sync behavior). + +**Trimmed out (explicitly NOT ported):** threads sidenav + scrim + hamburger, history search palette, projects, new-chat, archived threads, subagents region (the AG-UI adapter exposes no `subagents` signal), chat-debug, thread-id URL/routing. + +## Part 3 — Out of scope + +- No thread CRUD / multi-conversation anything (transport doesn't offer it; single conversation remains a deliberate property of this example). +- No python/graph changes (unless the Part-1 fallback triggers, which adds only a small `server.py` mapping). +- No website/homepage changes; the deployed demo updates via the existing `ag-ui demo → Vercel` CI path on merge. + +## Part 4 — Forcing function: AG-UI capability verification matrix + +This effort doubles as an **audit of the `@threadplane/ag-ui` adapter**: once the toolbar exposes the canonical demo's full surface, every capability is smoke-tested end-to-end over AG-UI via **Chrome MCP against the local servers** (real backend + real OpenAI key, like the approval verification on 2026-06-11). Each row gets a verdict; each gap becomes its own follow-up spec/plan. + +| # | Capability | How to smoke it | Expected over AG-UI | +|---|------------|-----------------|---------------------| +| 1 | Streaming + markdown | "Tell me about coral reefs" | works (e2e-covered) | +| 2 | Citations | signals search+cite prompt | works (verified in recut) | +| 3 | Interrupt — Accept | approval prompt → Accept | works (verified 2026-06-11) | +| 4 | Interrupt — Edit / Respond / Ignore | same prompt, other three actions | **unverified** — exercise each resume payload | +| 5 | Model select | pick gpt-5-nano → send | run uses chosen model (`state.model`) | +| 6 | Effort select + reasoning display | Effort=high + puzzle prompt | reasoning honored; does `chat-reasoning` render over AG-UI (THINKING events)? **unverified** | +| 7 | Gen-UI: a2ui | feedback form / product card | works (verified) | +| 8 | Gen-UI: json-render | switch select → form prompt | **unverified** — tool-result rendering should be transport-neutral | +| 9 | A2UI theme presets + dark/light | theme select + toggle | frontend; confirm surface theming applies | +| 10 | Research-subagent prompt | suggestions chip | run completes; **no subagent card** (adapter exposes no `subagents`) — known gap, document UX | +| 11 | Stop mid-stream | send long prompt → stop | `abortRun` cleanly idles | +| 12 | Regenerate | regenerate icon on an answer | replace semantics work | +| 13 | Error recovery | kill backend mid-run | alert + next send recovers (e2e-covered) | + +**Gap protocol — gaps are IN SCOPE.** This is a forcing function: the toolbar exposes the full canonical surface precisely so the AG-UI library is forced to support it. When the audit finds a gap: + +- (a) **Small adapter/example bug** → fix immediately with a test, same PR. +- (b) **Capability gap** (e.g. reasoning/THINKING events not reduced, json-render bridge broken, subagent runs invisible) → root-cause it, record it in the working **findings report** (`docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md`), then **design and implement the fix as a phase of this campaign** — its own mini spec/plan + PR (library change in `libs/ag-ui`, chat-lib wiring, and/or backend mapping as needed), landed before the campaign is called done. The audit re-runs the matrix row to confirm closure. +- (c) The only deferral allowed: a gap whose fix requires changes **outside this repo** (the AG-UI protocol itself or the upstream `ag-ui-langgraph` package). Those get the findings-report treatment plus an explicit decision with the user on whether to work around (e.g. via CUSTOM events) — and a workaround, if chosen, is implemented in scope. + +Controls in the demo stay visible and honest at every intermediate state. + +**Deliverables added by this part:** the findings report (as a running log, ending with every row green or explicitly category-c), plus the gap-closure PRs themselves. + +## Risks / verify items + +1. **State→graph plumbing** (Part 1 spike). Fallback defined above. +2. **`gen_ui_mode=json-render` over AG-UI:** matrix row 8; if broken → findings report + follow-up plan (select stays visible; gap noted in the example README). +3. **E2E stability:** the `/`→`/embed` redirect must keep all 10 existing specs green unchanged; mode components and suggestions render against aimock fixtures exactly as in examples/chat. + +## Testing + +- **Adapter:** unit tests listed in Part 1 (`nx test ag-ui`). +- **Example e2e:** existing 10 specs green unchanged; new specs: (a) mode switch — `/popup` and `/sidebar` render their compositions (toolbar click updates route + composition mounts); (b) toolbar state — pick `Effort = high`, send a fixture prompt, assert the run proceeds (and, if cheaply assertable via aimock request capture, that the LLM request reflects the knob); (c) URL knob — load `/embed?effort=high` and assert the select reflects it. +- **Local manual verification** (Chrome, like today): toolbar renders; modes switch; selects persist across reload; dark/light toggles; approval + a2ui still work. +- **Lint/build:** `nx lint ag-ui`, `nx test ag-ui`, `nx lint examples-ag-ui-angular`, `nx build examples-ag-ui-angular`. + +## Decomposition (for the plan) + +The campaign is phased; it is done only when the matrix is green (or explicitly category-c with a user decision): + +1. **Phase 1 — toolbar parity PR:** spike + adapter `input.state` forwarding (+tests); shell scaffold (routing, modes, suggestions); toolbar selects + submit wrapper + persistence + theme/scheme; e2e (10 green + 3 new). PR, merge on green. +2. **Phase 2 — capability audit:** Chrome MCP smoke of the full Part-4 matrix against local servers; findings report committed with verdict + root cause per row. +3. **Phase 3..N — gap closure:** one phase per gap, in priority order agreed with the user — mini spec/plan + implementation PR each (adapter / chat wiring / backend mapping), then re-run the matrix row to confirm. Category-(a) bugs are fixed wherever found without ceremony. +4. **Wrap:** verify the deployed demo; final matrix re-run noted in the findings report. diff --git a/examples/ag-ui/angular/e2e/playwright.config.ts b/examples/ag-ui/angular/e2e/playwright.config.ts index 73424f079..f108fc166 100644 --- a/examples/ag-ui/angular/e2e/playwright.config.ts +++ b/examples/ag-ui/angular/e2e/playwright.config.ts @@ -8,6 +8,7 @@ export default defineConfig({ fullyParallel: false, workers: 1, retries: process.env.CI ? 2 : 0, + timeout: 60_000, reporter: process.env.CI ? [['list'], ['html', { open: 'never' }]] : 'list', use: { baseURL: 'http://localhost:4201', diff --git a/examples/ag-ui/angular/e2e/toolbar.spec.ts b/examples/ag-ui/angular/e2e/toolbar.spec.ts new file mode 100644 index 000000000..ed19f3dc3 --- /dev/null +++ b/examples/ag-ui/angular/e2e/toolbar.spec.ts @@ -0,0 +1,33 @@ +// SPDX-License-Identifier: MIT +import { test, expect } from '@playwright/test'; +import { openDemo } from './test-helpers'; + +test('modes: segmented control switches embed → popup → sidebar compositions', async ({ page }) => { + await openDemo(page); + await expect(page).toHaveURL(/\/embed/); + + await page.getByRole('button', { name: 'Popup' }).click(); + await expect(page).toHaveURL(/\/popup/); + + await page.getByRole('button', { name: 'Sidebar' }).click(); + await expect(page).toHaveURL(/\/sidebar/); + + await page.getByRole('button', { name: 'Embed' }).click(); + await expect(page).toHaveURL(/\/embed/); + await expect(page.getByRole('textbox', { name: /message|prompt/i })).toBeVisible(); +}); + +test('knobs: effort select reflects ?effort=high and persists a change', async ({ page }) => { + await openDemo(page, '/embed?effort=high'); + const effortField = page.locator('[data-field="effort"]'); + await expect(effortField).toContainText(/high/i); +}); + +test('toolbar submit still streams (state merge does not break runs)', async ({ page }) => { + await openDemo(page); + const input = page.getByRole('textbox', { name: /message|prompt/i }); + await input.fill('say hi briefly'); + await page.getByRole('button', { name: /send/i }).click(); + const assistant = page.locator('chat-message').filter({ hasText: /./ }).last(); + await expect(assistant).toBeVisible({ timeout: 30_000 }); +}); diff --git a/examples/ag-ui/angular/src/app/app.config.ts b/examples/ag-ui/angular/src/app/app.config.ts index 9010b8e67..bdc229759 100644 --- a/examples/ag-ui/angular/src/app/app.config.ts +++ b/examples/ag-ui/angular/src/app/app.config.ts @@ -4,16 +4,19 @@ import { provideBrowserGlobalErrorListeners, provideZonelessChangeDetection, } from '@angular/core'; +import { provideRouter } from '@angular/router'; import { provideThreadplaneTelemetry } from '@threadplane/telemetry/browser'; import { provideChat } from '@threadplane/chat'; import { provideAgent } from '@threadplane/ag-ui'; import { environment } from '../environments/environment'; +import { routes } from './app.routes'; import { ItineraryStore } from './itinerary-store'; export const appConfig: ApplicationConfig = { providers: [ provideBrowserGlobalErrorListeners(), provideZonelessChangeDetection(), + provideRouter(routes), provideThreadplaneTelemetry(environment.telemetry), provideAgent({ url: environment.agentUrl }), provideChat({ license: environment.license }), diff --git a/examples/ag-ui/angular/src/app/app.html b/examples/ag-ui/angular/src/app/app.html index b21d9684f..800d7912d 100644 --- a/examples/ag-ui/angular/src/app/app.html +++ b/examples/ag-ui/angular/src/app/app.html @@ -1,29 +1 @@ -
-
-

AG-UI Chat

-

The Threadplane chat UI over the AG-UI transport.

-
-
- -
- @if (agent.interrupt && agent.interrupt()) { -
- -
- } - -
- @for (s of suggestions; track s.value) { - - } -
-
-
-
-
+ diff --git a/examples/ag-ui/angular/src/app/app.routes.ts b/examples/ag-ui/angular/src/app/app.routes.ts new file mode 100644 index 000000000..4d319e76c --- /dev/null +++ b/examples/ag-ui/angular/src/app/app.routes.ts @@ -0,0 +1,13 @@ +// SPDX-License-Identifier: MIT +import { Routes } from '@angular/router'; +import { EmbedMode } from './modes/embed-mode.component'; +import { PopupMode } from './modes/popup-mode.component'; +import { SidebarMode } from './modes/sidebar-mode.component'; + +export const routes: Routes = [ + { path: 'embed', component: EmbedMode }, + { path: 'popup', component: PopupMode }, + { path: 'sidebar', component: SidebarMode }, + { path: '', pathMatch: 'full', redirectTo: 'embed' }, + { path: '**', redirectTo: 'embed' }, +]; diff --git a/examples/ag-ui/angular/src/app/app.ts b/examples/ag-ui/angular/src/app/app.ts index 9d6127d07..eb33f9612 100644 --- a/examples/ag-ui/angular/src/app/app.ts +++ b/examples/ag-ui/angular/src/app/app.ts @@ -1,93 +1,12 @@ // SPDX-License-Identifier: MIT import { ChangeDetectionStrategy, Component } from '@angular/core'; -import { injectAgent } from '@threadplane/ag-ui'; -import { - ChatComponent, - ChatInterruptPanelComponent, - ChatWelcomeSuggestionComponent, - a2uiBasicCatalog, - type InterruptAction, -} from '@threadplane/chat'; -import { ItineraryPanelComponent } from './itinerary-panel.component'; -import { itineraryClientTools } from './client-tools'; +import { AgUiShell } from './shell/ag-ui-shell.component'; @Component({ selector: 'app-root', standalone: true, changeDetection: ChangeDetectionStrategy.OnPush, - imports: [ - ChatComponent, - ChatInterruptPanelComponent, - ChatWelcomeSuggestionComponent, - ItineraryPanelComponent, - ], + imports: [AgUiShell], templateUrl: './app.html', }) -export class App { - protected readonly agent = injectAgent(); - // The a2ui-surface render block in is gated on a truthy `views` - // catalog — without it, a2ui surfaces parse but never mount and the - // render_a2ui_surface tool call shows only as a tool chip (issue #616). - protected readonly catalog = a2uiBasicCatalog(); - - // Built in an injection context (field initializer) so itineraryClientTools() - // can inject the shared ItineraryStore. These declare what the agent can do - // to the page; the browser executes each call against the same store the - // panel renders. - protected readonly clientTools = itineraryClientTools(); - - // Welcome chips spanning the demo's full capability surface — docs/citations, - // generative UI, human approval, the five itinerary client tools, and the - // research subagent. Selecting one submits its prompt verbatim. - protected readonly suggestions = [ - { label: 'Docs & citations', value: 'What do the docs say about streaming?' }, - { label: 'Generative UI', value: 'Build me a revenue dashboard' }, - { label: 'Human approval', value: 'Issue me a $50 refund' }, - { label: 'Read my itinerary', value: "What's on my itinerary?" }, - { label: 'Agent edits the page', value: 'Add the Louvre to day 2 of my trip' }, - { label: 'Consent-gated clear', value: 'Clear my day 2 plans' }, - { label: 'Research subagent', value: 'Research AG-UI and give me the highlights' }, - ]; - - protected send(value: string): void { - void this.agent.submit({ message: value }); - } - - /** - * Resolve a human-in-the-loop interrupt (request_approval). The - * chat-interrupt-panel emits a four-action vocabulary; map each to a resume - * payload and replay the run via AG-UI's resume path — `submit({ resume })`, - * which the adapter forwards as `forwardedProps.command.resume`. `edit` / - * `respond` use window.prompt as a demo affordance; a production app would - * inline a textarea editor. - */ - protected async onInterruptAction(action: InterruptAction): Promise { - const interrupt = this.agent.interrupt?.(); - if (!interrupt) return; - - let resume: unknown; - switch (action) { - case 'accept': - resume = 'approved'; - break; - case 'edit': { - const reason = (interrupt.value as { reason?: string })?.reason ?? ''; - const edited = window.prompt(`Edit your response (current proposal: "${reason}"):`, 'approved'); - if (edited == null) return; - resume = edited; - break; - } - case 'respond': { - const text = window.prompt('Respond to the agent:', ''); - if (text == null) return; - resume = text; - break; - } - case 'ignore': - resume = 'denied'; - break; - } - - await this.agent.submit({ resume }); - } -} +export class App {} diff --git a/examples/ag-ui/angular/src/app/modes/embed-mode.component.ts b/examples/ag-ui/angular/src/app/modes/embed-mode.component.ts new file mode 100644 index 000000000..cf14fa70c --- /dev/null +++ b/examples/ag-ui/angular/src/app/modes/embed-mode.component.ts @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { ChatComponent, a2uiBasicCatalog } from '@threadplane/chat'; +import { AgUiShell } from '../shell/ag-ui-shell.component'; +import { WelcomeSuggestionsComponent } from './welcome-suggestions.component'; + +@Component({ + selector: 'embed-mode', + standalone: true, + imports: [ChatComponent, WelcomeSuggestionsComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + `, + styles: [` + :host { display: block; flex: 1; min-height: 0; } + `], +}) +export class EmbedMode { + protected readonly agent = inject(AgUiShell).agent; + protected readonly shell = inject(AgUiShell); + // Phase 4: catalog of A2UI components the chat composition uses to + // render when an AI message content begins with the + // ---a2ui_JSON--- wire-format prefix. Without this, the surface is + // parsed correctly but never mounted (the @if gate requires views()). + protected readonly catalog = a2uiBasicCatalog(); + + protected send(text: string): void { + void this.agent.submit({ message: text }); + } +} diff --git a/examples/ag-ui/angular/src/app/modes/popup-mode.component.ts b/examples/ag-ui/angular/src/app/modes/popup-mode.component.ts new file mode 100644 index 000000000..5944351fd --- /dev/null +++ b/examples/ag-ui/angular/src/app/modes/popup-mode.component.ts @@ -0,0 +1,50 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { ChatPopupComponent, a2uiBasicCatalog } from '@threadplane/chat'; +import { AgUiShell } from '../shell/ag-ui-shell.component'; +import { WelcomeSuggestionsComponent } from './welcome-suggestions.component'; + +@Component({ + selector: 'popup-mode', + standalone: true, + imports: [ChatPopupComponent, WelcomeSuggestionsComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + `, + styles: [` + :host { display: block; flex: 1; min-height: 0; } + .popup-mode__background { + display: grid; + place-items: center; + height: 100%; + color: #8a92a3; + font-size: 14px; + } + `], +}) +export class PopupMode { + protected readonly agent = inject(AgUiShell).agent; + protected readonly shell = inject(AgUiShell); + // Phase 4: A2UI component catalog forwarded to . + protected readonly catalog = a2uiBasicCatalog(); + + protected send(text: string): void { + void this.agent.submit({ message: text }); + } +} diff --git a/examples/ag-ui/angular/src/app/modes/sidebar-mode.component.ts b/examples/ag-ui/angular/src/app/modes/sidebar-mode.component.ts new file mode 100644 index 000000000..a9059c649 --- /dev/null +++ b/examples/ag-ui/angular/src/app/modes/sidebar-mode.component.ts @@ -0,0 +1,65 @@ +// SPDX-License-Identifier: MIT +import { Component, ChangeDetectionStrategy, inject } from '@angular/core'; +import { ChatSidebarComponent, a2uiBasicCatalog } from '@threadplane/chat'; +import { AgUiShell } from '../shell/ag-ui-shell.component'; +import { WelcomeSuggestionsComponent } from './welcome-suggestions.component'; + +@Component({ + selector: 'sidebar-mode', + standalone: true, + imports: [ChatSidebarComponent, WelcomeSuggestionsComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + + + + `, + styles: [` + :host { display: block; flex: 1; min-height: 0; position: relative; } + /* Projected into chat-sidebar's default content slot so [pushContent] + * applies its right-margin push to this background when the panel + * opens. Sized to fill the visible area below the toolbar. */ + .sidebar-mode__background { + display: grid; + place-items: center; + min-height: calc(100dvh - var(--demo-toolbar-height, 51px)); + color: #8a92a3; + font-size: 14px; + } + /* chat-sidebar's default content slot sets min-height: 100vh which, + * combined with the demo's flex column, would otherwise overflow the + * page. The background div above provides the visible "page" so we + * cap the chat-sidebar__content height to the available space. */ + :host ::ng-deep .chat-sidebar__content { + /* Important: lib sets min-height: 100vh on this slot which would + * push the page 51px below the viewport in our flex column under + * the 51px toolbar. Override here. */ + min-height: 0 !important; + } + `], +}) +export class SidebarMode { + protected readonly agent = inject(AgUiShell).agent; + protected readonly shell = inject(AgUiShell); + // Phase 4: A2UI component catalog forwarded to . + protected readonly catalog = a2uiBasicCatalog(); + + protected send(text: string): void { + void this.agent.submit({ message: text }); + } +} diff --git a/examples/ag-ui/angular/src/app/modes/welcome-suggestions.component.ts b/examples/ag-ui/angular/src/app/modes/welcome-suggestions.component.ts new file mode 100644 index 000000000..9e3da8df8 --- /dev/null +++ b/examples/ag-ui/angular/src/app/modes/welcome-suggestions.component.ts @@ -0,0 +1,117 @@ +// SPDX-License-Identifier: MIT +import { ChangeDetectionStrategy, Component, output } from '@angular/core'; +import { + ChatWelcomeSuggestionComponent, + ChatSelectComponent, + type ChatSelectOption, +} from '@threadplane/chat'; +import { FEATURED_SUGGESTIONS, MORE_SUGGESTIONS } from './welcome-suggestions'; + +/** + * Demo-side composition that renders the welcome-state suggestion surface + * as a single featured chip + a "More prompts" dropdown for everything + * else. The featured chip is `FEATURED_SUGGESTIONS[0]` — consumer + * controls which prompt is featured by ordering the array. + * + * Output `(selected)` fires with the suggestion's `value` for BOTH chip + * clicks and dropdown picks — consumers wire it directly to + * `agent.submit({ message: $event })` for auto-send semantics. + */ +@Component({ + selector: 'welcome-suggestions', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ChatWelcomeSuggestionComponent, ChatSelectComponent], + template: ` +
+ + +
+ `, + styles: [ + ` + :host { + display: flex; + justify-content: center; + width: 100%; + padding: 0 12px; + box-sizing: border-box; + } + .welcome-suggestions__row { + display: flex; + align-items: center; + gap: 12px; + max-width: 100%; + } + .welcome-suggestions__featured { + flex: 1 1 0; + min-width: 0; + max-width: 380px; + overflow: hidden; + } + /* chat-welcome-suggestion host is display: inline-block by default + * (sizes to content). At narrow viewports this lets the inner + * button overflow the wrapper and get clipped at the wrapper's + * right edge ("hard right border"). Force block sizing here so + * the host follows the wrapper's flex-shrunk width and the + * label inside ellipsizes. */ + .welcome-suggestions__featured ::ng-deep chat-welcome-suggestion { + display: block; + width: 100%; + } + .welcome-suggestions__featured ::ng-deep .chat-welcome-suggestion { + width: 100%; + } + .welcome-suggestions__featured ::ng-deep .chat-welcome-suggestion__label { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + min-width: 0; + flex: 1 1 auto; + } + /* Make the "More prompts" dropdown match the featured chip visually. + Scoped to .welcome-suggestions__row so the model picker (also + chat-select, elsewhere) is untouched. */ + .welcome-suggestions__row ::ng-deep chat-select .chat-select__trigger { + height: auto; + padding: 10px 16px; + background: var(--ngaf-chat-surface); + color: var(--ngaf-chat-text); + border: 1px solid var(--ngaf-chat-separator); + border-radius: 9999px; + font-size: var(--ngaf-chat-font-size-sm); + } + .welcome-suggestions__row ::ng-deep chat-select .chat-select__trigger:hover:not(:disabled) { + background: var(--ngaf-chat-surface-alt); + border-color: var(--ngaf-chat-text-muted); + color: var(--ngaf-chat-text); + } + .welcome-suggestions__row ::ng-deep chat-select .chat-select__menu { + min-width: 320px; + max-width: 480px; + width: max-content; + } + .welcome-suggestions__row > chat-select { + flex: 0 0 auto; + } + `, + ], +}) +export class WelcomeSuggestionsComponent { + readonly selected = output(); + protected readonly featuredOne = FEATURED_SUGGESTIONS[0]; + protected readonly moreOptions: readonly ChatSelectOption[] = [ + ...FEATURED_SUGGESTIONS.slice(1), + ...MORE_SUGGESTIONS, + ].map((s) => ({ value: s.value, label: s.label })); +} diff --git a/examples/ag-ui/angular/src/app/modes/welcome-suggestions.ts b/examples/ag-ui/angular/src/app/modes/welcome-suggestions.ts new file mode 100644 index 000000000..2d4930fbe --- /dev/null +++ b/examples/ag-ui/angular/src/app/modes/welcome-suggestions.ts @@ -0,0 +1,39 @@ +// SPDX-License-Identifier: MIT + +/** + * Welcome suggestion prompts shown in each mode's empty state. Kept in + * one file so all three modes ship the same list. + * + * The chips span THIS demo's full capability surface over the AG-UI + * transport — docs/citations, generative UI, human approval, the + * frontend-owned itinerary client tools, and the research subagent + * (list introduced in #655; presented here via the canonical two-tier + * featured + "More prompts" UI). + */ +export interface WelcomeSuggestion { + readonly label: string; + readonly value: string; +} + +export const FEATURED_SUGGESTIONS: readonly WelcomeSuggestion[] = [ + { label: 'Docs & citations', value: 'What do the docs say about streaming?' }, + { label: 'Generative UI', value: 'Build me a revenue dashboard' }, + { label: 'Human approval', value: 'Issue me a $50 refund' }, +]; + +export const MORE_SUGGESTIONS: readonly WelcomeSuggestion[] = [ + { label: 'Read my itinerary', value: "What's on my itinerary?" }, + { label: 'Agent edits the page', value: 'Add the Louvre to day 2 of my trip' }, + { label: 'Consent-gated clear', value: 'Clear my day 2 plans' }, + { label: 'Research subagent', value: 'Research AG-UI and give me the highlights' }, +]; + +/** + * Back-compat: unified array combining featured + more in the original + * order. Kept so existing imports don't break. Prefer FEATURED_SUGGESTIONS + * + MORE_SUGGESTIONS for the two-tier UI. + */ +export const WELCOME_SUGGESTIONS: readonly WelcomeSuggestion[] = [ + ...FEATURED_SUGGESTIONS, + ...MORE_SUGGESTIONS, +]; diff --git a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css new file mode 100644 index 000000000..3e6ae2401 --- /dev/null +++ b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.css @@ -0,0 +1,219 @@ +:host { + display: block; + height: 100dvh; +} + +.ag-ui-shell { + position: relative; + display: flex; + flex-direction: column; + height: 100%; + /* Publish the toolbar height as a CSS var so fixed-position overlays + * (chat-sidenav, chat-sidebar panel) can clear it. */ + --demo-toolbar-height: 51px; +} + +.ag-ui-shell__hamburger { + flex: 0 0 auto; + width: 36px; + height: 36px; + border: 0; + background: transparent; + color: var(--ngaf-chat-text); + border-radius: 8px; + font-size: 18px; + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; +} +.ag-ui-shell__hamburger:hover { + background: var(--ngaf-chat-surface-alt); +} + +.ag-ui-shell__main { + flex: 1; + min-height: 0; + transition: padding-left 200ms ease; + padding-left: 0; + display: flex; + flex-direction: column; + min-width: 0; +} +.ag-ui-shell__main[data-sidenav-mode="expanded"] { + padding-left: var(--ngaf-chat-sidenav-width-expanded, 280px); +} +.ag-ui-shell__main[data-sidenav-mode="collapsed"] { + padding-left: var(--ngaf-chat-sidenav-width-collapsed, 56px); +} +@media (max-width: 767px) { + .ag-ui-shell__main[data-sidenav-mode] { padding-left: 0; } +} + +.ag-ui-shell__toolbar { + flex: 0 0 auto; + min-height: 48px; + display: flex; + align-items: center; + flex-wrap: wrap; + row-gap: 6px; + gap: 10px; + padding: 8px 14px; + border-bottom: 1px solid var(--ngaf-chat-separator); + background: color-mix(in srgb, var(--ngaf-chat-bg) 94%, transparent); + color: var(--ngaf-chat-text); + font-family: inherit; + font-size: var(--ngaf-chat-font-size-sm); + box-sizing: border-box; + /* Toolbar establishes its own stacking context so the chat-select + * dropdown menus (z-index 10 inside the primitive) render above the + * chat content's scroll/transform stacking contexts below. */ + position: relative; + z-index: 50; + /* No overflow-x: that would clip absolutely-positioned dropdown menus. + * flex-wrap handles narrow viewports by wrapping fields to a new row. */ +} + +/* Push every field after Mode to the right of the toolbar. */ +.ag-ui-shell__field--first { + margin-left: auto; +} + +.ag-ui-shell__segmented { + display: inline-flex; + align-items: center; + gap: 2px; + padding: 2px; + border-radius: 8px; + border: 1px solid color-mix(in srgb, var(--ngaf-chat-text) 14%, transparent); + background: color-mix(in srgb, var(--ngaf-chat-text) 4%, transparent); + flex: 0 0 auto; +} + +.ag-ui-shell__segmented-button { + font: inherit; + color: var(--ngaf-chat-text); +} + +.ag-ui-shell__segmented-button { + border: 0; + background: transparent; + border-radius: 6px; + min-height: 28px; + padding: 0 10px; + cursor: pointer; +} + +.ag-ui-shell__segmented-button.is-active { + background: var(--ngaf-chat-text); + color: var(--ngaf-chat-bg); +} + +.ag-ui-shell__field { + display: inline-flex; + align-items: center; + gap: 6px; + color: var(--ngaf-chat-text-muted); + flex: 0 0 auto; +} + +.ag-ui-shell__segmented-button:hover:not(.is-active) { + background: color-mix(in srgb, var(--ngaf-chat-text) 8%, transparent); +} + +/* The toolbar lives at the top of the page; chat-select's default upward + * popover (bottom: calc(100% + 8px)) renders offscreen here. Flip the + * menus to open downward — scoped to the toolbar so the chat-input model + * picker (which IS at the bottom) keeps its upward default. */ +.ag-ui-shell__field ::ng-deep chat-select .chat-select__menu { + top: calc(100% + 8px); + bottom: auto; +} + + +.ag-ui-shell__interrupt-panel { + position: fixed; + left: 50%; + bottom: calc(80px + var(--ag-ui-shell-interrupt-offset, 0px)); + transform: translateX(-50%); + z-index: 999; + width: min(640px, calc(100vw - 32px)); + max-width: min(640px, calc(100vw - 32px)); + box-shadow: 0 6px 24px rgba(0, 0, 0, 0.2); + border-radius: var(--ngaf-chat-radius-card, 10px); +} + +.ag-ui-shell__subagents { + position: fixed; + left: 50%; + bottom: 96px; + transform: translateX(-50%); + z-index: 997; + width: min(640px, calc(100vw - 32px)); + display: flex; + flex-direction: column; + gap: 8px; +} + +.ag-ui-shell__theme-toggle { + width: 28px; + height: 28px; + border-radius: 8px; + border: 0; + background: transparent; + color: var(--ngaf-chat-text-muted); + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +.ag-ui-shell__theme-toggle:hover { + background: var(--ngaf-chat-surface-alt); + color: var(--ngaf-chat-text); +} + +/* The toolbar is full-width at the top of the page. Fixed-position + * chrome (chat-sidenav, chat-sidebar panel) lives at top:0 by default + * — push them down by the toolbar height so they don't render + * underneath. Scoped to .ag-ui-shell so the chat library's own + * positioning is unchanged for consumers without a top toolbar. */ +.ag-ui-shell ::ng-deep chat-sidenav { + top: var(--demo-toolbar-height); + /* chat-sidenav has `height: 100%` on :host plus position: fixed + bottom: 0. + * With our top override the explicit height "wins" and the sidenav extends + * past the viewport (top + 100% > 100vh). Constrain the height to the + * viewport minus the toolbar. */ + height: calc(100% - var(--demo-toolbar-height)); +} +/* chat-sidebar panel renders top-aligned with the page, NOT under the + * toolbar — so the panel's close button sits at the same viewport-y as + * the hamburger inside the toolbar (both at surface-top + 8 padding). + * The panel's z-index is below the toolbar's so the toolbar still + * renders above it where they overlap on the right edge. */ +.ag-ui-shell ::ng-deep .chat-sidebar__panel { + top: 0; +} +.ag-ui-shell__theme-toggle--toolbar { margin-left: auto; } + +/* Two-column body: frontend-owned itinerary panel beside the routed chat + * (ported from the pre-shell layout that #655 introduced). */ +.ag-ui-shell__body { flex: 1 1 auto; min-height: 0; display: flex; } +.ag-ui-shell__itinerary { + flex: 0 0 300px; + min-height: 0; + overflow-y: auto; + border-right: 1px solid var(--tp-border, #e5e7eb); +} +.ag-ui-shell__body > .ag-ui-shell__main { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; } + +/* Stack to a single column on narrow viewports; the panel goes full-width + * above the chat. */ +@media (max-width: 900px) { + .ag-ui-shell__body { flex-direction: column; } + .ag-ui-shell__itinerary { + flex: 0 0 auto; + max-height: 40dvh; + border-right: none; + border-bottom: 1px solid var(--tp-border, #e5e7eb); + } +} diff --git a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html new file mode 100644 index 000000000..f6c0511cc --- /dev/null +++ b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.html @@ -0,0 +1,53 @@ +
+ + +
+ +
+ + @if (agent.interrupt && agent.interrupt()) { +
+ +
+ } +
+
+
diff --git a/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts new file mode 100644 index 000000000..165b96b8a --- /dev/null +++ b/examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts @@ -0,0 +1,202 @@ +// SPDX-License-Identifier: MIT +import { + Component, + ChangeDetectionStrategy, + DOCUMENT, + effect, + inject, + signal, +} from '@angular/core'; +import { Router, RouterOutlet } from '@angular/router'; +import { injectAgent } from '@threadplane/ag-ui'; +import { + ChatInterruptPanelComponent, + ChatSelectComponent, + type InterruptAction, +} from '@threadplane/chat'; +import { PalettePersistence } from './palette-persistence.service'; +import { ItineraryPanelComponent } from '../itinerary-panel.component'; +import { itineraryClientTools } from '../client-tools'; + +export type DemoMode = 'embed' | 'popup' | 'sidebar'; +const MODES: readonly DemoMode[] = ['embed', 'popup', 'sidebar'] as const; + +/** Default knob values — omitted from the URL when active. */ +const DEFAULTS = { + model: 'gpt-5-mini', + effort: 'minimal', + genui: 'a2ui', + theme: 'default-dark', + scheme: 'dark', +} as const; + +@Component({ + selector: 'ag-ui-shell', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [RouterOutlet, ChatSelectComponent, ChatInterruptPanelComponent, ItineraryPanelComponent], + templateUrl: './ag-ui-shell.component.html', + styleUrl: './ag-ui-shell.component.css', + providers: [PalettePersistence], +}) +export class AgUiShell { + private readonly router = inject(Router); + private readonly document = inject(DOCUMENT); + protected readonly persistence = inject(PalettePersistence); + + // ── Knob signals: URL > localStorage > default ─────────────────────────── + private urlKnob(name: string): string | null { + // AgUiShell is not a routed component so ActivatedRoute.snapshot may not + // carry query params at initialization time. Read from window.location.search + // (the real browser URL) which is available immediately at bootstrap. + const win = this.document.defaultView; + const search = win?.location.search ?? ''; + const v = new URLSearchParams(search).get(name); + return v && v.length > 0 ? v : null; + } + + readonly model = signal(this.urlKnob('model') ?? this.persistence.read('model') ?? DEFAULTS.model); + readonly effort = signal(this.urlKnob('effort') ?? this.persistence.read('effort') ?? DEFAULTS.effort); + readonly genUiMode = signal(this.urlKnob('genui') ?? this.persistence.read('genUiMode') ?? DEFAULTS.genui); + readonly theme = signal(this.urlKnob('theme') ?? this.persistence.read('theme') ?? DEFAULTS.theme); + readonly colorScheme = signal<'light' | 'dark'>( + ((this.urlKnob('scheme') ?? this.persistence.read('colorScheme')) as 'light' | 'dark' | null) ?? DEFAULTS.scheme, + ); + + // ── Mode from the active route ─────────────────────────────────────────── + readonly mode = signal(this.parseMode(this.router.url)); + protected readonly modeOptions: readonly { value: DemoMode; label: string }[] = [ + { value: 'embed', label: 'Embed' }, + { value: 'popup', label: 'Popup' }, + { value: 'sidebar', label: 'Sidebar' }, + ]; + + private parseMode(url: string): DemoMode { + const seg = url.split('?')[0].split('/').filter(Boolean)[0]; + return (MODES as readonly string[]).includes(seg) ? (seg as DemoMode) : 'embed'; + } + + // ── Select options (canonical lists) ───────────────────────────────────── + readonly modelOptions = signal([ + { value: 'gpt-5-mini', label: 'gpt-5-mini' }, + { value: 'gpt-5-nano', label: 'gpt-5-nano' }, + ]); + protected readonly effortOptions = signal([ + { value: 'minimal', label: 'minimal (fast)' }, + { value: 'low', label: 'low' }, + { value: 'medium', label: 'medium' }, + { value: 'high', label: 'high (visible reasoning)' }, + ]); + protected readonly genUiOptions = signal([ + { value: 'a2ui', label: 'A2UI' }, + { value: 'json-render', label: 'json-render' }, + ]); + protected readonly themeOptions = signal([ + { value: 'default-dark', label: 'Default dark' }, + { value: 'default-light', label: 'Default light' }, + { value: 'material-dark', label: 'Material dark' }, + { value: 'material-light', label: 'Material light' }, + ]); + + // Frontend-declared client tools (itinerary get/add/move/clear + day_card). + // Built in an injection context (field initializer) so itineraryClientTools() + // can inject the shared ItineraryStore. Modes bind this to . + readonly clientTools = itineraryClientTools(); + + // ── Shared agent: submit wrapper merges the knobs into input.state ────── + readonly agent = (() => { + const a = injectAgent(); + const orig = a.submit.bind(a); + (a as { submit: typeof a.submit }).submit = (async ( + input: Parameters[0], + opts?: Parameters[1], + ) => { + return orig( + { + ...(input ?? {}), + state: { + ...((input as { state?: Record })?.state ?? {}), + model: this.model(), + reasoning_effort: this.effort(), + gen_ui_mode: this.genUiMode(), + }, + }, + opts, + ); + }) as typeof a.submit; + return a; + })(); + + constructor() { + // Routed mode components read the shared wrapped agent via + // `inject(AgUiShell).agent` — no token needed. + // Keep mode() in sync with navigation. + this.router.events.subscribe(() => { + const m = this.parseMode(this.router.url); + if (m !== this.mode()) this.mode.set(m); + }); + + // Reflect theme + scheme onto exactly like the canonical shell. + effect(() => { + const html = this.document.documentElement; + html.setAttribute('data-theme', this.theme()); + const scheme = this.colorScheme(); + html.setAttribute('data-threadplane-chat-theme', scheme); + const t = this.theme(); + if (t === 'default-dark' || t === 'default-light') { + const next = scheme === 'light' ? 'default-light' : 'default-dark'; + if (next !== t) this.theme.set(next); + } + }); + + // Persist + sync knobs to the URL (defaults omitted). + effect(() => { + const q: Record = { + model: this.model() === DEFAULTS.model ? null : this.model(), + effort: this.effort() === DEFAULTS.effort ? null : this.effort(), + genui: this.genUiMode() === DEFAULTS.genui ? null : this.genUiMode(), + theme: this.theme() === DEFAULTS.theme ? null : this.theme(), + scheme: this.colorScheme() === DEFAULTS.scheme ? null : this.colorScheme(), + }; + void this.router.navigate([], { queryParams: q, queryParamsHandling: 'merge', replaceUrl: true }); + }); + } + + protected onModeChange(next: DemoMode | string): void { + if (!(MODES as readonly string[]).includes(next as string)) return; + void this.router.navigate(['/', next], { queryParamsHandling: 'preserve' }); + } + onModelChange(v: string): void { this.model.set(v); this.persistence.write('model', v); } + protected onEffortChange(v: string): void { this.effort.set(v); this.persistence.write('effort', v); } + protected onGenUiModeChange(v: string): void { this.genUiMode.set(v); this.persistence.write('genUiMode', v); } + protected onThemeChange(v: string): void { this.theme.set(v); this.persistence.write('theme', v); } + protected onColorSchemeChange(v: 'light' | 'dark' | string): void { + if (v !== 'light' && v !== 'dark') return; + this.colorScheme.set(v); + this.persistence.write('colorScheme', v); + } + + /** Same four-action vocabulary as the canonical shell; resumes via + * AG-UI's submit({ resume }) path (forwardedProps.command.resume). */ + protected async onInterruptAction(action: InterruptAction): Promise { + const interrupt = this.agent.interrupt?.(); + if (!interrupt) return; + let resume: unknown; + switch (action) { + case 'accept': resume = 'approved'; break; + case 'edit': { + const reason = (interrupt.value as { reason?: string })?.reason ?? ''; + const edited = window.prompt(`Edit your response (current proposal: "${reason}"):`, 'approved'); + if (edited == null) return; + resume = edited; break; + } + case 'respond': { + const text = window.prompt('Respond to the agent:', ''); + if (text == null) return; + resume = text; break; + } + case 'ignore': resume = 'denied'; break; + } + await this.agent.submit({ resume }); + } +} diff --git a/examples/ag-ui/angular/src/app/shell/palette-persistence.service.ts b/examples/ag-ui/angular/src/app/shell/palette-persistence.service.ts new file mode 100644 index 000000000..d84f10070 --- /dev/null +++ b/examples/ag-ui/angular/src/app/shell/palette-persistence.service.ts @@ -0,0 +1,54 @@ +// SPDX-License-Identifier: MIT +import { Injectable } from '@angular/core'; + +const KEY = 'threadplane-ag-ui-demo:palette'; + +interface PaletteState { + model: string; + effort: string; + genUiMode: string; + theme: string; + colorScheme: string; +} + +type PaletteKey = keyof PaletteState; + +/** + * Tiny localStorage-backed persistence for control-palette state. Single + * JSON object under `threadplane-ag-ui-demo:palette` so reads/writes are + * atomic-per-key. Survives malformed JSON by returning `null` and + * silently overwriting on next write. + */ +@Injectable({ providedIn: 'root' }) +export class PalettePersistence { + read(key: K): PaletteState[K] | null { + const raw = this.load(); + return (raw[key] as PaletteState[K] | undefined) ?? null; + } + + write(key: K, value: PaletteState[K] | null): void { + const current = this.load(); + if (value === null || value === undefined) { + delete current[key]; + } else { + current[key] = value; + } + try { + localStorage.setItem(KEY, JSON.stringify(current)); + } catch { + // Storage may be full or unavailable (private mode). Silently drop; + // the demo continues to work, just without persistence. + } + } + + private load(): PaletteState { + try { + const raw = localStorage.getItem(KEY); + if (!raw) return {} as PaletteState; + const parsed = JSON.parse(raw); + return typeof parsed === 'object' && parsed !== null ? (parsed as PaletteState) : {} as PaletteState; + } catch { + return {} as PaletteState; + } + } +} diff --git a/examples/ag-ui/angular/src/styles.css b/examples/ag-ui/angular/src/styles.css index d0b29ceb8..9b2325945 100644 --- a/examples/ag-ui/angular/src/styles.css +++ b/examples/ag-ui/angular/src/styles.css @@ -96,28 +96,4 @@ html[data-theme='material-light'] { .ag-ui-demo { display: flex; flex-direction: column; height: 100dvh; } .ag-ui-demo__header { padding: 12px 16px; border-bottom: 1px solid var(--tp-border, #e5e7eb); } .ag-ui-demo__header h1 { margin: 0; font-size: 1.1rem; } -.ag-ui-demo__header p { margin: 2px 0 0; font-size: 0.85rem; opacity: 0.7; } -.ag-ui-demo__interrupt { padding: 12px 16px; border-bottom: 1px solid var(--tp-border, #e5e7eb); } -.ag-ui-demo__chat { flex: 1 1 auto; min-height: 0; } - -/* Two-column body: itinerary panel beside the chat. */ -.ag-ui-demo__body { flex: 1 1 auto; min-height: 0; display: flex; } -.ag-ui-demo__panel { - flex: 0 0 300px; - min-height: 0; - overflow-y: auto; - border-right: 1px solid var(--tp-border, #e5e7eb); -} -.ag-ui-demo__main { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; } - -/* Stack to a single column on narrow viewports; the panel goes full-width - * above the chat. */ -@media (max-width: 900px) { - .ag-ui-demo__body { flex-direction: column; } - .ag-ui-demo__panel { - flex: 0 0 auto; - max-height: 40dvh; - border-right: none; - border-bottom: 1px solid var(--tp-border, #e5e7eb); - } -} +.ag-ui-demo__header p { margin: 2px 0 0; font-size: 0.85rem; opacity: 0.7; }.ag-ui-demo__chat { flex: 1 1 auto; min-height: 0; } diff --git a/libs/ag-ui/src/lib/to-agent.spec.ts b/libs/ag-ui/src/lib/to-agent.spec.ts index 8f5223ba3..eb748a30d 100644 --- a/libs/ag-ui/src/lib/to-agent.spec.ts +++ b/libs/ag-ui/src/lib/to-agent.spec.ts @@ -23,6 +23,9 @@ class StubAgent { // We override runAgent to emit events through our subscriber pattern. private readonly _events = new Subject(); + // Simulate AbstractAgent.state (typed as any in the base class). + state: Record = {}; + // Simulate subscriber list just like AbstractAgent does private readonly _subscribers: Array<{ onEvent?: (p: { event: BaseEvent }) => void; onRunFailed?: (p: { error: Error }) => void }> = []; @@ -233,6 +236,41 @@ describe('toAgent', () => { expect(calls[calls.length - 1][0]).toBeUndefined(); }); + describe('input.state forwarding', () => { + it('merges input.state into the source agent state before running', async () => { + const stub = new StubAgent(); + stub.state = { existing: 'value' }; + const a = toAgent(stub as unknown as AbstractAgent); + await a.submit({ message: 'hi', state: { gen_ui_mode: 'json-render' } }); + expect(stub.state).toMatchObject({ existing: 'value', gen_ui_mode: 'json-render' }); + }); + + it('reflects the patch in the local state() signal optimistically', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + await a.submit({ message: 'hi', state: { model: 'gpt-5-nano' } }); + expect(a.state()['model']).toBe('gpt-5-nano'); + }); + + it('forwards state on the resume path too', async () => { + const stub = new StubAgent(); + const a = toAgent(stub as unknown as AbstractAgent); + // Arrange an active interrupt (mirrors existing interrupt tests) + stub.emit({ type: 'CUSTOM', name: 'on_interrupt', value: { kind: 'approval' } } as unknown as BaseEvent); + expect(a.interrupt!()).toBeDefined(); + await a.submit({ resume: 'approved', state: { reasoning_effort: 'high' } }); + expect(stub.state).toMatchObject({ reasoning_effort: 'high' }); + }); + + it('leaves source state untouched when input.state is absent', async () => { + const stub = new StubAgent(); + stub.state = { preserved: true }; + const a = toAgent(stub as unknown as AbstractAgent); + await a.submit({ message: 'hi' }); + expect(stub.state).toEqual({ preserved: true }); + }); + }); + describe('regenerate()', () => { it('truncates messages inclusive of user (userIdx+1) and re-runs without re-appending', async () => { const stub = new StubAgent(); diff --git a/libs/ag-ui/src/lib/to-agent.ts b/libs/ag-ui/src/lib/to-agent.ts index cf54c01b5..c0392721e 100644 --- a/libs/ag-ui/src/lib/to-agent.ts +++ b/libs/ag-ui/src/lib/to-agent.ts @@ -90,6 +90,17 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag // thread the catalog into every runAgent() call. const clientToolsCap = createClientToolsCapability(source, store); + /** Forward a neutral-contract state patch onto the AG-UI run input. + * Mirrors the canonical demo's `input.state` mechanism: the patch is + * merged into the source agent's client state (carried on + * RunAgentInput.state) and reflected optimistically in the local + * state signal — the server's next STATE_SNAPSHOT stays authoritative. */ + const applyStatePatch = (patch: Record | undefined): void => { + if (!patch || Object.keys(patch).length === 0) return; + source.state = { ...((source.state as Record) ?? {}), ...patch }; + store.state.update((prev) => ({ ...prev, ...patch })); + }; + captureAgentRuntimeTelemetry( options.telemetry, 'ngaf:runtime_instance_created', @@ -158,6 +169,7 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag // Resume path: clear the pending interrupt and replay the run with the // resume payload forwarded to the LangGraph backend via AG-UI's // forwardedProps.command.resume mechanism. + applyStatePatch(input.state); store.interrupt.set(undefined); const run = startRunTelemetry('resume'); const tools = clientToolsCap.catalogAsAgUiTools(); @@ -176,6 +188,8 @@ export function toAgent(source: AbstractAgent, options: ToAgentOptions = {}): Ag return; } + applyStatePatch(input.state); + // Optimistic append of user message to our signals and to the source // agent's own message list so runAgent() sees the new message. const userMsg = buildUserMessage(input); diff --git a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts index 00f5fd16d..5737d46b4 100644 --- a/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts +++ b/libs/chat/src/lib/compositions/chat-popup/chat-popup.component.ts @@ -3,6 +3,7 @@ import { Component, ChangeDetectionStrategy, input, model, DestroyRef, inject, DOCUMENT, effect } from '@angular/core'; import type { Agent } from '../../agent'; import type { ViewRegistry } from '@threadplane/render'; +import type { ClientToolRegistry } from '../../client-tools/tool-def'; import { ChatComponent } from '../chat/chat.component'; import type { ChatSelectOption } from '../../primitives/chat-select/chat-select.component'; import { ChatLauncherButtonComponent } from '../../primitives/chat-launcher-button/chat-launcher-button.component'; @@ -68,6 +69,7 @@ import { CHAT_HOST_TOKENS, ensureChatRootStyles } from '../../styles/chat-tokens (undefined); + /** Frontend-declared client tools forwarded to the inner ``. */ + readonly clientTools = input(undefined); /** Forwarded to the inner . When non-empty, a model picker pill * renders in the chat-input chrome. */ readonly modelOptions = input([]); diff --git a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts index ecc371860..a89f72700 100644 --- a/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts +++ b/libs/chat/src/lib/compositions/chat-sidebar/chat-sidebar.component.ts @@ -11,6 +11,7 @@ import { } from '@angular/core'; import type { Agent } from '../../agent'; import type { ViewRegistry } from '@threadplane/render'; +import type { ClientToolRegistry } from '../../client-tools/tool-def'; import { ChatComponent } from '../chat/chat.component'; import { ChatLauncherButtonComponent } from '../../primitives/chat-launcher-button/chat-launcher-button.component'; import type { ChatSelectOption } from '../../primitives/chat-select/chat-select.component'; @@ -109,6 +110,7 @@ import { CHAT_HOST_TOKENS, ensureChatRootStyles } from '../../styles/chat-tokens (undefined); + /** Frontend-declared client tools forwarded to the inner ``. */ + readonly clientTools = input(undefined); /** Forwarded to the inner . When non-empty, a model picker pill * renders in the chat-input chrome. */ readonly modelOptions = input([]);