diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index 2ef4951e6..aa5af03ea 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -1151,7 +1151,7 @@ { "name": "spec", "type": "Signal", - "description": "Convert the A2UI surface to a json-render Spec for rendering.\n Prefers `state().surface` (the progressively-built wire surface)\n over the legacy `surface` input. surfaceToSpec handles\n children-id-list → spec.children translation + reserved-key\n filtering + path-ref → $bindState rewriting; the rendered tree\n then uses render-element's standard input-mapping\n (`childKeys: el.children`) so catalog components receive the\n inputs they actually declare.\n\n This supersedes the earlier slot-based progressive renderer,\n which mounted root components but never populated their\n childKeys input — leaving Columns/Rows/etc. with no children.", + "description": "Convert the A2UI surface to a json-render Spec for rendering.\n Prefers `state().surface` (the progressively-built wire surface)\n over the legacy `surface` input. surfaceToSpec handles\n children-id-list → spec.children translation + reserved-key\n filtering + path-ref → $bindState rewriting; the rendered tree\n then uses render-element's standard input-mapping\n (`childKeys: el.children`) so catalog components receive the\n inputs they actually declare.\n\n An earlier slot-based progressive renderer mounted root components\n but never populated their childKeys input, leaving Columns/Rows/etc.\n with no children; the spec conversion replaced it.", "optional": false }, { @@ -9211,7 +9211,7 @@ "description": "" }, "examples": [ - "```ts\nconst interrupt = getInterrupt(agent);\nif (interrupt) agent.resume('approved');\n```" + "```ts\nconst interrupt = getInterrupt(agent);\nif (interrupt) agent.submit({ resume: 'approved' });\n```" ] }, { diff --git a/apps/website/content/docs/chat/concepts/message-model.mdx b/apps/website/content/docs/chat/concepts/message-model.mdx index ea0aee46a..4edf14e5e 100644 --- a/apps/website/content/docs/chat/concepts/message-model.mdx +++ b/apps/website/content/docs/chat/concepts/message-model.mdx @@ -56,12 +56,12 @@ The assistant template is the only one that reads `delivery`. `streaming` on `` calls `agent.submit()` itself and then emits `submitted` with the same trimmed text, which is what turns a keystroke into a `user` message in the list above. +The typing indicator and the input both take the agent and need nothing else. `` calls `agent.submit()` itself, which is what turns a keystroke into a `user` message in the list above, and then emits `submitted` with the same trimmed text. -The output fires after the message has already been sent. A handler that calls `submit()` again posts the user message twice. The demo itself does this: its `(submitted)` handler submits a second time after `` has already submitted, which is a defect in the example. Your own application should bind `(submitted)` only for side effects such as analytics, not for submission. +The output fires after the message has already been sent. A handler that calls `submit()` again posts the user message twice. The demo binds nothing to it; bind `(submitted)` only for side effects such as analytics, never for submission. ### The component class diff --git a/apps/website/content/docs/chat/guides/thread-routing.mdx b/apps/website/content/docs/chat/guides/thread-routing.mdx index a8eda53fe..c5b1ef4e7 100644 --- a/apps/website/content/docs/chat/guides/thread-routing.mdx +++ b/apps/website/content/docs/chat/guides/thread-routing.mdx @@ -64,7 +64,7 @@ Never generate a thread id in the browser. Use the value handed to `onThreadId`, ### The sidebar and the thread list -The `` composition owns the transcript, the input, and the loading and error states. It also renders its own `` in a sidebar, shown at widths of 768px and up whenever the `[threads]` input it receives is non-empty. The same `[threads]` and `[activeThreadId]` inputs that feed the template's explicit list also feed that built-in one, which is why `` re-emits `threadSelected` — it is passing along the selection its own internal list produced. Because the demo passes `[threads]` to both `` and the explicit `` in the template, a desktop viewport shows two thread lists side by side; that is a defect in the example, not a pattern to copy. +The `` composition owns the transcript, the input, and the loading and error states, so the template adds the list beside it. `` can also render a `` of its own, in a sidebar shown at widths of 768px and up whenever its `[threads]` input is non-empty, and it re-emits that list's `threadSelected`. The demo leaves those inputs unbound and mounts one explicit list instead, so that the list can carry the row actions shown below. diff --git a/cockpit/chat/messages/angular/src/app/messages.component.ts b/cockpit/chat/messages/angular/src/app/messages.component.ts index bf20c0582..98b268f2e 100644 --- a/cockpit/chat/messages/angular/src/app/messages.component.ts +++ b/cockpit/chat/messages/angular/src/app/messages.component.ts @@ -73,7 +73,7 @@ import { MESSAGES_AGENT, type MessagesState } from './agent-ref';
- +
@@ -165,8 +165,4 @@ export class MessagesComponent { protected readonly messageContent = messageContent; protected readonly markdownDocument = markdownDocument; // #endregion - - submitMessage(content: string) { - this.agent.submit({ message: content }); - } } diff --git a/cockpit/chat/threads/angular/src/app/threads.component.ts b/cockpit/chat/threads/angular/src/app/threads.component.ts index 377fc8a57..18777e591 100644 --- a/cockpit/chat/threads/angular/src/app/threads.component.ts +++ b/cockpit/chat/threads/angular/src/app/threads.component.ts @@ -56,12 +56,7 @@ export const activeThreadIdState = signal(null); template: ` - +

Threads

diff --git a/libs/chat/ng-package.json b/libs/chat/ng-package.json index e6be1075e..df5d3d82e 100644 --- a/libs/chat/ng-package.json +++ b/libs/chat/ng-package.json @@ -14,11 +14,6 @@ "glob": "development-install.*", "output": ".install-collector" }, - { - "input": "src/lib/styles", - "glob": "chat.css", - "output": "." - }, { "input": "src/themes", "glob": "*.css", diff --git a/libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts b/libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts deleted file mode 100644 index 26de5c912..000000000 --- a/libs/chat/src/lib/a2ui/a2ui-slot.directive.spec.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { Component, ChangeDetectionStrategy, input, signal } from '@angular/core'; -import { TestBed } from '@angular/core/testing'; -import { A2uiSlotDirective } from './a2ui-slot.directive'; -import type { A2uiComponentView } from './component-view'; -import type { A2uiViews } from './views'; - -@Component({ - standalone: true, selector: 'a2ui-test-real', changeDetection: ChangeDetectionStrategy.OnPush, - template: 'REAL:{{ label() ?? "" }}', -}) -class RealCmp { readonly label = input(); } - -@Component({ - standalone: true, selector: 'a2ui-test-fallback', changeDetection: ChangeDetectionStrategy.OnPush, - template: 'FB', -}) -class FallbackCmp {} - -@Component({ - standalone: true, - imports: [A2uiSlotDirective], - template: ``, -}) -class HostCmp { - readonly view = input.required(); - readonly views = input.required(); -} - -function makeView(over: Partial = {}): A2uiComponentView { - return { - id: 'c1', type: 't', bindings: [], ready: false, props: {}, def: { t: {} } as never, - ...over, - }; -} - -describe('a2uiSlot', () => { - beforeEach(() => TestBed.configureTestingModule({})); - - it('mounts the fallback while !ready', () => { - const fx = TestBed.createComponent(HostCmp); - fx.componentRef.setInput('view', makeView({ ready: false })); - fx.componentRef.setInput('views', { t: { component: RealCmp, fallback: FallbackCmp } }); - fx.detectChanges(); - expect(fx.nativeElement.querySelector('[data-role="fallback"]')).toBeTruthy(); - expect(fx.nativeElement.querySelector('[data-role="real"]')).toBeFalsy(); - }); - - it('mounts the real component once ready=true', () => { - const fx = TestBed.createComponent(HostCmp); - const v = signal(makeView({ ready: false })); - fx.componentRef.setInput('view', v()); - fx.componentRef.setInput('views', { t: { component: RealCmp, fallback: FallbackCmp } }); - fx.detectChanges(); - fx.componentRef.setInput('view', makeView({ ready: true, props: { label: 'Ada' } })); - fx.detectChanges(); - expect(fx.nativeElement.querySelector('[data-role="real"]')).toBeTruthy(); - expect(fx.nativeElement.querySelector('[data-role="real"]')!.textContent).toContain('Ada'); - }); - - it('monotonic: once real mounts, later ready=false does NOT remount fallback', () => { - const fx = TestBed.createComponent(HostCmp); - fx.componentRef.setInput('view', makeView({ ready: true, props: { label: 'Ada' } })); - fx.componentRef.setInput('views', { t: { component: RealCmp, fallback: FallbackCmp } }); - fx.detectChanges(); - fx.componentRef.setInput('view', makeView({ ready: false, props: {} })); - fx.detectChanges(); - expect(fx.nativeElement.querySelector('[data-role="real"]')).toBeTruthy(); - expect(fx.nativeElement.querySelector('[data-role="fallback"]')).toBeFalsy(); - }); - - it('uses A2uiDefaultFallbackComponent when views[type].fallback is omitted', () => { - const fx = TestBed.createComponent(HostCmp); - fx.componentRef.setInput('view', makeView({ ready: false })); - fx.componentRef.setInput('views', { t: { component: RealCmp } }); - fx.detectChanges(); - expect(fx.nativeElement.querySelector('.a2ui-default-fallback')).toBeTruthy(); - }); - - it('accepts bare-Type view entries (legacy shape)', () => { - const fx = TestBed.createComponent(HostCmp); - fx.componentRef.setInput('view', makeView({ ready: true, props: { label: 'X' } })); - fx.componentRef.setInput('views', { t: RealCmp }); - fx.detectChanges(); - expect(fx.nativeElement.querySelector('[data-role="real"]')).toBeTruthy(); - }); -}); diff --git a/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts b/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts deleted file mode 100644 index a703dc06a..000000000 --- a/libs/chat/src/lib/a2ui/a2ui-slot.directive.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - Directive, Input, ViewContainerRef, ComponentRef, Type, inject, -} from '@angular/core'; -import type { A2uiComponentView } from './component-view'; -import type { A2uiViews } from './views'; -import { normalizeViewEntry } from './views'; -import { A2uiDefaultFallbackComponent } from './a2ui-default-fallback.component'; - -/** Internal recursive structural directive that mounts the right - * component for an `A2uiComponentView` instance. Monotonic: once the - * real component mounts, subsequent ticks only push new input values - * via `ComponentRef.setInput()` — no remount, no re-check of `ready`. */ -@Directive({ - selector: '[a2uiSlot]', - standalone: true, -}) -export class A2uiSlotDirective { - private view: A2uiComponentView | null = null; - private views: A2uiViews = {}; - private mountedReal = false; - private ref: ComponentRef | null = null; - private readonly vcr = inject(ViewContainerRef); - - @Input({ required: true }) set a2uiSlot(view: A2uiComponentView) { - this.view = view; - this.render(); - } - - @Input({ required: true }) set a2uiSlotViews(views: A2uiViews) { - this.views = views; - this.render(); - } - - private render(): void { - const view = this.view; - if (!view) return; - const entry = this.views[view.type]; - const normalized = entry != null ? normalizeViewEntry(entry) : undefined; - - // Monotonic gate: once real mounted, only push inputs. - if (this.mountedReal && this.ref) { - this.pushProps(this.ref, view.props); - return; - } - - if (view.ready && normalized) { - this.vcr.clear(); - const created = this.vcr.createComponent(normalized.component); - this.pushProps(created, view.props); - this.ref = created; - this.mountedReal = true; - return; - } - - // Not ready (or no entry yet) → mount fallback. - const fallback: Type = - normalized?.fallback ?? A2uiDefaultFallbackComponent; - // Avoid thrashing: only remount if the current ref isn't the fallback. - if (this.ref && this.ref.componentType === fallback) return; - this.vcr.clear(); - this.ref = this.vcr.createComponent(fallback); - } - - private pushProps(ref: ComponentRef, props: Record): void { - for (const [k, v] of Object.entries(props)) { - try { - ref.setInput(k, v); - } catch { - // Component doesn't declare this input — silently skip. The - // wire format may include keys the Angular component doesn't - // accept (e.g. children references handled separately). - } - } - } -} diff --git a/libs/chat/src/lib/a2ui/surface.component.ts b/libs/chat/src/lib/a2ui/surface.component.ts index 6f4b3d3da..32c48e96b 100644 --- a/libs/chat/src/lib/a2ui/surface.component.ts +++ b/libs/chat/src/lib/a2ui/surface.component.ts @@ -75,8 +75,9 @@ import type { A2uiViews } from './views'; }) /** * Renders an A2UI surface. Supports two input shapes: - * - `state` (preferred): chat-side `A2uiSurfaceState` driving progressive - * per-component rendering via `a2uiSlot` + readiness gates. + * - `state` (preferred): chat-side `A2uiSurfaceState`, converted to a + * json-render spec whose `$bindState` props defer each component until + * its data has arrived. * - `surface` (legacy): wire-format `A2uiSurface` fed into ``; * kept for backwards compatibility. * @@ -172,9 +173,9 @@ export class A2uiSurfaceComponent { * (`childKeys: el.children`) so catalog components receive the * inputs they actually declare. * - * This supersedes the earlier slot-based progressive renderer, - * which mounted root components but never populated their - * childKeys input — leaving Columns/Rows/etc. with no children. */ + * An earlier slot-based progressive renderer mounted root components + * but never populated their childKeys input, leaving Columns/Rows/etc. + * with no children; the spec conversion replaced it. */ readonly spec = computed(() => { const surf = this.state()?.surface ?? this.surface(); return surf && surf.components.size > 0 ? surfaceToSpec(surf) : null; diff --git a/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts b/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts index d76171591..244acb56e 100644 --- a/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts +++ b/libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts @@ -22,7 +22,7 @@ import { CHAT_INTERRUPT_STYLES } from '../../styles/chat-interrupt.styles'; * @example * ```ts * const interrupt = getInterrupt(agent); - * if (interrupt) agent.resume('approved'); + * if (interrupt) agent.submit({ resume: 'approved' }); * ``` */ export function getInterrupt(agent: Agent): AgentInterrupt | undefined { diff --git a/libs/cockpit-shell/package.json b/libs/cockpit-shell/package.json index 914d6f952..fd603c014 100644 --- a/libs/cockpit-shell/package.json +++ b/libs/cockpit-shell/package.json @@ -15,7 +15,6 @@ "private": true, "dependencies": { "@threadplane/cockpit-registry": "*", - "marked": "^15.0.0", "shiki": "^4.0.2" } } diff --git a/package-lock.json b/package-lock.json index b8e94b39b..368d8eccf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -92,6 +92,7 @@ "jsdom": "^29.0.0", "jsonc-eslint-parser": "^2.1.0", "katex": "^0.17.0", + "marked": "^15.0.0", "msw": "^2.4.9", "ng-packagr": "~21.1.0", "nx": "22.5.1", @@ -1678,7 +1679,6 @@ "license": "MIT", "dependencies": { "@threadplane/cockpit-registry": "*", - "marked": "^15.0.0", "shiki": "^4.0.2" } }, @@ -34808,6 +34808,7 @@ "version": "15.0.12", "resolved": "https://registry.npmjs.org/marked/-/marked-15.0.12.tgz", "integrity": "sha512-8dD6FusOQSrpv9Z1rdNMdlSgQOIP880DHqnohobOmYLElGEqAL/JvxvuxZO16r4HtjTlfPRDC1hbvxC9dPN2nA==", + "dev": true, "license": "MIT", "bin": { "marked": "bin/marked.js" diff --git a/package.json b/package.json index c6ead4408..bc9bb719f 100644 --- a/package.json +++ b/package.json @@ -74,6 +74,7 @@ "jsdom": "^29.0.0", "jsonc-eslint-parser": "^2.1.0", "katex": "^0.17.0", + "marked": "^15.0.0", "msw": "^2.4.9", "ng-packagr": "~21.1.0", "nx": "22.5.1",