diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 5c52d48ff..b8391a0aa 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -2153,6 +2153,12 @@ "description": "", "optional": false }, + { + "name": "normalizedSpec", + "type": "Signal", + "description": "The bound spec with schema-documented `{ statePath }` prop refs\nrewritten to engine-native `{ $bindState }` + `_bindings` so values\nresolve against the state store instead of interpolating as\n\"[object Object]\" (F4).", + "optional": false + }, { "name": "registry", "type": "InputSignal", @@ -2393,6 +2399,19 @@ "description": "", "params": [] }, + { + "name": "onInput", + "signature": "onInput(event: Event): void", + "description": "Sync the textarea's value into the signal on user input. A direct\n [value]/(input) pair is used instead of ngModel: NgModel does not\n reliably write a programmatic clear back to the view under zoneless\n + OnPush, leaving sent text visible in the composer (audit F1).", + "params": [ + { + "name": "event", + "type": "Event", + "description": "", + "optional": false + } + ] + }, { "name": "onKeydown", "signature": "onKeydown(event: KeyboardEvent): void", @@ -2782,6 +2801,12 @@ "description": "", "optional": false }, + { + "name": "clientTools", + "type": "InputSignal> | undefined>", + "description": "Frontend-declared client tools forwarded to the inner ``.", + "optional": false + }, { "name": "closeOnEscape", "type": "InputSignal", @@ -3277,6 +3302,12 @@ "description": "", "optional": false }, + { + "name": "clientTools", + "type": "InputSignal> | undefined>", + "description": "Frontend-declared client tools forwarded to the inner ``.", + "optional": false + }, { "name": "closeOnEscape", "type": "InputSignal", @@ -4702,7 +4733,7 @@ { "name": "MarkdownChildrenComponent", "kind": "class", - "description": "Recursively dispatches a parent node's children through the markdown view\nregistry. Each child's `type` is looked up in the registry; the resolved\ncomponent is rendered with `[node]` bound to that child.\n\nIdentity-preserving: `track $any(child)` keys on the JS reference of the\nchild node. Because @cacheplane/partial-markdown preserves node identity\nacross pushes, unchanged subtrees never re-render.", + "description": "Recursively dispatches a parent node's children through the markdown view\nregistry. Each child's `type` is looked up in the registry; the resolved\ncomponent is rendered with `[node]` bound to that child.\n\nPosition-stable: `track $index` avoids NG0956 re-creation warnings that\noccur when the markdown pipeline re-parses content on every stream delta,\nproducing new child object references even for unchanged nodes.", "params": [], "examples": [], "properties": [ diff --git a/cockpit/ag-ui/json-render/angular/src/app/json-render.component.ts b/cockpit/ag-ui/json-render/angular/src/app/json-render.component.ts index df95311e2..cf5a669a3 100644 --- a/cockpit/ag-ui/json-render/angular/src/app/json-render.component.ts +++ b/cockpit/ag-ui/json-render/angular/src/app/json-render.component.ts @@ -2,6 +2,7 @@ import { Component } from '@angular/core'; import { ChatComponent, ChatWelcomeSuggestionComponent, views } from '@threadplane/chat'; import { injectAgent } from '@threadplane/ag-ui'; +import { signalStateStore } from '@threadplane/render'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { StatCardComponent } from './views/stat-card.component'; import { ContainerComponent } from './views/container.component'; @@ -30,7 +31,7 @@ const WELCOME_SUGGESTIONS = [ imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], template: ` - +
@for (s of suggestions; track s.value) { @@ -44,5 +45,10 @@ export class JsonRenderComponent { protected readonly agent = injectAgent(); protected readonly dashboardViews = dashboardViews; protected readonly suggestions = WELCOME_SUGGESTIONS; + /** + * Explicit shared store: backend state (STATE_SNAPSHOT) syncs into it via + * the chat composition, so every dashboard surface reads live values. + */ + protected readonly dashStore = signalStateStore({}); protected send(text: string): void { void this.agent.submit({ message: text }); } } diff --git a/cockpit/ag-ui/json-render/python/docs/guide.md b/cockpit/ag-ui/json-render/python/docs/guide.md index e320fc49f..cdc4d89f7 100644 --- a/cockpit/ag-ui/json-render/python/docs/guide.md +++ b/cockpit/ag-ui/json-render/python/docs/guide.md @@ -10,18 +10,19 @@ as the backend updates the data. -Render a backend-authored dashboard with `@threadplane/chat` over the AG-UI adapter. Register your view components in the `views` map and pass it to ``. Have the agent emit a json-render spec (with `$state` bindings) as the assistant message content, and put the data the spec binds to in the LangGraph graph state so `ag-ui-langgraph` emits it as a `STATE_SNAPSHOT`. The chat composition resolves the bindings automatically. +Render a backend-authored dashboard with `@threadplane/chat` over the AG-UI adapter. Register your view components in the `views` map and pass it to ``, along with an explicit `[store]` — the composition syncs incoming agent state into that store, and the spec's `$state` bindings resolve against it. Have the agent emit a json-render spec (with `$state` bindings) as the assistant message content, and put the data the spec binds to in the LangGraph graph state so `ag-ui-langgraph` emits it as a `STATE_SNAPSHOT`. -Build a `views` registry keyed by the component types your spec will reference, and pass it to ``: +Build a `views` registry keyed by the component types your spec will reference, and pass it to `` together with an explicit store. Without a `[store]`, each render surface seeds its own isolated store from the spec — the explicit store is what lets backend state (`STATE_SNAPSHOT`) reach the dashboard bindings: ```typescript // json-render.component.ts import { ChatComponent, views } from '@threadplane/chat'; import { injectAgent } from '@threadplane/ag-ui'; +import { signalStateStore } from '@threadplane/render'; import { StatCardComponent } from './views/stat-card.component'; import { DashboardGridComponent } from './views/dashboard-grid.component'; // …line-chart, bar-chart, data-grid, container @@ -31,10 +32,13 @@ const dashboardViews = views({ dashboard_grid: DashboardGridComponent, // … }); + +// In the component class: +readonly dashStore = signalStateStore({}); ``` ```html - + ``` @@ -76,8 +80,8 @@ data prop uses a `$state` binding rather than a literal: This is the AG-UI-native part. Instead of pushing data through a side channel, put it in the **graph state** — `ag-ui-langgraph` emits the state object as a `STATE_SNAPSHOT`, the adapter writes it to the agent's `state` signal, and the -chat composition syncs it into the render store where the `$state` bindings -resolve: +chat composition syncs it into the explicit `[store]` you passed, where the +`$state` bindings resolve: ```python # graph.py — emit_state returns the accumulated tool data into state diff --git a/cockpit/ag-ui/json-render/python/src/graph.py b/cockpit/ag-ui/json-render/python/src/graph.py index 2f19c184a..8c3c453e2 100644 --- a/cockpit/ag-ui/json-render/python/src/graph.py +++ b/cockpit/ag-ui/json-render/python/src/graph.py @@ -17,8 +17,9 @@ emit_state walks the message history for this turn and returns the tool results as top-level state fields — ag-ui-langgraph emits them as -STATE_SNAPSHOT; the Angular chat-lib effect syncs them into the render -store, where the spec's $state bindings resolve them. +STATE_SNAPSHOT; the Angular chat-lib effect syncs them into the explicit +[store] the app passes to , where the spec's $state bindings +resolve them. """ import json diff --git a/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts b/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts index d627999d6..248592bb6 100644 --- a/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts +++ b/cockpit/chat/generative-ui/angular/src/app/generative-ui.component.ts @@ -2,6 +2,7 @@ import { Component } from '@angular/core'; import { ChatComponent, ChatWelcomeSuggestionComponent, views } from '@threadplane/chat'; import { injectAgent } from '@threadplane/langgraph'; +import { signalStateStore } from '@threadplane/render'; import { ExampleChatLayoutComponent } from '@threadplane/example-layouts'; import { StatCardComponent } from './views/stat-card.component'; @@ -31,7 +32,7 @@ const WELCOME_SUGGESTIONS = [ imports: [ChatComponent, ChatWelcomeSuggestionComponent, ExampleChatLayoutComponent], template: ` - +
@for (s of suggestions; track s.value) { **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:** Close audit gaps F1, F2, F3, F4, F6 from `docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md` (F5 — subagent card — is deferred to Phase 4 design). + +**Architecture:** Three small library fixes (`@threadplane/chat` chat-input binding, `@threadplane/ag-ui` abort handling, chat markdown track expressions), one example-level fix (`data-color-scheme` parity with the canonical shell), and one bounded investigate-then-fix (json-render object values, shared with the canonical demo). Each fix lands TDD-first where a test can express it. + +**Tech Stack:** Angular 21 signals (zoneless, OnPush), vitest + TestBed for lib units, Playwright + aimock for e2e, nx for gates. + +**Branch:** `ag-ui-gap-closure-p3` off `origin/main` (the worktree's current branch holds the merged findings PR #660 — do not stack on it). + +**Context for the engineer:** +- The audit evidence and root-cause notes live in `docs/superpowers/specs/2026-06-11-ag-ui-capability-findings.md`. Read it first. +- F1 was probed live: after Enter-submit, `chat-input`'s `messageText()` signal is `""` but the DOM textarea still shows the text. The `[ngModel]`/`(ngModelChange)` pair fails to write the programmatic clear back to the view (zoneless + OnPush). Reproduces on production `demo.threadplane.ai` mid-thread. +- F2 root cause: `examples/ag-ui/angular/src/styles.css` keys the page scheme off `html[data-color-scheme]`, but `AgUiShell` only sets `data-theme` + `data-threadplane-chat-theme`. The canonical shell sets `data-color-scheme` in both its runtime effect (`examples/chat/angular/src/app/shell/demo-shell.component.ts:133`) and an index.html pre-bootstrap script. The ag-ui example has neither. +- F3 root cause: `stop()` calls `source.abortRun()`; the AG-UI HttpAgent then invokes `onRunFailed({error: "BodyStreamBuffer was aborted"})`, and the adapter's handler unconditionally sets `status: 'error'`. +- F6: identity-tracked `@for` in `libs/chat/src/lib/markdown/markdown-children.component.ts:31` (`track $any(child)`) and `libs/chat/src/lib/markdown/views/markdown-table.component.ts:20` (`track $any(row)`). Markdown re-parses every stream delta, producing new child objects each time → NG0956 + full DOM re-creation per chunk. + +--- + +### Task 0: Branch setup + +- [ ] **Step 1: Create the phase branch off latest main** + +```bash +cd /Users/blove/repos/angular-agent-framework/.claude/worktrees/ag-ui-demo-toolbar +git fetch origin main +git checkout -b ag-ui-gap-closure-p3 origin/main +``` + +Expected: `Switched to a new branch 'ag-ui-gap-closure-p3'`. + +--- + +### Task 1: F2 — `data-color-scheme` parity (example-level) + +**Files:** +- Modify: `examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts` (theme/scheme effect, ~line 140) +- Modify: `examples/ag-ui/angular/src/index.html` (add pre-bootstrap script) +- Modify: `examples/ag-ui/angular/e2e/toolbar.spec.ts` (regression assertion) + +- [ ] **Step 1: Write the failing e2e assertion** + +Append to `examples/ag-ui/angular/e2e/toolbar.spec.ts`: + +```ts +test('light scheme flips the page background (data-color-scheme parity)', async ({ page }) => { + await openDemo(page, '/embed?scheme=light&theme=default-light'); + await expect(page.locator('html')).toHaveAttribute('data-color-scheme', 'light'); + // The page background var must resolve to the light value, not the dark default. + const bg = await page.evaluate(() => + getComputedStyle(document.documentElement).getPropertyValue('--demo-page-bg').trim(), + ); + expect(bg).toBe('#ffffff'); +}); +``` + +- [ ] **Step 2: Run it to make sure it fails** + +Run (aimock harness — see `examples/ag-ui/angular/e2e/README.md`; kill any orphaned uvicorn on :8000 / serve first, `NX_DAEMON=false`): + +```bash +NX_DAEMON=false npx nx e2e examples-ag-ui-angular-e2e --grep "data-color-scheme parity" +``` + +Expected: FAIL — `data-color-scheme` attribute missing. + +- [ ] **Step 3: Set the attribute in the shell effect** + +In `examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts`, the constructor effect currently reads: + +```ts + // 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); +``` + +Add the page-scheme attribute alongside the chat one: + +```ts + // 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); + html.setAttribute('data-color-scheme', scheme); +``` + +- [ ] **Step 4: Add the pre-bootstrap script to index.html** + +Replace the `` of `examples/ag-ui/angular/src/index.html` with (mirrors the canonical script; note the ag-ui persistence key): + +```html + + + AG-UI Chat — Threadplane Example + + + + + +``` + +- [ ] **Step 5: Run the e2e spec to verify it passes** + +```bash +NX_DAEMON=false npx nx e2e examples-ag-ui-angular-e2e --grep "data-color-scheme parity" +``` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add examples/ag-ui/angular/src/app/shell/ag-ui-shell.component.ts examples/ag-ui/angular/src/index.html examples/ag-ui/angular/e2e/toolbar.spec.ts +git commit -m "fix(examples/ag-ui): set data-color-scheme so light mode reaches the page chrome (F2)" +``` + +--- + +### Task 2: F1 — chat-input clears the textarea after submit (`@threadplane/chat`) + +**Files:** +- Modify: `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts` +- Test: `libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts` +- Modify: `examples/ag-ui/angular/e2e/send-receive.spec.ts` (integration regression) + +- [ ] **Step 1: Write the failing unit test** + +Append to the component-level describe blocks in `chat-input.component.spec.ts` (the file already imports `TestBed`, `ChatInputComponent`, and `mockAgent`; follow the file's existing component-test setup — use `fixture.componentRef.setInput('agent', agent)`): + +```ts +describe('ChatInputComponent — clears view on submit (F1 regression)', () => { + it('empties both the signal and the textarea DOM value after Enter submit', async () => { + const agent = mockAgent(); + TestBed.configureTestingModule({ imports: [ChatInputComponent] }); + const fixture = TestBed.createComponent(ChatInputComponent); + fixture.componentRef.setInput('agent', agent); + fixture.detectChanges(); + + const textarea: HTMLTextAreaElement = + fixture.nativeElement.querySelector('textarea'); + // Simulate real typing: set the DOM value and fire the input event. + textarea.value = 'hello world'; + textarea.dispatchEvent(new Event('input', { bubbles: true })); + fixture.detectChanges(); + expect(fixture.componentInstance.messageText()).toBe('hello world'); + + // Enter-key submit (the (keydown.enter) path). + textarea.dispatchEvent( + new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }), + ); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + expect(agent.submitCalls).toHaveLength(1); + expect(fixture.componentInstance.messageText()).toBe(''); + // The DOM must reflect the clear — this is the bug: ngModel never + // writes the programmatic '' back to the view. + expect(textarea.value).toBe(''); + }); +}); +``` + +- [ ] **Step 2: Run it to verify it fails on the DOM assertion** + +```bash +npx nx test chat -- --run --testNamePattern "F1 regression" +``` + +Expected: FAIL on `expect(textarea.value).toBe('')` (received `'hello world'`). + +- [ ] **Step 3: Replace the ngModel pair with direct value/input bindings** + +In `libs/chat/src/lib/primitives/chat-input/chat-input.component.ts`: + +a) Remove `import { FormsModule } from '@angular/forms';` and remove `FormsModule` from the component's `imports: [...]` array (it exists solely for this textarea). + +b) In the template, replace: + +```html + @@ -149,10 +148,20 @@ export class ChatInputComponent { if (submitted !== null) { this.submitted.emit(submitted); this.messageText.set(''); + const el = this.textareaEl()?.nativeElement; + if (el) el.value = ''; requestAnimationFrame(() => this.textareaEl()?.nativeElement.focus()); } } + /** Sync the textarea's value into the signal on user input. A direct + * [value]/(input) pair is used instead of ngModel: NgModel does not + * reliably write a programmatic clear back to the view under zoneless + * + OnPush, leaving sent text visible in the composer (audit F1). */ + protected onInput(event: Event): void { + this.messageText.set((event.target as HTMLTextAreaElement).value); + } + /** Abort the current streaming response (if the adapter supports it). */ onStop(): void { const a = this.agent() as unknown as { stop?: () => void | Promise };