diff --git a/apps/website/content/api/chat/ChatComponent-o0tq1y.json b/apps/website/content/api/chat/ChatComponent-o0tq1y.json index 67bc062a3..8e5bb984e 100644 --- a/apps/website/content/api/chat/ChatComponent-o0tq1y.json +++ b/apps/website/content/api/chat/ChatComponent-o0tq1y.json @@ -52,7 +52,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 776 + "line": 870 }, "overloadIndex": 1, "parameters": [] @@ -97,7 +97,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 403 + "line": 459 }, "isStatic": false, "isReadonly": true, @@ -153,7 +153,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 525 + "line": 581 }, "isStatic": false, "isReadonly": true, @@ -209,7 +209,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 390 + "line": 446 }, "isStatic": false, "isReadonly": true, @@ -279,7 +279,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1245 + "line": 1427 }, "overloadIndex": 1, "isStatic": false, @@ -338,7 +338,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1281 + "line": 1463 }, "overloadIndex": 1, "isStatic": false, @@ -380,7 +380,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1122 + "line": 1304 }, "overloadIndex": 1, "isStatic": false, @@ -441,7 +441,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 448 + "line": 504 }, "isStatic": false, "isReadonly": true, @@ -497,7 +497,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 438 + "line": 494 }, "isStatic": false, "isReadonly": true, @@ -553,7 +553,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 437 + "line": 493 }, "isStatic": false, "isReadonly": true, @@ -627,7 +627,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 399 + "line": 455 }, "isStatic": false, "isReadonly": true, @@ -673,7 +673,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 512 + "line": 568 }, "isStatic": false, "isReadonly": true, @@ -719,7 +719,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 432 + "line": 488 }, "isStatic": false, "isReadonly": true, @@ -792,7 +792,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 401 + "line": 457 }, "isStatic": false, "isReadonly": true, @@ -848,7 +848,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 550 + "line": 606 }, "isStatic": false, "isReadonly": true, @@ -891,7 +891,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 517 + "line": 573 }, "isStatic": false, "isReadonly": true, @@ -937,7 +937,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 454 + "line": 510 }, "isStatic": false, "isReadonly": true, @@ -993,7 +993,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 413 + "line": 469 }, "isStatic": false, "isReadonly": true, @@ -1039,7 +1039,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 423 + "line": 479 }, "isStatic": false, "isReadonly": true, @@ -1086,7 +1086,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1308 + "line": 1490 }, "overloadIndex": 1, "isStatic": false, @@ -1163,7 +1163,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1313 + "line": 1495 }, "overloadIndex": 1, "isStatic": false, @@ -1246,7 +1246,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1328 + "line": 1512 }, "overloadIndex": 1, "isStatic": false, @@ -1321,7 +1321,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1323 + "line": 1507 }, "overloadIndex": 1, "isStatic": false, @@ -1388,7 +1388,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1318 + "line": 1500 }, "overloadIndex": 1, "isStatic": false, @@ -1457,7 +1457,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1290 + "line": 1472 }, "overloadIndex": 1, "isStatic": false, @@ -1530,7 +1530,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1027 + "line": 1175 }, "overloadIndex": 1, "isStatic": false, @@ -1590,7 +1590,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 452 + "line": 508 }, "isStatic": false, "isReadonly": true, @@ -1636,7 +1636,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 450 + "line": 506 }, "isStatic": false, "isReadonly": true, @@ -1692,7 +1692,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 447 + "line": 503 }, "isStatic": false, "isReadonly": true, @@ -1748,7 +1748,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 500 + "line": 556 }, "isStatic": false, "isReadonly": true, @@ -1803,7 +1803,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 456 + "line": 512 }, "isStatic": false, "isReadonly": true, @@ -1859,7 +1859,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 543 + "line": 599 }, "isStatic": false, "isReadonly": true, @@ -1896,7 +1896,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1060 + "line": 1208 }, "overloadIndex": 1, "isStatic": false, @@ -1947,7 +1947,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 422 + "line": 478 }, "isStatic": false, "isReadonly": true, @@ -1993,7 +1993,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 421 + "line": 477 }, "isStatic": false, "isReadonly": true, @@ -2039,7 +2039,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 440 + "line": 496 }, "isStatic": false, "isReadonly": true, @@ -2094,7 +2094,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 400 + "line": 456 }, "isStatic": false, "isReadonly": true, @@ -2139,7 +2139,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 1108 + "line": 1290 }, "overloadIndex": 1, "isStatic": false, @@ -2209,7 +2209,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 402 + "line": 458 }, "isStatic": false, "isReadonly": true, @@ -2255,7 +2255,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 446 + "line": 502 }, "isStatic": false, "isReadonly": true, @@ -2311,7 +2311,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 533 + "line": 589 }, "isStatic": false, "isReadonly": true, @@ -2394,7 +2394,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 391 + "line": 447 }, "isStatic": false, "isReadonly": true, @@ -2440,7 +2440,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 509 + "line": 565 }, "isStatic": false, "isReadonly": true, @@ -2486,7 +2486,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 404 + "line": 460 }, "isStatic": false, "isReadonly": true, @@ -2495,7 +2495,7 @@ ], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 104 + "line": 120 }, "angular": { "selector": "chat", @@ -2640,6 +2640,6 @@ ], "source": { "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "line": 104 + "line": 120 } } diff --git a/apps/website/content/api/chat/ChatInputComponent-5x4y5w.json b/apps/website/content/api/chat/ChatInputComponent-5x4y5w.json index e328e1f5a..a2cb13a34 100644 --- a/apps/website/content/api/chat/ChatInputComponent-5x4y5w.json +++ b/apps/website/content/api/chat/ChatInputComponent-5x4y5w.json @@ -52,7 +52,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 125 + "line": 148 }, "overloadIndex": 1, "parameters": [] @@ -107,7 +107,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 95 + "line": 105 }, "isStatic": false, "isReadonly": true, @@ -153,7 +153,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 115 + "line": 125 }, "isStatic": false, "isReadonly": true, @@ -199,7 +199,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 109 + "line": 119 }, "isStatic": false, "isReadonly": true, @@ -245,7 +245,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 106 + "line": 116 }, "isStatic": false, "isReadonly": true, @@ -282,7 +282,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 142 + "line": 178 }, "overloadIndex": 1, "isStatic": false, @@ -333,7 +333,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 103 + "line": 113 }, "isStatic": false, "isReadonly": true, @@ -379,7 +379,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 102 + "line": 112 }, "isStatic": false, "isReadonly": true, @@ -425,7 +425,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 174 + "line": 235 }, "overloadIndex": 1, "isStatic": false, @@ -476,7 +476,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 166 + "line": 227 }, "overloadIndex": 1, "isStatic": false, @@ -518,7 +518,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 146 + "line": 182 }, "overloadIndex": 1, "isStatic": false, @@ -569,7 +569,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 97 + "line": 107 }, "isStatic": false, "isReadonly": true, @@ -615,7 +615,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 99 + "line": 109 }, "isStatic": false, "isReadonly": true, @@ -661,7 +661,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 101 + "line": 111 }, "isStatic": false, "isReadonly": true, @@ -707,7 +707,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 96 + "line": 106 }, "isStatic": false, "isReadonly": true, @@ -753,7 +753,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "line": 100 + "line": 110 }, "isStatic": false, "isReadonly": true, diff --git a/apps/website/content/api/chat/ChatMessageComponent-1s4clpt.json b/apps/website/content/api/chat/ChatMessageComponent-1s4clpt.json index e062770ce..4e24efe05 100644 --- a/apps/website/content/api/chat/ChatMessageComponent-1s4clpt.json +++ b/apps/website/content/api/chat/ChatMessageComponent-1s4clpt.json @@ -52,7 +52,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "line": 48 + "line": 61 }, "overloadIndex": 1, "parameters": [] @@ -97,7 +97,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "line": 57 + "line": 75 }, "isStatic": false, "isReadonly": true, @@ -143,7 +143,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "line": 41 + "line": 48 }, "isStatic": false, "isReadonly": true, @@ -189,7 +189,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "line": 54 + "line": 72 }, "isStatic": false, "isReadonly": true, @@ -245,7 +245,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "line": 44 + "line": 51 }, "isStatic": false, "isReadonly": true, @@ -301,7 +301,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "line": 43 + "line": 50 }, "isStatic": false, "isReadonly": true, @@ -357,7 +357,53 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "line": 40 + "line": 47 + }, + "isStatic": false, + "isReadonly": true, + "isOptional": false + }, + { + "name": "speaker", + "kind": "Property", + "canonicalReference": "@threadplane/chat!ChatMessageComponent#speaker:member", + "excerptTokens": [ + { + "kind": "Content", + "text": "readonly speaker: " + }, + { + "kind": "Content", + "text": "import(\"@angular/core\")." + }, + { + "kind": "Reference", + "text": "InputSignal", + "canonicalReference": "@angular/core!InputSignal:interface" + }, + { + "kind": "Content", + "text": "" + }, + { + "kind": "Content", + "text": ";" + } + ], + "docs": { + "summary": "Speaker name read by assistive technology before the message, so a\ntranscript says who said what. Visually hidden; the layout already shows\nit. Defaults to \"You\" for user and \"Assistant\" for assistant messages.", + "remarks": "", + "deprecated": "", + "returns": "", + "examples": [], + "see": [], + "params": [], + "typeParams": [] + }, + "members": [], + "source": { + "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", + "line": 57 }, "isStatic": false, "isReadonly": true, @@ -403,7 +449,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "line": 42 + "line": 49 }, "isStatic": false, "isReadonly": true, @@ -449,7 +495,7 @@ "members": [], "source": { "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "line": 55 + "line": 73 }, "isStatic": false, "isReadonly": true, @@ -458,7 +504,7 @@ ], "source": { "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "line": 13 + "line": 15 }, "angular": { "selector": "chat-message", @@ -487,6 +533,12 @@ "required": true, "type": "ChatMessageRole" }, + { + "name": "speaker", + "alias": "speaker", + "required": false, + "type": "string | undefined" + }, { "name": "streaming", "alias": "streaming", @@ -500,6 +552,6 @@ ], "source": { "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "line": 13 + "line": 15 } } diff --git a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts index 1ea057da1..a7b0f9479 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts @@ -1553,3 +1553,305 @@ describe('ChatComponent — scrollToBottom', () => { expect(cmp.pinned()).toBe(true); }); }); + +/** jsdom reports :focus-visible false for programmatic focus; model keyboard (true) or pointer (false) focus. */ +function focusVisibleAs(keyboard: boolean) { + const original = Element.prototype.matches; + return vi.spyOn(Element.prototype, 'matches').mockImplementation(function (this: Element, selector: string) { + return selector === ':focus-visible' ? keyboard && this === document.activeElement : original.call(this, selector); + }); +} + +describe('ChatComponent — focus continuity from welcome to conversation', () => { + it('moves focus into the conversation composer when the first send replaces the welcome view', async () => { + const keyboard = focusVisibleAs(true); + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatComponent); + const agent = mockAgent({ messages: [] }); + fx.componentRef.setInput('agent', agent); + fx.detectChanges(); + document.body.appendChild(fx.nativeElement); + try { + const welcomeTextarea = (fx.nativeElement as HTMLElement).querySelector('chat-welcome textarea') as HTMLTextAreaElement; + welcomeTextarea.focus(); + expect(document.activeElement).toBe(welcomeTextarea); + + agent.messages.set([{ id: 'u1', role: 'user', content: 'hello', delivery: staticDelivery('u1') }]); + fx.detectChanges(); + await new Promise((r) => requestAnimationFrame(() => r(undefined))); + + const composer = (fx.nativeElement as HTMLElement).querySelector('chat-window chat-input textarea'); + expect(composer).not.toBeNull(); + expect(document.activeElement).toBe(composer); + } finally { + (fx.nativeElement as HTMLElement).remove(); + keyboard.mockRestore(); + } + }); + + it('does not take focus when the conversation view appears without focus in the welcome view', async () => { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatComponent); + const agent = mockAgent({ messages: [] }); + fx.componentRef.setInput('agent', agent); + fx.detectChanges(); + document.body.appendChild(fx.nativeElement); + try { + agent.messages.set([{ id: 'u1', role: 'user', content: 'hello', delivery: staticDelivery('u1') }]); + fx.detectChanges(); + await new Promise((r) => requestAnimationFrame(() => r(undefined))); + expect(document.activeElement).toBe(document.body); + } finally { + (fx.nativeElement as HTMLElement).remove(); + } + }); +}); + +describe('ChatComponent — reading position while a reply arrives', () => { + function pendingChat() { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatComponent); + const agent = mockAgent({ + isLoading: true, + messages: [ + { id: 'u0', role: 'user', content: 'Earlier question', delivery: staticDelivery('u0') }, + { id: 'a0', role: 'assistant', content: 'Earlier answer', delivery: staticDelivery('a0') }, + { id: 'u1', role: 'user', content: 'New question', delivery: staticDelivery('u1') }, + ], + }); + fx.componentRef.setInput('agent', agent); + fx.detectChanges(); + const el = (fx.nativeElement as HTMLElement).querySelector('.chat-scroll')!; + let scrollTop = 2000; + Object.defineProperties(el, { + scrollHeight: { configurable: true, writable: true, value: 2400 }, + clientHeight: { configurable: true, value: 400 }, + scrollTop: { configurable: true, get: () => scrollTop, set: (value: number) => { scrollTop = Math.max(0, Math.min(el.scrollHeight - el.clientHeight, value)); } }, + }); + const pinned = (fx.componentInstance as unknown as { pinned(): boolean }).pinned; + return { fx, agent, el, pinned }; + } + + async function scrollUp(el: HTMLElement, to: number) { + el.dispatchEvent(new WheelEvent('wheel', { deltaY: -600, bubbles: true, cancelable: true })); + el.scrollTop = to; + el.dispatchEvent(new Event('scroll')); + await new Promise((resolve) => setTimeout(resolve, 0)); + await new Promise((r) => requestAnimationFrame(() => r(undefined))); + } + + it('keeps a reader scrolled up before the first token where they are when the reply message appears', async () => { + const { fx, agent, el, pinned } = pendingChat(); + await scrollUp(el, 600); + expect(pinned()).toBe(false); + + agent.messages.update((m) => [...m, { id: 'a1', role: 'assistant', content: 'First token', delivery: streamingDelivery('run') }]); + fx.detectChanges(); + + expect(el.scrollTop).toBe(600); + expect(pinned()).toBe(false); + }); + + it('follows a run that starts on the person\'s own prompt again, as Retry does', async () => { + const { fx, agent, el, pinned } = pendingChat(); + agent.isLoading.set(false); + fx.detectChanges(); + await scrollUp(el, 600); + expect(pinned()).toBe(false); + + agent.isLoading.set(true); + fx.detectChanges(); + + expect(pinned()).toBe(true); + expect(el.scrollTop).toBe(2000); + }); + + it('re-pins when the whole transcript is replaced, as on a thread switch', async () => { + const { fx, agent, el, pinned } = pendingChat(); + await scrollUp(el, 600); + expect(pinned()).toBe(false); + + agent.messages.set([ + { id: 'x0', role: 'user', content: 'Other thread', delivery: staticDelivery('x0') }, + { id: 'x1', role: 'assistant', content: 'Other answer', delivery: staticDelivery('x1') }, + ]); + fx.detectChanges(); + + expect(pinned()).toBe(true); + expect(el.scrollTop).toBe(2000); + }); +}); + +describe('ChatComponent — run endings are distinguishable', () => { + function runningChat() { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatComponent); + const agent = mockAgent({ + isLoading: true, + messages: [{ id: 'u1', role: 'user', content: 'Long question', delivery: staticDelivery('u1') }], + }); + fx.componentRef.setInput('agent', agent); + fx.detectChanges(); + const host = fx.nativeElement as HTMLElement; + return { fx, agent, host, status: () => host.querySelector('.chat-sr-only[role="status"]')?.textContent?.trim() }; + } + + it('marks a turn the person stopped, even when no partial reply is kept, and announces it', () => { + const { fx, agent, host, status } = runningChat(); + (host.querySelector('button[aria-label="Stop generating"]') as HTMLButtonElement).click(); + agent.isLoading.set(false); + fx.detectChanges(); + + expect(host.querySelector('.chat-stopped-notice')?.textContent).toContain('Response stopped'); + expect(status()).toBe('Response stopped.'); + }); + + it('clears the stopped marker when the next run starts', () => { + const { fx, agent, host } = runningChat(); + (host.querySelector('button[aria-label="Stop generating"]') as HTMLButtonElement).click(); + agent.isLoading.set(false); + fx.detectChanges(); + agent.isLoading.set(true); + fx.detectChanges(); + agent.messages.update((m) => [...m, { id: 'u2', role: 'user', content: 'Next', delivery: staticDelivery('u2') }]); + agent.isLoading.set(false); + fx.detectChanges(); + + expect(host.querySelector('.chat-stopped-notice')).toBeNull(); + }); + + it('marks a reply whose delivery ended aborted, whoever stopped it', () => { + const { fx, agent, host } = runningChat(); + agent.messages.update((m) => [...m, { id: 'a1', role: 'assistant', content: 'Partial', delivery: completeDelivery('run', 'aborted') }]); + agent.isLoading.set(false); + fx.detectChanges(); + expect(host.querySelector('.chat-stopped-notice')).not.toBeNull(); + }); + + it('announces completion without a stopped marker when the reply finishes', () => { + const { fx, agent, host, status } = runningChat(); + agent.messages.update((m) => [...m, { id: 'a1', role: 'assistant', content: 'Answer', delivery: completeDelivery('run', 'success') }]); + agent.isLoading.set(false); + fx.detectChanges(); + expect(host.querySelector('.chat-stopped-notice')).toBeNull(); + expect(status()).toBe('Response complete.'); + }); + + it('leaves failures to the error alert', () => { + const { fx, agent, status } = runningChat(); + agent.error.set({ kind: 'network', message: 'Offline', retryable: true } as never); + agent.isLoading.set(false); + fx.detectChanges(); + expect(status()).toBe(''); + }); + + it('does not mark a stopped turn whose reply then completed (adapter could not stop, or another thread)', () => { + const { fx, agent, host } = runningChat(); + (host.querySelector('button[aria-label="Stop generating"]') as HTMLButtonElement).click(); + agent.messages.update((m) => [...m, { id: 'a1', role: 'assistant', content: 'Full answer', delivery: completeDelivery('run', 'success') }]); + agent.isLoading.set(false); + fx.detectChanges(); + expect(host.querySelector('.chat-stopped-notice')).toBeNull(); + }); + + it('does not announce completion while the run waits on an interrupt', () => { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatComponent); + const agent = mockAgent({ isLoading: true, withInterrupt: true, messages: [{ id: 'u1', role: 'user', content: 'Delete backups', delivery: staticDelivery('u1') }] } as never); + fx.componentRef.setInput('agent', agent); + fx.detectChanges(); + agent.interrupt!.set({ id: 'i1', value: { question: 'Approve?' } } as never); + agent.isLoading.set(false); + fx.detectChanges(); + expect((fx.nativeElement as HTMLElement).querySelector('.chat-sr-only[role="status"]')?.textContent?.trim()).toBe(''); + }); + + it('names the transcript region and each speaker for assistive technology', () => { + const { fx, agent, host } = runningChat(); + agent.messages.update((m) => [...m, { id: 'a1', role: 'assistant', content: 'Answer', delivery: completeDelivery('run', 'success') }]); + agent.isLoading.set(false); + fx.detectChanges(); + const region = host.querySelector('.chat-scroll')!; + expect(region.getAttribute('role')).toBe('region'); + expect(region.getAttribute('aria-label')).toBe('Conversation'); + expect(region.getAttribute('tabindex')).toBe('0'); + const speakers = [...host.querySelectorAll('.chat-message__speaker')].map((e) => e.textContent?.trim()); + expect(speakers).toEqual(['You:', 'Assistant:']); + }); +}); + +describe('ChatComponent — focus continuity when a control removes itself', () => { + function chatWith(agent: ReturnType) { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatComponent); + fx.componentRef.setInput('agent', agent); + fx.detectChanges(); + document.body.appendChild(fx.nativeElement); + return fx; + } + const frame = () => new Promise((r) => requestAnimationFrame(() => r(undefined))); + const composerOf = (host: HTMLElement) => host.querySelector('chat-window chat-input textarea'); + + it('continues in the composer after the focused Retry clears the error', async () => { + const keyboard = focusVisibleAs(true); + const agent = mockAgent({ messages: [{ id: 'u1', role: 'user', content: 'hi', delivery: staticDelivery('u1') }] }); + agent.error.set({ kind: 'network', message: 'Offline', retryable: true } as never); + const fx = chatWith(agent); + try { + const host = fx.nativeElement as HTMLElement; + const retry = host.querySelector('chat-error button') as HTMLButtonElement; + retry.focus(); + agent.error.set(undefined); + fx.detectChanges(); + await frame(); + expect(document.activeElement).toBe(composerOf(host)); + } finally { (fx.nativeElement as HTMLElement).remove(); keyboard.mockRestore(); } + }); + + it.each([true, false])('after the scroll-to-latest bubble re-pins and disappears, keyboard=%s continues in the composer only from the keyboard', async (fromKeyboard) => { + const keyboard = focusVisibleAs(fromKeyboard); + const agent = mockAgent({ messages: [{ id: 'u1', role: 'user', content: 'hi', delivery: staticDelivery('u1') }] }); + const fx = chatWith(agent); + try { + const host = fx.nativeElement as HTMLElement; + (fx.componentInstance as unknown as { pinned: { set(v: boolean): void } }).pinned.set(false); + fx.detectChanges(); + const bubble = host.querySelector('chat-scroll-bubble button') as HTMLButtonElement; + bubble.focus(); + bubble.click(); + fx.detectChanges(); + await frame(); + expect(host.querySelector('chat-scroll-bubble')).toBeNull(); + expect(document.activeElement).toBe(fromKeyboard ? composerOf(host) : document.body); + } finally { (fx.nativeElement as HTMLElement).remove(); keyboard.mockRestore(); } + }); + +}); + +describe('ChatComponent — message direction and speaker names', () => { + it('gives each message body and the composer an automatic base direction', () => { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatComponent); + fx.componentRef.setInput('agent', mockAgent({ messages: [ + { id: 'u1', role: 'user', content: 'مرحبا، كيف حالك؟ 123 abc', delivery: staticDelivery('u1') }, + { id: 'a1', role: 'assistant', content: 'أهلا', delivery: staticDelivery('a1') }, + ] })); + fx.detectChanges(); + const host = fx.nativeElement as HTMLElement; + expect(host.querySelector('chat-message[data-role="user"] .chat-message__bubble')?.getAttribute('dir')).toBe('auto'); + expect(host.querySelector('chat-message[data-role="assistant"] .chat-message__assistant-body')?.getAttribute('dir')).toBe('auto'); + expect(host.querySelector('chat-window chat-input textarea')?.getAttribute('dir')).toBe('auto'); + }); + + it('uses an explicit speaker name in place of the default', () => { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatMessageComponent); + fx.componentRef.setInput('role', 'assistant'); + fx.componentRef.setInput('speaker', 'Support agent'); + fx.detectChanges(); + expect((fx.nativeElement as HTMLElement).querySelector('.chat-message__speaker')?.textContent?.trim()).toBe('Support agent:'); + fx.componentRef.setInput('speaker', undefined); + fx.detectChanges(); + expect((fx.nativeElement as HTMLElement).querySelector('.chat-message__speaker')?.textContent?.trim()).toBe('Assistant:'); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index be546576c..25676e416 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -1,7 +1,7 @@ // libs/chat/src/lib/compositions/chat/chat.component.ts import { Component, ChangeDetectionStrategy, input, model, output, computed, contentChild, contentChildren, effect, signal, untracked, viewChild, - ElementRef, DestroyRef, inject, Injector, runInInjectionContext, + ElementRef, DestroyRef, inject, Injector, runInInjectionContext, DOCUMENT, } from '@angular/core'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { DEVELOPMENT_COLLECTION_POLICY, isDevelopmentRuntimeEnabled } from '@threadplane/telemetry/browser'; @@ -91,6 +91,22 @@ function sameItems(a: readonly T[], b: readonly T[]): boolean { * Returns true when the scroll position is within `tolerance` px of the bottom. * Pure helper extracted for unit testing. */ +/** + * Calls `moved` when focus left the element for somewhere else (another + * control, or plain page content), not when the element was removed while + * focused. Browsers may dispatch focusout during removal, while the target is + * still connected, so the removal check waits one task. + */ +function onFocusMovedAway(event: FocusEvent, moved: () => void): void { + if (event.relatedTarget) { moved(); return; } + const target = event.target as Node | null; + setTimeout(() => { if (target?.isConnected) moved(); }, 0); +} + +function matchesFocusVisible(target: EventTarget | null): boolean { + try { return target instanceof Element && target.matches(':focus-visible'); } catch { return false; } +} + export function isPinned( scrollHeight: number, scrollTop: number, @@ -177,10 +193,34 @@ export function isPinned( } .chat-footer-wrap { position: relative; } .chat-run-activity, .chat-interrupt-region { display: block; } + .chat-scroll:focus-visible { outline: 2px solid var(--tplane-chat-text-muted); outline-offset: -2px; } + .chat-stopped-notice { + display: flex; + align-items: center; + gap: var(--tplane-chat-space-2); + box-sizing: border-box; + width: 100%; + max-width: var(--tplane-chat-max-width); + margin: 0 auto; + /* Same column and inset as chat-message-list, so it aligns with replies. */ + padding: 0 var(--tplane-chat-space-6); + color: var(--tplane-chat-text-muted); + font-size: var(--tplane-chat-font-size-sm); + } + .chat-stopped-notice svg { width: 12px; height: 12px; flex: none; } + .chat-sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } `], template: ` @if (showWelcome()) { - + @if (showModelPicker() && modelOptions().length > 0) { -
+ +
{{ humanContent(message) }} @@ -356,6 +399,16 @@ export function isPinned( />
} + + + @if (showStoppedNotice()) { +

+ + Response stopped +

+ }
+ +
{{ runAnnouncement() }}
} @@ -725,6 +781,7 @@ export class ChatComponent { private readonly markdownDocuments = new Map(); private readonly destroyRef = inject(DestroyRef); private readonly injector = inject(Injector); + private readonly composerDocument = inject(DOCUMENT); // Resolved against the component's own `providers` in normal use. The fallback // is for tests that construct ChatComponent via `new` inside a bare injection // context (no element injector, so component-level providers are skipped). @@ -741,6 +798,43 @@ export class ChatComponent { private partialEventsLastIndex = 0; private readonly scrollContainer = viewChild>('scrollContainer'); + private readonly composer = viewChild('composer'); + /** + * Controls that remove themselves when used — the welcome view on the first + * send (typed or a suggestion), Retry once the error clears — drop focus to + * ; the conversation composer is where the person continues. Each + * flag is true while focus is inside that control. A focusout with no + * relatedTarget is the removal itself, so it keeps the flag. + */ + protected welcomeHadFocus = false; + protected errorHadFocus = false; + + /** + * The turn the person stopped, identified by its prompt (user-message count + * and text survive the history refresh that can re-key message ids after a + * stop). Cleared when the next run starts. + */ + private readonly stoppedTurn = signal<{ userCount: number; prompt: string } | null>(null); + + /** True while the latest turn ended because the person (or adapter) stopped it. */ + protected readonly showStoppedNotice = computed(() => { + const agent = this.agent(); + if (agent.isLoading()) return false; + const msgs = agent.messages(); + const last = msgs[msgs.length - 1]; + const delivery = last?.role === 'assistant' ? last.delivery : undefined; + if (delivery?.phase === 'complete' && delivery.outcome === 'aborted') return true; + const stopped = this.stoppedTurn(); + // A reply that completed after the stop (an adapter that could not stop, + // or another thread with the same last prompt) is not a stopped turn. + if (!stopped || (delivery?.phase === 'complete' && delivery.outcome === 'success')) return false; + const users = msgs.filter((m) => m.role === 'user'); + const prompt = users[users.length - 1]; + return users.length === stopped.userCount && !!prompt && messageContent(prompt) === stopped.prompt; + }); + + /** Polite status text for the end of a run; emptied when a run starts so a repeat is re-announced. */ + protected readonly runAnnouncement = signal(''); private readonly messageCount = computed(() => this.agent().messages().length); private prevMessageCount = 0; private wasLoading = false; @@ -840,6 +934,53 @@ export class ChatComponent { } }); + effect(() => { + if (!this.composer() || !this.welcomeHadFocus) return; + this.welcomeHadFocus = false; + this.continueInComposer(); + }); + + effect(() => { + let error: unknown; + try { error = this.agent().error(); } catch { return; } + if (error || !this.errorHadFocus) return; + this.errorHadFocus = false; + this.continueInComposer(); + }); + + // Run boundaries: a new run clears the stopped marker and the last + // announcement; its end says how it ended (errors announce via the alert). + let runActive = false; + effect(() => { + let loading: boolean; + try { loading = this.agent().isLoading(); } catch { return; } + if (loading) { + const startsRun = !runActive; + runActive = true; + untracked(() => { + this.stoppedTurn.set(null); + this.runAnnouncement.set(''); + // A run on the person's own prompt (a send, or Retry re-sending it) + // follows the reply, as a submit does. + const msgs = this.agent().messages(); + if (startsRun && msgs[msgs.length - 1]?.role === 'user' && !this.pinned()) { + this.upwardWheel = undefined; + this.pinned.set(true); + this.followIfPinned(); + } + }); + return; + } + if (!runActive) return; + runActive = false; + untracked(() => { + // Errors announce through the alert; a pending interrupt through the + // host's interrupt template. + if (this.agent().error() || this.agent().interrupt?.()) return; + this.runAnnouncement.set(this.showStoppedNotice() ? 'Response stopped.' : 'Response complete.'); + }); + }); + // Native scroll events can arrive while our programmatic frame guard is // still live. Retain upward intent without cancelling or moving the user; // only actual transcript movement can interrupt that guard. @@ -906,9 +1047,16 @@ export class ChatComponent { void lastContent; const el = this.scrollContainer()?.nativeElement; if (!el) return; - const isNewMessage = count !== this.prevMessageCount; + const previousCount = this.prevMessageCount; + const isNewMessage = count !== previousCount; this.prevMessageCount = count; - if (isNewMessage) { + // Re-pin for the person's own turn or a replaced transcript: a thread + // switch or load empties the list first, a rollback shrinks it. A reply + // arriving is not a reason to move a reader who scrolled up while it + // was pending; the bubble offers the way back. (Ids are not compared: + // a history refresh can re-key optimistic ids.) + const replaced = previousCount === 0 || count < previousCount; + if (isNewMessage && (replaced || msgs[msgs.length - 1]?.role === 'user')) { this.upwardWheel = undefined; untracked(() => this.pinned.set(true)); } @@ -1091,8 +1239,42 @@ export class ChatComponent { requestAnimationFrame(() => { this.programmaticScrollCount--; }); } + protected onUserStopped(): void { + if (typeof (this.agent() as { stop?: unknown }).stop !== 'function') return; + const users = this.agent().messages().filter((m) => m.role === 'user'); + const prompt = users[users.length - 1]; + this.stoppedTurn.set(prompt ? { userCount: users.length, prompt: messageContent(prompt) } : null); + } + + protected onWelcomeFocusOut(event: FocusEvent): void { + onFocusMovedAway(event, () => { this.welcomeHadFocus = false; }); + } + + protected onErrorFocusOut(event: FocusEvent): void { + onFocusMovedAway(event, () => { this.errorHadFocus = false; }); + } + + /** Text fields always match :focus-visible; buttons only from the keyboard. */ + protected isKeyboardFocus(event: FocusEvent): boolean { + return matchesFocusVisible(event.target); + } + + /** After the current render, focuses the composer if focus fell to . */ + private continueInComposer(): void { + requestAnimationFrame(() => { + const active = this.composerDocument.activeElement; + if (!active || active === this.composerDocument.body) this.composer()?.focusTextarea(); + }); + } + protected onScrollBubbleClick(): void { + // Re-pinning removes the bubble. From the keyboard, the latest content is + // where the composer is; a pointer press leaves focus alone (a touch + // keyboard would cover what was just scrolled to). + const active = this.composerDocument.activeElement; + const fromKeyboard = !!active?.closest('chat-scroll-bubble') && matchesFocusVisible(active); this.scrollToBottom(); + if (fromKeyboard) this.continueInComposer(); } protected onUserSubmitted(): void { @@ -1316,6 +1498,8 @@ export class ChatComponent { /** Regenerate the assistant response at the given message index. */ onRegenerate(messageIndex: number): void { + this.upwardWheel = undefined; + this.pinned.set(true); void this.agent().regenerate(messageIndex); this.regenerate.emit(); } diff --git a/libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts b/libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts index d11ebbf89..512a268bf 100644 --- a/libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-input/chat-input.component.spec.ts @@ -1,4 +1,4 @@ -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { signal, computed } from '@angular/core'; import { TestBed, ComponentFixture } from '@angular/core/testing'; import { ChatInputComponent, submitMessage } from './chat-input.component'; @@ -124,6 +124,56 @@ describe('ChatInputComponent', () => { expect(button.disabled).toBe(false); }); + it('keeps the draft and starts no second run when Enter is pressed while a reply is running', () => { + const agent = mockAgent({ isLoading: true }); + setSignalInput(fixture.componentInstance.agent, agent); + fixture.componentInstance.messageText.set('follow-up for later'); + fixture.detectChanges(); + fixture.componentInstance.onKeydown(new KeyboardEvent('keydown', { key: 'Enter' })); + expect(agent.submitCalls).toHaveLength(0); + expect(fixture.componentInstance.messageText()).toBe('follow-up for later'); + }); + + it.each([true, false])('when the focused Stop is replaced by Send, keyboard=%s moves focus to the composer only from the keyboard', async (fromKeyboard) => { + const original = Element.prototype.matches; + const spy = vi.spyOn(Element.prototype, 'matches').mockImplementation(function (this: Element, selector: string) { + return selector === ':focus-visible' ? fromKeyboard && this === document.activeElement : original.call(this, selector); + }); + const agent = mockAgent({ isLoading: true }); + setSignalInput(fixture.componentInstance.agent, agent); + fixture.detectChanges(); + document.body.appendChild(fixture.nativeElement); + try { + const stop = fixture.nativeElement.querySelector('button[aria-label="Stop generating"]') as HTMLButtonElement; + stop.focus(); + expect(document.activeElement).toBe(stop); + agent.isLoading.set(false); + fixture.detectChanges(); + await new Promise((r) => requestAnimationFrame(() => r(undefined))); + expect(document.activeElement).toBe(fromKeyboard ? fixture.nativeElement.querySelector('textarea') : document.body); + } finally { + fixture.nativeElement.remove(); + spy.mockRestore(); + } + }); + + it('says why Enter did nothing while a reply runs, and clears that when the reply ends', () => { + const agent = mockAgent({ isLoading: true }); + setSignalInput(fixture.componentInstance.agent, agent); + fixture.componentInstance.messageText.set('next question'); + fixture.detectChanges(); + const hint = () => (fixture.nativeElement as HTMLElement).querySelector('.chat-input__hint'); + expect(hint()?.textContent?.trim() ?? '').toBe(''); + fixture.componentInstance.onKeydown(new KeyboardEvent('keydown', { key: 'Enter' })); + fixture.detectChanges(); + expect(hint()?.getAttribute('role')).toBe('status'); + expect(hint()?.textContent?.trim()).toBe('A response is still running. Stop it to send now, or send when it finishes.'); + agent.isLoading.set(false); + fixture.detectChanges(); + expect(hint()?.textContent?.trim() ?? '').toBe(''); + expect(fixture.componentInstance.messageText()).toBe('next question'); + }); + it('renders the pill with full border-radius', () => { const pill = (fixture.nativeElement as HTMLElement).querySelector('.chat-input__pill') as HTMLElement; expect(pill).not.toBeNull(); diff --git a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts index c90fd6245..6efb77990 100644 --- a/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts +++ b/libs/chat/src/lib/primitives/chat-input/chat-input.component.ts @@ -53,6 +53,7 @@ export function submitMessage( (focus)="focused.set(true)" (blur)="focused.set(false)" name="messageText" + dir="auto" rows="1" aria-label="Type a message" > @@ -64,6 +65,8 @@ export function submitMessage( type="button" class="chat-input__send chat-input__send--stop" (click)="onStop()" + (focus)="onStopFocus($event)" + (blur)="onStopBlur($event)" aria-label="Stop generating" title="Stop generating" > @@ -87,6 +90,13 @@ export function submitMessage( } + +
+ @if (showRunningHint()) { + A response is still running. Stop it to send now, or send when it finishes. + } +
`, @@ -116,6 +126,19 @@ export class ChatInputComponent { private readonly textareaEl = viewChild>('textareaEl'); + /** + * True while the Stop control holds keyboard focus. Stop is swapped for Send + * when the run ends — by the user or on its own — and removing the focused + * button would drop focus to ; the composer is the logical + * continuation. Pointer focus is left alone: moving it into the textarea + * would raise a touch keyboard nobody asked for. + */ + protected stopHadFocus = false; + + /** Enter was pressed with a draft while a reply was running; cleared when the run ends. */ + private readonly enterBlockedByRun = signal(false); + protected readonly showRunningHint = computed(() => this.enterBlockedByRun() && this.isLoading()); + /** * Auto-resize the textarea to fit its content as the user types or pastes * multi-line text. Caps at min(40vh, 320px); beyond that the textarea @@ -137,6 +160,19 @@ export class ChatInputComponent { el.style.overflowY = el.scrollHeight > cap ? 'auto' : 'hidden'; void text; }); + + effect(() => { + if (!this.isLoading()) this.enterBlockedByRun.set(false); + }); + + effect(() => { + if (this.isLoading() || !this.stopHadFocus) return; + this.stopHadFocus = false; + requestAnimationFrame(() => { + const active = this.textareaEl()?.nativeElement.ownerDocument.activeElement; + if (!active || active === active.ownerDocument.body) this.focusTextarea(); + }); + }); } focusTextarea(): void { @@ -144,6 +180,12 @@ export class ChatInputComponent { } onSubmit(): void { + // Enter reaches here even while Send is disabled; a running reply keeps + // the draft in place instead of starting a second, concurrent run. + if (!this.canSubmit()) { + if (this.isLoading() && this.messageText().trim()) this.enterBlockedByRun.set(true); + return; + } const submitted = submitMessage(this.agent(), this.messageText()); if (submitted !== null) { this.submitted.emit(submitted); @@ -162,6 +204,25 @@ export class ChatInputComponent { this.messageText.set((event.target as HTMLTextAreaElement).value); } + protected onStopFocus(event: FocusEvent): void { + try { + this.stopHadFocus = (event.target as Element).matches(':focus-visible'); + } catch { + this.stopHadFocus = false; + } + } + + /** + * Focus that moved elsewhere in the page ends the hand-off; removal of the + * button does not. Browsers may dispatch blur during removal, while the + * button is still connected, so the removal check waits one task. + */ + protected onStopBlur(event: FocusEvent): void { + if (event.relatedTarget) { this.stopHadFocus = false; return; } + const button = event.target as Node; + setTimeout(() => { if (button.isConnected) this.stopHadFocus = false; }, 0); + } + /** Abort the current streaming response (if the adapter supports it). */ onStop(): void { const a = this.agent() as unknown as { stop?: () => void | Promise }; diff --git a/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts index d25f2590f..4b74d1ad1 100644 --- a/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts +++ b/libs/chat/src/lib/primitives/chat-message/chat-message.component.ts @@ -9,6 +9,8 @@ import type { Message } from '../../agent/message'; /** Message roles used to select the chat message layout. */ export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool'; +const DEFAULT_SPEAKERS: Partial> = { user: 'You', assistant: 'Assistant' }; + /** Provides role-specific message layout, projected controls, and citations for assistant messages. */ @Component({ selector: 'chat-message', @@ -24,7 +26,12 @@ export type ChatMessageRole = 'user' | 'assistant' | 'system' | 'tool'; '[attr.data-prev-role]': 'prevRole() ?? null', }, template: ` -
+ @if (speakerText(); as speaker) { + {{ speaker }} + } + +
@@ -42,6 +49,12 @@ export class ChatMessageComponent { readonly streaming = input(false); readonly prevRole = input(undefined); readonly message = input(undefined); + /** + * Speaker name read by assistive technology before the message, so a + * transcript says who said what. Visually hidden; the layout already shows + * it. Defaults to "You" for user and "Assistant" for assistant messages. + */ + readonly speaker = input(undefined); private readonly resolver = inject(CitationsResolverService); @@ -51,6 +64,11 @@ export class ChatMessageComponent { }); } + protected readonly speakerText = computed(() => { + const name = this.speaker() ?? DEFAULT_SPEAKERS[this.role()]; + return name ? `${name}:` : undefined; + }); + readonly currentStr = computed(() => String(this.current())); readonly streamingStr = computed(() => String(this.streaming())); diff --git a/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.spec.ts b/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.spec.ts index 5ca954dce..acb46577f 100644 --- a/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.spec.ts +++ b/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.spec.ts @@ -33,9 +33,9 @@ describe('ChatScrollBubbleComponent', () => { expect(clicks).toBe(1); }); - it('uses aria-label "Latest activity" in streaming mode', () => { + it('names the scroll action in streaming mode too', () => { expect(render('streaming').nativeElement.querySelector('button')!.getAttribute('aria-label')) - .toBe('Latest activity'); + .toBe('Scroll to latest, response in progress'); }); it('uses aria-label "Scroll to latest" in idle mode', () => { diff --git a/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts b/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts index 0efb350cb..2dcd967aa 100644 --- a/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts +++ b/libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts @@ -40,6 +40,7 @@ export class ChatScrollBubbleComponent { readonly mode = input.required(); readonly clicked = output(); protected readonly ariaLabel = computed(() => - this.mode() === 'streaming' ? 'Latest activity' : 'Scroll to latest', + // Name the action in both modes; the dots only show that more is arriving. + this.mode() === 'streaming' ? 'Scroll to latest, response in progress' : 'Scroll to latest', ); } diff --git a/libs/chat/src/lib/styles/chat-error.styles.ts b/libs/chat/src/lib/styles/chat-error.styles.ts index f66eaa7db..e92fab908 100644 --- a/libs/chat/src/lib/styles/chat-error.styles.ts +++ b/libs/chat/src/lib/styles/chat-error.styles.ts @@ -1,5 +1,5 @@ export const CHAT_ERROR_STYLES = ` - :host { display: block; } + :host { display: block; padding: 0 var(--tplane-chat-edge-pad); } .chat-error { display: flex; flex-wrap: wrap; @@ -11,7 +11,9 @@ export const CHAT_ERROR_STYLES = ` border-radius: var(--tplane-chat-radius-card); padding: 8px 12px; font-size: var(--tplane-chat-font-size-sm); - margin: 0 var(--tplane-chat-space-6) var(--tplane-chat-space-2); + box-sizing: border-box; + max-width: var(--tplane-chat-max-width); + margin: 0 auto var(--tplane-chat-space-2); } .chat-error__icon { flex-shrink: 0; width: 16px; height: 16px; margin-top: 2px; } .chat-error__msg { flex: 1; min-width: 0; word-break: break-word; } @@ -38,7 +40,7 @@ export const CHAT_ERROR_STYLES = ` color: var(--tplane-chat-error-text); } .chat-error__retry:focus-visible, .chat-error__check:focus-visible { - outline: 2px solid var(--tplane-chat-error-border); + outline: 2px solid var(--tplane-chat-error-text); outline-offset: 2px; } `; diff --git a/libs/chat/src/lib/styles/chat-input.styles.ts b/libs/chat/src/lib/styles/chat-input.styles.ts index 2ba1aedb8..e7fc6cda3 100644 --- a/libs/chat/src/lib/styles/chat-input.styles.ts +++ b/libs/chat/src/lib/styles/chat-input.styles.ts @@ -23,6 +23,16 @@ export const CHAT_INPUT_STYLES = ` padding: 8px 8px 8px 16px; min-height: 56px; box-sizing: border-box; + transition: border-color 150ms ease; +} +/* The textarea draws no outline of its own; the pill carries the focus cue. */ +.chat-input__pill:focus-within { border-color: var(--tplane-chat-text-muted); } + +.chat-input__hint > span { + display: block; + margin: 6px 16px 0; + font-size: var(--tplane-chat-font-size-sm); + color: var(--tplane-chat-text-muted); } .chat-input__textarea { @@ -75,10 +85,18 @@ export const CHAT_INPUT_STYLES = ` .chat-input__send:not(:disabled):hover { transform: scale(1.05); } .chat-input__send svg { width: 16px; height: 16px; } +/* Stop is the live action while a reply runs, so it takes the enabled Send + fill rather than the muted tone that reads as unavailable. */ .chat-input__send--stop { - background: var(--tplane-chat-text-muted); + background: var(--tplane-chat-text); color: var(--tplane-chat-bg); } .chat-input__send--stop:hover { transform: scale(1.05); } +/* Forced colours drop the circular fills, leaving a bare glyph; a system-colour + border keeps both controls recognisable as buttons. */ +@media (forced-colors: active) { + .chat-input__send, .chat-input__send--stop { border: 1px solid ButtonText; } + .chat-input__send:disabled { border-color: GrayText; } +} .chat-input__send--stop svg { width: 14px; height: 14px; } `; diff --git a/libs/chat/src/lib/styles/chat-message-actions.styles.ts b/libs/chat/src/lib/styles/chat-message-actions.styles.ts index 82b8177cd..501aebc7b 100644 --- a/libs/chat/src/lib/styles/chat-message-actions.styles.ts +++ b/libs/chat/src/lib/styles/chat-message-actions.styles.ts @@ -1,7 +1,8 @@ // libs/chat/src/lib/styles/chat-message-actions.styles.ts // // Action-button row underneath assistant messages. Hidden by default, fades -// in on hover/focus of the parent chat-message, always visible on mobile. +// in on hover/focus of the parent chat-message, always visible on narrow or +// hover-less (touch) devices. export const CHAT_MESSAGE_ACTIONS_STYLES = ` :host { display: flex; @@ -22,7 +23,8 @@ export const CHAT_MESSAGE_ACTIONS_STYLES = ` opacity: 0 !important; pointer-events: none !important; } - @media (max-width: 768px) { + /* Touch-first devices of any width have no hover to reveal the row. */ + @media (max-width: 768px), (hover: none) { :host-context(chat-message[data-role="assistant"]) { opacity: 1; pointer-events: auto; diff --git a/libs/chat/src/lib/styles/chat-message.styles.spec.ts b/libs/chat/src/lib/styles/chat-message.styles.spec.ts index a6fdd5a40..812411409 100644 --- a/libs/chat/src/lib/styles/chat-message.styles.spec.ts +++ b/libs/chat/src/lib/styles/chat-message.styles.spec.ts @@ -1,6 +1,7 @@ // libs/chat/src/lib/styles/chat-message.styles.spec.ts import { describe, it, expect } from 'vitest'; import { CHAT_MESSAGE_STYLES } from './chat-message.styles'; +import { CHAT_MESSAGE_ACTIONS_STYLES } from './chat-message-actions.styles'; describe('CHAT_MESSAGE_STYLES — controls positioning', () => { const normalized = CHAT_MESSAGE_STYLES.replace(/\s+/g, ' '); @@ -15,3 +16,20 @@ describe('CHAT_MESSAGE_STYLES — controls positioning', () => { ); }); }); + +describe('CHAT_MESSAGE_STYLES — controls reveal stays in sync with the actions row', () => { + // The controls wrapper and the actions row each hide on non-current + // messages. If one reveals under a media query the other does not, the + // buttons are tappable but invisible (or visible but inert). + const revealQuery = (css: string, selector: string) => + [...css.replace(/\s+/g, ' ').matchAll(/@media ([^{]+)\{([^}]*\})/g)] + .find(([, , body]) => body.includes(selector))?.[1].trim(); + + it('reveals the wrapper under the same media query as the actions row', () => { + const wrapper = revealQuery(CHAT_MESSAGE_STYLES, '.chat-message__controls'); + const row = revealQuery(CHAT_MESSAGE_ACTIONS_STYLES, ':host-context(chat-message[data-role="assistant"])'); + expect(wrapper).toBeDefined(); + expect(wrapper).toBe(row); + expect(wrapper).toContain('(hover: none)'); + }); +}); diff --git a/libs/chat/src/lib/styles/chat-message.styles.ts b/libs/chat/src/lib/styles/chat-message.styles.ts index 598c8569f..55e90048b 100644 --- a/libs/chat/src/lib/styles/chat-message.styles.ts +++ b/libs/chat/src/lib/styles/chat-message.styles.ts @@ -1,6 +1,15 @@ // libs/chat/src/lib/styles/chat-message.styles.ts export const CHAT_MESSAGE_STYLES = ` :host { display: block; } + .chat-message__speaker { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip-path: inset(50%); + white-space: nowrap; + } :host([data-role="user"]) { display: block; margin-top: 0.5rem; @@ -84,7 +93,9 @@ export const CHAT_MESSAGE_STYLES = ` opacity: 1; pointer-events: auto; } - @media (max-width: 768px) { + /* Must match chat-message-actions' reveal query: if only the inner row is + revealed, its buttons are tappable inside this still-transparent wrapper. */ + @media (max-width: 768px), (hover: none) { :host([data-role="assistant"]) .chat-message__controls { opacity: 1; pointer-events: auto; } } .chat-message__control-btn { diff --git a/libs/chat/src/lib/styles/chat-tokens.ts b/libs/chat/src/lib/styles/chat-tokens.ts index 7f6462cdd..3637a800c 100644 --- a/libs/chat/src/lib/styles/chat-tokens.ts +++ b/libs/chat/src/lib/styles/chat-tokens.ts @@ -12,7 +12,9 @@ const LIGHT_TOKENS = ` --tplane-chat-muted: rgb(200, 200, 200); --tplane-chat-error-bg: #fef2f2; --tplane-chat-error-border: #fecaca; - --tplane-chat-error-text: #dc2626; + /* #dc2626 measured 4.41:1 on --tplane-chat-error-bg, below 4.5:1 for the + 14px alert text; this red is ≈5.9:1 there and 6.4:1 on white. */ + --tplane-chat-error-text: #b91c1c; --tplane-chat-destructive: #dc2626; --tplane-chat-warning-bg: #fffbeb; --tplane-chat-warning-text: #b45309; diff --git a/libs/langgraph/src/lib/agent.fn.spec.ts b/libs/langgraph/src/lib/agent.fn.spec.ts index 5af38f88d..f0b78e252 100644 --- a/libs/langgraph/src/lib/agent.fn.spec.ts +++ b/libs/langgraph/src/lib/agent.fn.spec.ts @@ -702,6 +702,31 @@ describe('agent', () => { expect(ref.isThreadLoading()).toBe(false); }); + it('keeps the run loading when a bound threadId signal adopts the thread created for it', async () => { + // The host mirrors the transport-created id into its threadId signal + // mid-run (null → id). That is an adoption, not a switch: the bridge keeps + // streaming, so the derived status must not drop to idle under it. + const transport = new MockAgentTransport(); + const threadId = signal(null); + const ref = withInjectionContext(() => + agent({ apiUrl: '', assistantId: 'a', transport, threadId }) + ); + + ref.submit({ message: 'hello' }); + transport.emit([ + { type: 'messages', messages: [{ id: '1', type: 'human', content: 'hello' }] as any[] }, + ]); + await new Promise(r => setTimeout(r, 20)); + expect(ref.isLoading()).toBe(true); + + threadId.set('thread-created'); + await new Promise(r => setTimeout(r, 30)); + + expect(ref.isLoading()).toBe(true); + expect(ref.messages()).toHaveLength(1); + await ref.stop(); + }); + it('langGraphInterrupts() exposes raw LangGraph interrupts signal', () => { const transport = new MockAgentTransport(); const ref = withInjectionContext(() => diff --git a/libs/langgraph/src/lib/agent.fn.ts b/libs/langgraph/src/lib/agent.fn.ts index 592cce979..94c7492f9 100644 --- a/libs/langgraph/src/lib/agent.fn.ts +++ b/libs/langgraph/src/lib/agent.fn.ts @@ -248,8 +248,16 @@ export function agent< let hasSeenThreadId = false; let lastThreadId: string | null = null; + // Mirrors the bridge's own rule, read before the bridge sees the same id + // (this subscription is made first). An id the bridge adopts mid-run — the + // thread the transport created for the run, mirrored back by the host — must + // keep the run's status: the bridge keeps streaming, and an Idle status + // would show the running reply as finished, with no Stop and Send enabled. + // Only reached after the first emission, so `manager` is initialised. + const bridgeSwitchesThread = (id: string | null): boolean => + manager.currentThreadId !== null && manager.currentThreadId !== id; threadId$.pipe(takeUntil(destroy$)).subscribe((id) => { - if (hasSeenThreadId && lastThreadId !== id) { + if (hasSeenThreadId && lastThreadId !== id && (bridgeSwitchesThread(id) || status$.value !== ResourceStatus.Loading)) { resetDerivedThreadState(); } hasSeenThreadId = true; diff --git a/scripts/react-parity/baseline.json b/scripts/react-parity/baseline.json index 9cb1a2ef0..d8d07f2a3 100644 --- a/scripts/react-parity/baseline.json +++ b/scripts/react-parity/baseline.json @@ -1,15 +1,11 @@ { "schemaVersion": 1, - "baselineHead": "fb915e8accdbe5bce033f97b10daa0f8a009e827", + "baselineHead": "ff5be97a03ad5bcd2df1e8407b017e6ea220d4e6", "sourceState": { "modified": [ - "libs/react/README.md", - "libs/react/src/chat/index.ts", - "libs/react/src/chat/styles.css" + "libs/chat/src/lib/styles/chat-message.styles.ts" ], - "untracked": [ - "libs/react/src/chat/suggestions.tsx" - ] + "untracked": [] }, "scope": { "libraries": [ @@ -1505,7 +1501,7 @@ "decorators": [ "Component" ], - "signature": "@Component({\n selector: 'chat',\n standalone: true,\n imports: [\n KeyValuePipe, NgTemplateOutlet,\n ChatWindowComponent, ChatMessageListComponent, MessageTemplateDirective, ChatMessageComponent,\n ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent,\n ChatThreadListComponent, ChatGenerativeUiComponent,\n ChatStreamingMdComponent, ChatToolCallsComponent, ChatToolViewsComponent, A2uiSurfaceComponent,\n ChatMessageActionsComponent, ChatWelcomeComponent, ChatSelectComponent, ChatReasoningComponent,\n ChatScrollBubbleComponent,\n ],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n { provide: CHAT_LIFECYCLE, useFactory: createChatLifecycle },\n {\n provide: DEVELOPMENT_COLLECTION_POLICY,\n useFactory: () => {\n const host = inject(ChatComponent);\n const parent = inject(DEVELOPMENT_COLLECTION_POLICY, { optional: true, skipSelf: true });\n return () => (parent?.() ?? true) && isDevelopmentRuntimeEnabled(host.agent());\n },\n },\n ],\n styles: [CHAT_HOST_TOKENS, `\n :host {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n height: 100%;\n min-height: 0;\n max-height: 100%;\n overflow: hidden;\n background: var(--tplane-chat-bg);\n }\n :host > chat-welcome {\n display: flex;\n flex: 1 1 auto;\n width: 100%;\n }\n .chat-shell { display: flex; flex: 1; min-height: 0; overflow: hidden; }\n .chat-shell__sidebar {\n width: 240px;\n flex-shrink: 0;\n border-right: 1px solid var(--tplane-chat-separator);\n background: var(--tplane-chat-surface-alt);\n overflow-y: auto;\n display: none;\n }\n @media (min-width: 768px) { .chat-shell__sidebar { display: block; } }\n .chat-shell__main { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; }\n .chat-empty {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n padding: 60px 20px;\n color: var(--tplane-chat-text-muted);\n text-align: center;\n flex: 1;\n min-height: 0;\n }\n .chat-empty[hidden] { display: none; }\n .chat-empty__title { font-size: 1.125rem; font-weight: 500; color: var(--tplane-chat-text); margin: 0; }\n .chat-empty__sub { margin: 0; font-size: var(--tplane-chat-font-size-sm); }\n .chat-empty__title { font-size: 1.125rem; font-weight: 500; color: var(--tplane-chat-text); margin: 0; }\n .chat-empty__sub { margin: 0; font-size: var(--tplane-chat-font-size-sm); }\n .chat-scroll { flex: 1; min-height: 0; overflow-y: auto; padding-top: var(--tplane-chat-edge-pad); }\n .chat-scroll::-webkit-scrollbar { width: 6px; }\n .chat-scroll::-webkit-scrollbar-thumb { background: var(--tplane-chat-separator); border-radius: 10px; }\n [chatFooter] {\n padding-bottom: var(--tplane-chat-edge-pad);\n }\n .chat-footer-wrap { position: relative; }\n .chat-run-activity, .chat-interrupt-region { display: block; }\n `],\n template: `\n @if (showWelcome()) {\n \n \n @if (showModelPicker() && modelOptions().length > 0) {\n \n }\n \n \n \n \n \n } @else {\n
\n @if (threads().length > 0) {\n \n }\n
\n \n \n
\n \n \n {{ humanContent(message) }}\n \n\n \n @let content = messageContent(message);\n @let classified = classifyMessage(content, message);\n \n \n @if (activityTemplate(); as activity) {\n \n } @else {\n \n @if (message.reasoning && reasoningRunStart(i)) {\n @let run = reasoningRun(i);\n \n }\n \n \n }\n \n @if (classified.markdown(); as md) {\n \n }\n @if (classified.spec(); as spec) {\n \n \n }\n @if (classified.type() === 'a2ui' && views(); as catalog) {\n @for (entry of classified.a2uiSurfaces() | keyvalue; track entry.key) {\n \n }\n }\n \n @if (content.trim()) {\n \n }\n \n \n\n \n \n \n\n \n {{ messageContent(message) }}\n \n \n\n \n @if (runActivityContext(); as runContext) {\n
\n \n
\n } @else if (pinned() && !currentAssistantStreaming()) {\n \n \n }\n\n \n @if (interruptContext(); as pending) {\n
\n \n
\n }\n
\n \n
\n
\n
\n }\n `,\n})\nexport class ChatComponent {\n readonly agent = input.required();\n readonly views = input(undefined);\n readonly clientTools = input(undefined);\n readonly store = input(undefined);\n readonly handlers = input) => unknown | Promise>>({});\n readonly threads = input([]);\n readonly activeThreadId = input('');\n readonly welcomeDisabled = input(false);\n readonly modelOptions = input([]);\n readonly showModelPicker = input(true);\n readonly selectedModel = model('');\n readonly modelPickerPlaceholder = input('Choose a model');\n readonly genuiToolNames = input([\n 'generate_a2ui_schema',\n 'generate_json_render_spec',\n 'render_spec',\n ]);\n readonly clientToolExecutionGuard = input(undefined);\n readonly clientToolContinuationPolicy = input(undefined);\n readonly showWelcome = computed(() => {\n if (this.welcomeDisabled())\n return false;\n const a = this.agent() as unknown as {\n isThreadLoading?: () => boolean;\n };\n if (a.isThreadLoading?.())\n return false;\n return this.agent().messages().length === 0;\n });\n readonly threadSelected = output();\n readonly renderEvent = output();\n readonly clientToolContinuationLimit = output();\n readonly regenerate = output();\n readonly rate = output<{\n messageIndex: number;\n rating: 'up' | 'down';\n }>();\n readonly messageCopy = output<{\n messageIndex: number;\n content: string;\n }>();\n private readonly _internalStore = signalStateStore({});\n readonly resolvedStore = computed(() => {\n const explicit = this.store();\n if (explicit)\n return explicit;\n if (this.effectiveViews())\n return this._internalStore;\n return undefined;\n });\n private readonly coordinator = computed(() => {\n const reg = this.clientTools();\n const policy = this.clientToolContinuationPolicy();\n return reg ? createClientToolsCoordinator(reg, {\n executionGuard: this.clientToolExecutionGuard(),\n continuationPolicy: {\n ...policy,\n onLimit: (event) => {\n policy?.onLimit?.(event);\n this.clientToolContinuationLimit.emit(event);\n },\n },\n }) : undefined;\n });\n protected readonly effectiveViews = computed(() => {\n const base = this.views();\n const coord = this.coordinator();\n if (!coord)\n return base;\n return base ? withViews(base, coord.viewRegistry) : coord.viewRegistry;\n });\n readonly renderRegistry = computed(() => {\n const v = this.views();\n return v ? toRenderRegistry(v) : undefined;\n });\n readonly viewToolNames = computed(() => Object.keys(this.effectiveViews() ?? {}));\n readonly excludedToolNames = computed(() => [\n ...this.genuiToolNames(),\n ...this.viewToolNames(),\n ]);\n readonly messageContent = messageContent;\n readonly activityTemplate = contentChild(ChatActivityTemplateDirective);\n readonly toolCallTemplates = contentChildren(ChatToolCallTemplateDirective);\n private readonly activityContexts = new Map();\n readonly runActivityTemplate = contentChild(ChatRunActivityTemplateDirective);\n readonly interruptTemplate = contentChild(ChatInterruptTemplateDirective);\n private readonly currentRunMessages = computed(() => {\n const msgs = this.agent().messages();\n let start = msgs.length;\n while (start > 0 && msgs[start - 1].role !== 'user')\n start--;\n return msgs.slice(start);\n }, { equal: sameItems });\n protected readonly runActivityContext = computed(() => {\n if (!this.runActivityTemplate())\n return undefined;\n const agent = this.agent();\n if (!agent.isLoading())\n return undefined;\n const messages = this.currentRunMessages();\n if (messages.some((m) => m.role === 'assistant'))\n return undefined;\n return { $implicit: agent, running: true, hasAssistantMessage: false, messages };\n }, {\n equal: (a, b) => a === b ||\n (!!a && !!b && a.$implicit === b.$implicit && sameItems(a.messages, b.messages)),\n });\n protected readonly interruptContext = computed(() => {\n if (!this.interruptTemplate())\n return undefined;\n const agent = this.agent();\n const interrupt = agent.interrupt?.();\n return interrupt ? { $implicit: agent, interrupt } : undefined;\n }, {\n equal: (a, b) => a === b || (!!a && !!b && a.$implicit === b.$implicit && a.interrupt === b.interrupt),\n });\n private readonly runActivityRegion = viewChild>('runActivityRegion');\n private readonly interruptRegion = viewChild>('interruptRegion');\n protected activityContextFor(message: Message, index: number): ChatActivityTemplateContext {\n const agent = this.agent();\n const excluded = new Set(this.excludedToolNames());\n const toolCalls = resolveMessageToolCalls(agent, message).filter((tc) => !excluded.has(tc.name));\n const byCallId = new Map();\n agent.subagents?.().forEach((sa) => byCallId.set(sa.toolCallId, sa));\n const subagents: Subagent[] = [];\n for (const tc of toolCalls) {\n const sa = byCallId.get(tc.id);\n if (sa)\n subagents.push(sa);\n }\n const prior = this.activityContexts.get(message.id);\n if (prior &&\n prior.$implicit === message &&\n prior.index === index &&\n prior.agent === agent &&\n sameItems(prior.toolCalls, toolCalls) &&\n sameItems(prior.subagents, subagents)) {\n return prior;\n }\n const context: ChatActivityTemplateContext = { $implicit: message, index, toolCalls, subagents, agent };\n this.activityContexts.set(message.id, context);\n return context;\n }\n protected humanContent(message: {\n content: unknown;\n }): string {\n const raw = messageContent(message);\n return a2uiActionLabel(raw) ?? raw;\n }\n private prevAssistant(msgs: Message[], index: number): Message | undefined {\n for (let j = index - 1; j >= 0; j--) {\n if (msgs[j].role === 'tool')\n continue;\n return msgs[j].role === 'assistant' ? msgs[j] : undefined;\n }\n return undefined;\n }\n protected reasoningRunStart(index: number): boolean {\n const msgs = this.agent().messages();\n if (!msgs[index]?.reasoning)\n return false;\n return !this.prevAssistant(msgs, index)?.reasoning;\n }\n protected reasoningRun(index: number): {\n content: string;\n durationMs: number | undefined;\n delivery: MessageDelivery;\n label: string | undefined;\n } {\n const msgs = this.agent().messages();\n const steps: Message[] = [];\n for (let j = index; j < msgs.length; j++) {\n const m = msgs[j];\n if (m.role === 'tool')\n continue;\n if (m.role === 'assistant' && m.reasoning) {\n steps.push(m);\n continue;\n }\n break;\n }\n const content = steps.map((step) => step.reasoning ?? '').filter(Boolean).join('\\n\\n');\n const durations = steps\n .map((step) => step.reasoningDurationMs)\n .filter((d): d is number => typeof d === 'number');\n const durationMs = durations.length ? durations.reduce((a, b) => a + b, 0) : undefined;\n const last = steps[steps.length - 1];\n const delivery = last?.delivery ?? msgs[index].delivery;\n const label = steps.length > 1\n ? durationMs !== undefined\n ? `Thought for ${formatDuration(durationMs)} · ${steps.length} steps`\n : `${steps.length} steps`\n : undefined;\n return { content, durationMs, delivery, label };\n }\n private readonly classifiers = new Map();\n private readonly markdownDocuments = new Map();\n private readonly destroyRef = inject(DestroyRef);\n private readonly injector = inject(Injector);\n private readonly lifecycle = (inject(CHAT_LIFECYCLE, { optional: true }) ?? createChatLifecycle()) as ChatLifecycleInternal;\n private eventsSubscribed = false;\n protected readonly liveSurfaceStore: A2uiSurfaceStore = createA2uiSurfaceStore();\n private readonly partialBridge: PartialArgsBridge = createPartialArgsBridge(this.liveSurfaceStore);\n private partialEventsLastIndex = 0;\n private readonly scrollContainer = viewChild>('scrollContainer');\n private readonly messageCount = computed(() => this.agent().messages().length);\n private prevMessageCount = 0;\n private wasLoading = false;\n protected readonly pinned = signal(true);\n private programmaticScrollCount = 0;\n private programmaticScrollTop = 0;\n private upwardWheel: {\n events: WheelEvent[];\n scrollTop: number;\n settled: boolean;\n supported: boolean;\n followDeferred: boolean;\n } | undefined;\n private static readonly PIN_TOLERANCE_PX = 150;\n protected readonly currentAssistantStreaming = computed(() => {\n const msgs = this.agent().messages();\n if (msgs.length === 0)\n return false;\n const last = msgs[msgs.length - 1];\n return last?.role === 'assistant' && last.delivery.phase === 'streaming';\n });\n constructor() {\n ensureChatRootStyles();\n effect(() => {\n if (this.eventsSubscribed)\n return;\n let agent: ReturnType;\n try {\n agent = this.agent();\n }\n catch {\n return;\n }\n this.eventsSubscribed = true;\n this.lifecycle._internal.componentReady.set(true);\n agent.events$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {\n if (event.type !== 'state_update')\n return;\n const store = this.resolvedStore();\n if (!store)\n return;\n store.update(event.data);\n });\n });\n effect(() => {\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n const stateFn = (agentRef as unknown as {\n state?: () => unknown;\n }).state;\n if (typeof stateFn !== 'function')\n return;\n const state = stateFn.call(agentRef);\n const store = this.resolvedStore();\n if (!store || state == null || typeof state !== 'object' || Array.isArray(state))\n return;\n const updates: Record = {};\n for (const [k, v] of Object.entries(state as Record)) {\n if (k === 'messages')\n continue;\n updates['/' + k] = v;\n }\n if (Object.keys(updates).length > 0)\n store.update(updates);\n });\n effect(() => {\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n const lc = (agentRef as unknown as {\n lifecycle?: {\n streamStartedAt?: () => number | null;\n };\n }).lifecycle;\n const streamStartedAt = lc?.streamStartedAt?.();\n if (streamStartedAt != null && !this.lifecycle._internal.firstMessageSent()) {\n this.lifecycle._internal.firstMessageSent.set(true);\n }\n });\n effect((onCleanup) => {\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const dispatchTasks = new Set>();\n const onWheel = (event: WheelEvent) => {\n if (event.deltaY >= 0 || event.ctrlKey || event.defaultPrevented)\n return;\n for (const target of event.composedPath()) {\n if (target === el)\n break;\n if (!(target instanceof HTMLElement) || target.scrollTop <= 0 || target.scrollHeight <= target.clientHeight)\n continue;\n if (/^(auto|scroll|overlay)$/.test(getComputedStyle(target).overflowY))\n return;\n }\n const previous = this.upwardWheel;\n if (previous && !previous.settled) {\n previous.events.push(event);\n return;\n }\n const intent = {\n events: [event], scrollTop: Math.max(this.programmaticScrollTop, el.scrollTop, previous?.scrollTop ?? 0),\n settled: false, supported: previous?.supported ?? false, followDeferred: previous?.followDeferred ?? false,\n };\n this.upwardWheel = intent;\n const task = setTimeout(() => {\n dispatchTasks.delete(task);\n if (this.upwardWheel !== intent || this.scrollContainer()?.nativeElement !== el)\n return;\n intent.settled = true;\n intent.supported ||= intent.events.some((wheel) => !wheel.defaultPrevented);\n intent.events = [];\n if (!intent.supported)\n this.upwardWheel = undefined;\n else if (el.scrollTop < intent.scrollTop)\n this.onScroll();\n const followDeferred = intent.followDeferred;\n intent.followDeferred = false;\n if (followDeferred)\n this.followIfPinned();\n }, 0);\n dispatchTasks.add(task);\n };\n el.addEventListener('wheel', onWheel, { passive: true });\n onCleanup(() => {\n el.removeEventListener('wheel', onWheel);\n for (const task of dispatchTasks)\n clearTimeout(task);\n dispatchTasks.clear();\n this.upwardWheel = undefined;\n });\n });\n effect(() => {\n let count: number;\n let msgs: ReturnType['messages']>;\n try {\n count = this.messageCount();\n msgs = this.agent().messages();\n }\n catch {\n return;\n }\n const lastContent = msgs.length > 0 ? (msgs[msgs.length - 1] as unknown as Record)['content'] : undefined;\n void lastContent;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const isNewMessage = count !== this.prevMessageCount;\n this.prevMessageCount = count;\n if (isNewMessage) {\n this.upwardWheel = undefined;\n untracked(() => this.pinned.set(true));\n }\n if (isNewMessage || this.pinned())\n this.followIfPinned();\n });\n effect(() => {\n let loading: boolean;\n try {\n loading = this.agent().isLoading();\n }\n catch {\n return;\n }\n if (loading) {\n this.wasLoading = true;\n return;\n }\n if (!this.wasLoading)\n return;\n this.wasLoading = false;\n if (this.pinned()) {\n requestAnimationFrame(() => this.followIfPinned());\n }\n });\n effect((onCleanup) => {\n const regions = [this.runActivityRegion()?.nativeElement, this.interruptRegion()?.nativeElement]\n .filter((el): el is HTMLElement => !!el);\n if (regions.length === 0)\n return;\n if (typeof ResizeObserver === 'undefined') {\n requestAnimationFrame(() => this.followIfPinned());\n return;\n }\n const observer = new ResizeObserver(() => this.followIfPinned());\n for (const el of regions)\n observer.observe(el);\n onCleanup(() => observer.disconnect());\n });\n effect(() => {\n let agent: ReturnType;\n try {\n agent = this.agent();\n }\n catch {\n return;\n }\n const customSig = (agent as unknown as {\n customEvents?: () => readonly {\n name: string;\n data: unknown;\n }[];\n }).customEvents;\n if (typeof customSig !== 'function')\n return;\n const events = customSig();\n for (let i = this.partialEventsLastIndex; i < events.length; i++) {\n const e = events[i];\n if (e.name !== 'a2ui-partial')\n continue;\n const d = e.data as {\n tool_call_id?: string;\n args_so_far?: string;\n } | null;\n if (!d || typeof d.tool_call_id !== 'string' || typeof d.args_so_far !== 'string')\n continue;\n this.partialBridge.push(d.tool_call_id, d.args_so_far);\n }\n this.partialEventsLastIndex = events.length;\n });\n let connected: unknown;\n effect(() => {\n const coord = this.coordinator();\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n if (!coord || !agentRef)\n return;\n if (connected === coord)\n return;\n connected = coord;\n queueMicrotask(() => {\n runInInjectionContext(this.injector, () => coord.connect(agentRef));\n });\n });\n effect(() => {\n let liveIds: Set;\n try {\n liveIds = new Set();\n for (const m of this.agent().messages()) {\n const id = (m as unknown as {\n id?: string;\n }).id;\n if (id)\n liveIds.add(id);\n }\n }\n catch {\n return;\n }\n for (const key of [...this.classifiers.keys()]) {\n if (!liveIds.has(key)) {\n this.classifiers.get(key)?.classifier.dispose();\n this.classifiers.delete(key);\n }\n }\n for (const key of [...this.markdownDocuments.keys()]) {\n if (!liveIds.has(key))\n this.markdownDocuments.delete(key);\n }\n for (const key of [...this.activityContexts.keys()]) {\n if (!liveIds.has(key))\n this.activityContexts.delete(key);\n }\n });\n }\n prevRole(index: number): ChatMessageRole | undefined {\n if (index === 0)\n return undefined;\n const prev = this.agent().messages()[index - 1];\n if (!prev)\n return undefined;\n const role = (prev as unknown as {\n role?: string;\n }).role;\n if (role === 'user')\n return 'user';\n if (role === 'assistant')\n return 'assistant';\n if (role === 'system')\n return 'system';\n if (role === 'tool')\n return 'tool';\n return undefined;\n }\n protected onScroll(event?: Event): void {\n const el = this.scrollContainer()?.nativeElement;\n if (!el || (event && event.target !== el))\n return;\n const intent = this.upwardWheel;\n if (intent && !intent.settled)\n return;\n const userScrolledUp = intent && intent.supported && el.scrollTop < intent.scrollTop;\n if (intent && el.scrollTop !== intent.scrollTop)\n this.upwardWheel = undefined;\n if (this.programmaticScrollCount > 0 && !userScrolledUp)\n return;\n const nextPinned = isPinned(el.scrollHeight, el.scrollTop, el.clientHeight, ChatComponent.PIN_TOLERANCE_PX);\n if (nextPinned !== this.pinned())\n this.pinned.set(nextPinned);\n }\n scrollToBottom(): void {\n this.upwardWheel = undefined;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n this.programmaticScrollCount++;\n el.scrollTop = el.scrollHeight;\n this.programmaticScrollTop = el.scrollTop;\n requestAnimationFrame(() => { this.programmaticScrollCount--; });\n this.pinned.set(true);\n }\n private followIfPinned(): void {\n if (!untracked(() => this.pinned()))\n return;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const intent = this.upwardWheel;\n if (intent && el.scrollTop < intent.scrollTop) {\n if (!intent.settled) {\n intent.followDeferred = true;\n return;\n }\n this.onScroll();\n if (!untracked(() => this.pinned()))\n return;\n }\n this.programmaticScrollCount++;\n el.scrollTop = el.scrollHeight;\n this.programmaticScrollTop = el.scrollTop;\n if (intent && this.upwardWheel === intent)\n intent.scrollTop = this.programmaticScrollTop;\n requestAnimationFrame(() => { this.programmaticScrollCount--; });\n }\n protected onScrollBubbleClick(): void {\n this.scrollToBottom();\n }\n protected onUserSubmitted(): void {\n this.upwardWheel = undefined;\n this.pinned.set(true);\n this.recordSubmit();\n }\n submitMessage(text: string): void {\n const trimmed = text.trim();\n if (!trimmed || this.agent().isInputBlocked?.())\n return;\n void this.agent().submit({ message: trimmed });\n this.recordSubmit();\n }\n clearThread(): void {\n this.clearClassifiers();\n this.lifecycle._internal.messageCount.set(0);\n this.lifecycle._internal.inputSubmittedAt.set(null);\n }\n private recordSubmit(): void {\n if (!this.lifecycle._internal.firstMessageSent()) {\n this.lifecycle._internal.firstMessageSent.set(true);\n }\n this.lifecycle._internal.messageCount.update((c) => c + 1);\n this.lifecycle._internal.inputSubmittedAt.set(Date.now());\n }\n protected prevMessage(index: number): unknown {\n if (index === 0)\n return undefined;\n return this.agent().messages()[index - 1];\n }\n protected isGenuiTurn(message: unknown, _prevMsg: unknown, index?: number): boolean {\n const names = new Set(this.genuiToolNames());\n const m = message as {\n extra?: Record;\n } | null | undefined;\n if (!m)\n return false;\n const calls = (m.extra?.['tool_calls'] as Array<{\n name?: string;\n }> | undefined) ?? [];\n if (calls.some(c => c.name != null && names.has(c.name)))\n return true;\n const rawContent = m.extra?.['content'];\n if (Array.isArray(rawContent)) {\n for (const block of rawContent) {\n if (block != null\n && typeof block === 'object'\n && (block as {\n type?: unknown;\n }).type === 'function_call'\n && typeof (block as {\n name?: unknown;\n }).name === 'string'\n && names.has((block as {\n name: string;\n }).name)) {\n return true;\n }\n }\n }\n const projectedContent = (m as {\n content?: unknown;\n }).content;\n if (typeof projectedContent === 'string' && projectedContent.length > 0) {\n if (projectedContent.includes('\"createSurface\"')\n || projectedContent.includes('\"updateComponents\"')\n || projectedContent.includes('\"updateDataModel\"')) {\n return true;\n }\n if (projectedContent.includes('\"root\"') && projectedContent.includes('\"elements\"')) {\n return true;\n }\n }\n const p = _prevMsg as {\n role?: string;\n name?: string;\n extra?: Record;\n } | null | undefined;\n if (p && p.role === 'tool') {\n const toolName = (p.extra?.['name'] as string | undefined) ?? p.name;\n if (typeof toolName === 'string' && names.has(toolName))\n return true;\n }\n if (typeof index === 'number' && index > 0) {\n const msgs = this.agent().messages();\n for (let i = index - 1; i >= 0; i--) {\n const prev = msgs[i] as {\n role?: string;\n extra?: Record;\n };\n if (!prev)\n break;\n if (prev.role === 'user')\n break;\n const prevCalls = (prev.extra?.['tool_calls'] as Array<{\n name?: string;\n }> | undefined) ?? [];\n if (prevCalls.some(c => c.name != null && names.has(c.name)))\n return true;\n const prevRaw = prev.extra?.['content'];\n if (Array.isArray(prevRaw)) {\n for (const block of prevRaw) {\n if (block != null\n && typeof block === 'object'\n && (block as {\n type?: unknown;\n }).type === 'function_call'\n && typeof (block as {\n name?: unknown;\n }).name === 'string'\n && names.has((block as {\n name: string;\n }).name)) {\n return true;\n }\n }\n }\n }\n }\n return false;\n }\n classifyMessage(content: string, message: Pick): ContentClassifier {\n const generation = message.delivery.generation;\n let entry = this.classifiers.get(message.id);\n if (!entry || entry.generation !== generation) {\n entry?.classifier.dispose();\n entry = { generation, classifier: createContentClassifier() };\n this.classifiers.set(message.id, entry);\n }\n if (entry.content !== content) {\n entry.classifier.update(content);\n entry.content = content;\n }\n return entry.classifier;\n }\n protected markdownDocumentFor(content: string, message: Pick): StreamingMarkdownDocument {\n const prior = this.markdownDocuments.get(message.id);\n const delivery = message.delivery;\n if (prior?.generation === delivery.generation &&\n prior.phase === delivery.phase &&\n prior.content === content) {\n return prior;\n }\n const document = markdownDocument(content, delivery);\n this.markdownDocuments.set(message.id, document);\n return document;\n }\n clearClassifiers(): void {\n for (const [, entry] of this.classifiers) {\n entry.classifier.dispose();\n }\n this.classifiers.clear();\n this.markdownDocuments.clear();\n this.activityContexts.clear();\n }\n onSpecEvent(event: RenderEvent, messageIndex: number): void {\n this.renderEvent.emit({ messageIndex, event });\n }\n protected onClientToolEvent(event: RenderEvent): void {\n const coord = this.coordinator();\n if (!coord)\n return;\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n coord.handleRenderEvent(agentRef, event);\n }\n onA2uiAction(message: A2uiActionMessage): void {\n if (this.agent().isInputBlocked?.())\n return;\n void this.agent().submit({ message: JSON.stringify(message) });\n }\n onA2uiEvent(event: RenderEvent, messageIndex: number, surfaceId: string): void {\n this.renderEvent.emit({ messageIndex, surfaceId, event });\n }\n onRegenerate(messageIndex: number): void {\n void this.agent().regenerate(messageIndex);\n this.regenerate.emit();\n }\n onRate(message: unknown, value: 'up' | 'down'): void {\n const idx = this.agent().messages().indexOf(message as never);\n this.rate.emit({ messageIndex: idx, rating: value });\n }\n onCopy(message: unknown, content: string): void {\n const idx = this.agent().messages().indexOf(message as never);\n this.messageCopy.emit({ messageIndex: idx, content });\n }\n}" + "signature": "@Component({\n selector: 'chat',\n standalone: true,\n imports: [\n KeyValuePipe, NgTemplateOutlet,\n ChatWindowComponent, ChatMessageListComponent, MessageTemplateDirective, ChatMessageComponent,\n ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent,\n ChatThreadListComponent, ChatGenerativeUiComponent,\n ChatStreamingMdComponent, ChatToolCallsComponent, ChatToolViewsComponent, A2uiSurfaceComponent,\n ChatMessageActionsComponent, ChatWelcomeComponent, ChatSelectComponent, ChatReasoningComponent,\n ChatScrollBubbleComponent,\n ],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n { provide: CHAT_LIFECYCLE, useFactory: createChatLifecycle },\n {\n provide: DEVELOPMENT_COLLECTION_POLICY,\n useFactory: () => {\n const host = inject(ChatComponent);\n const parent = inject(DEVELOPMENT_COLLECTION_POLICY, { optional: true, skipSelf: true });\n return () => (parent?.() ?? true) && isDevelopmentRuntimeEnabled(host.agent());\n },\n },\n ],\n styles: [CHAT_HOST_TOKENS, `\n :host {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n height: 100%;\n min-height: 0;\n max-height: 100%;\n overflow: hidden;\n background: var(--tplane-chat-bg);\n }\n :host > chat-welcome {\n display: flex;\n flex: 1 1 auto;\n width: 100%;\n }\n .chat-shell { display: flex; flex: 1; min-height: 0; overflow: hidden; }\n .chat-shell__sidebar {\n width: 240px;\n flex-shrink: 0;\n border-right: 1px solid var(--tplane-chat-separator);\n background: var(--tplane-chat-surface-alt);\n overflow-y: auto;\n display: none;\n }\n @media (min-width: 768px) { .chat-shell__sidebar { display: block; } }\n .chat-shell__main { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; }\n .chat-empty {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n padding: 60px 20px;\n color: var(--tplane-chat-text-muted);\n text-align: center;\n flex: 1;\n min-height: 0;\n }\n .chat-empty[hidden] { display: none; }\n .chat-empty__title { font-size: 1.125rem; font-weight: 500; color: var(--tplane-chat-text); margin: 0; }\n .chat-empty__sub { margin: 0; font-size: var(--tplane-chat-font-size-sm); }\n .chat-empty__title { font-size: 1.125rem; font-weight: 500; color: var(--tplane-chat-text); margin: 0; }\n .chat-empty__sub { margin: 0; font-size: var(--tplane-chat-font-size-sm); }\n .chat-scroll { flex: 1; min-height: 0; overflow-y: auto; padding-top: var(--tplane-chat-edge-pad); }\n .chat-scroll::-webkit-scrollbar { width: 6px; }\n .chat-scroll::-webkit-scrollbar-thumb { background: var(--tplane-chat-separator); border-radius: 10px; }\n [chatFooter] {\n padding-bottom: var(--tplane-chat-edge-pad);\n }\n .chat-footer-wrap { position: relative; }\n .chat-run-activity, .chat-interrupt-region { display: block; }\n .chat-scroll:focus-visible { outline: 2px solid var(--tplane-chat-text-muted); outline-offset: -2px; }\n .chat-stopped-notice {\n display: flex;\n align-items: center;\n gap: var(--tplane-chat-space-2);\n box-sizing: border-box;\n width: 100%;\n max-width: var(--tplane-chat-max-width);\n margin: 0 auto;\n /* Same column and inset as chat-message-list, so it aligns with replies. */\n padding: 0 var(--tplane-chat-space-6);\n color: var(--tplane-chat-text-muted);\n font-size: var(--tplane-chat-font-size-sm);\n }\n .chat-stopped-notice svg { width: 12px; height: 12px; flex: none; }\n .chat-sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n }\n `],\n template: `\n @if (showWelcome()) {\n \n \n @if (showModelPicker() && modelOptions().length > 0) {\n \n }\n \n \n \n \n \n } @else {\n
\n @if (threads().length > 0) {\n \n }\n
\n \n \n \n
\n \n \n {{ humanContent(message) }}\n \n\n \n @let content = messageContent(message);\n @let classified = classifyMessage(content, message);\n \n \n @if (activityTemplate(); as activity) {\n \n } @else {\n \n @if (message.reasoning && reasoningRunStart(i)) {\n @let run = reasoningRun(i);\n \n }\n \n \n }\n \n @if (classified.markdown(); as md) {\n \n }\n @if (classified.spec(); as spec) {\n \n \n }\n @if (classified.type() === 'a2ui' && views(); as catalog) {\n @for (entry of classified.a2uiSurfaces() | keyvalue; track entry.key) {\n \n }\n }\n \n @if (content.trim()) {\n \n }\n \n \n\n \n \n \n\n \n {{ messageContent(message) }}\n \n \n\n \n @if (runActivityContext(); as runContext) {\n
\n \n
\n } @else if (pinned() && !currentAssistantStreaming()) {\n \n \n }\n\n \n @if (interruptContext(); as pending) {\n
\n \n
\n }\n\n \n @if (showStoppedNotice()) {\n

\n \n Response stopped\n

\n }\n
\n \n
\n \n
{{ runAnnouncement() }}
\n
\n
\n }\n `,\n})\nexport class ChatComponent {\n readonly agent = input.required();\n readonly views = input(undefined);\n readonly clientTools = input(undefined);\n readonly store = input(undefined);\n readonly handlers = input) => unknown | Promise>>({});\n readonly threads = input([]);\n readonly activeThreadId = input('');\n readonly welcomeDisabled = input(false);\n readonly modelOptions = input([]);\n readonly showModelPicker = input(true);\n readonly selectedModel = model('');\n readonly modelPickerPlaceholder = input('Choose a model');\n readonly genuiToolNames = input([\n 'generate_a2ui_schema',\n 'generate_json_render_spec',\n 'render_spec',\n ]);\n readonly clientToolExecutionGuard = input(undefined);\n readonly clientToolContinuationPolicy = input(undefined);\n readonly showWelcome = computed(() => {\n if (this.welcomeDisabled())\n return false;\n const a = this.agent() as unknown as {\n isThreadLoading?: () => boolean;\n };\n if (a.isThreadLoading?.())\n return false;\n return this.agent().messages().length === 0;\n });\n readonly threadSelected = output();\n readonly renderEvent = output();\n readonly clientToolContinuationLimit = output();\n readonly regenerate = output();\n readonly rate = output<{\n messageIndex: number;\n rating: 'up' | 'down';\n }>();\n readonly messageCopy = output<{\n messageIndex: number;\n content: string;\n }>();\n private readonly _internalStore = signalStateStore({});\n readonly resolvedStore = computed(() => {\n const explicit = this.store();\n if (explicit)\n return explicit;\n if (this.effectiveViews())\n return this._internalStore;\n return undefined;\n });\n private readonly coordinator = computed(() => {\n const reg = this.clientTools();\n const policy = this.clientToolContinuationPolicy();\n return reg ? createClientToolsCoordinator(reg, {\n executionGuard: this.clientToolExecutionGuard(),\n continuationPolicy: {\n ...policy,\n onLimit: (event) => {\n policy?.onLimit?.(event);\n this.clientToolContinuationLimit.emit(event);\n },\n },\n }) : undefined;\n });\n protected readonly effectiveViews = computed(() => {\n const base = this.views();\n const coord = this.coordinator();\n if (!coord)\n return base;\n return base ? withViews(base, coord.viewRegistry) : coord.viewRegistry;\n });\n readonly renderRegistry = computed(() => {\n const v = this.views();\n return v ? toRenderRegistry(v) : undefined;\n });\n readonly viewToolNames = computed(() => Object.keys(this.effectiveViews() ?? {}));\n readonly excludedToolNames = computed(() => [\n ...this.genuiToolNames(),\n ...this.viewToolNames(),\n ]);\n readonly messageContent = messageContent;\n readonly activityTemplate = contentChild(ChatActivityTemplateDirective);\n readonly toolCallTemplates = contentChildren(ChatToolCallTemplateDirective);\n private readonly activityContexts = new Map();\n readonly runActivityTemplate = contentChild(ChatRunActivityTemplateDirective);\n readonly interruptTemplate = contentChild(ChatInterruptTemplateDirective);\n private readonly currentRunMessages = computed(() => {\n const msgs = this.agent().messages();\n let start = msgs.length;\n while (start > 0 && msgs[start - 1].role !== 'user')\n start--;\n return msgs.slice(start);\n }, { equal: sameItems });\n protected readonly runActivityContext = computed(() => {\n if (!this.runActivityTemplate())\n return undefined;\n const agent = this.agent();\n if (!agent.isLoading())\n return undefined;\n const messages = this.currentRunMessages();\n if (messages.some((m) => m.role === 'assistant'))\n return undefined;\n return { $implicit: agent, running: true, hasAssistantMessage: false, messages };\n }, {\n equal: (a, b) => a === b ||\n (!!a && !!b && a.$implicit === b.$implicit && sameItems(a.messages, b.messages)),\n });\n protected readonly interruptContext = computed(() => {\n if (!this.interruptTemplate())\n return undefined;\n const agent = this.agent();\n const interrupt = agent.interrupt?.();\n return interrupt ? { $implicit: agent, interrupt } : undefined;\n }, {\n equal: (a, b) => a === b || (!!a && !!b && a.$implicit === b.$implicit && a.interrupt === b.interrupt),\n });\n private readonly runActivityRegion = viewChild>('runActivityRegion');\n private readonly interruptRegion = viewChild>('interruptRegion');\n protected activityContextFor(message: Message, index: number): ChatActivityTemplateContext {\n const agent = this.agent();\n const excluded = new Set(this.excludedToolNames());\n const toolCalls = resolveMessageToolCalls(agent, message).filter((tc) => !excluded.has(tc.name));\n const byCallId = new Map();\n agent.subagents?.().forEach((sa) => byCallId.set(sa.toolCallId, sa));\n const subagents: Subagent[] = [];\n for (const tc of toolCalls) {\n const sa = byCallId.get(tc.id);\n if (sa)\n subagents.push(sa);\n }\n const prior = this.activityContexts.get(message.id);\n if (prior &&\n prior.$implicit === message &&\n prior.index === index &&\n prior.agent === agent &&\n sameItems(prior.toolCalls, toolCalls) &&\n sameItems(prior.subagents, subagents)) {\n return prior;\n }\n const context: ChatActivityTemplateContext = { $implicit: message, index, toolCalls, subagents, agent };\n this.activityContexts.set(message.id, context);\n return context;\n }\n protected humanContent(message: {\n content: unknown;\n }): string {\n const raw = messageContent(message);\n return a2uiActionLabel(raw) ?? raw;\n }\n private prevAssistant(msgs: Message[], index: number): Message | undefined {\n for (let j = index - 1; j >= 0; j--) {\n if (msgs[j].role === 'tool')\n continue;\n return msgs[j].role === 'assistant' ? msgs[j] : undefined;\n }\n return undefined;\n }\n protected reasoningRunStart(index: number): boolean {\n const msgs = this.agent().messages();\n if (!msgs[index]?.reasoning)\n return false;\n return !this.prevAssistant(msgs, index)?.reasoning;\n }\n protected reasoningRun(index: number): {\n content: string;\n durationMs: number | undefined;\n delivery: MessageDelivery;\n label: string | undefined;\n } {\n const msgs = this.agent().messages();\n const steps: Message[] = [];\n for (let j = index; j < msgs.length; j++) {\n const m = msgs[j];\n if (m.role === 'tool')\n continue;\n if (m.role === 'assistant' && m.reasoning) {\n steps.push(m);\n continue;\n }\n break;\n }\n const content = steps.map((step) => step.reasoning ?? '').filter(Boolean).join('\\n\\n');\n const durations = steps\n .map((step) => step.reasoningDurationMs)\n .filter((d): d is number => typeof d === 'number');\n const durationMs = durations.length ? durations.reduce((a, b) => a + b, 0) : undefined;\n const last = steps[steps.length - 1];\n const delivery = last?.delivery ?? msgs[index].delivery;\n const label = steps.length > 1\n ? durationMs !== undefined\n ? `Thought for ${formatDuration(durationMs)} · ${steps.length} steps`\n : `${steps.length} steps`\n : undefined;\n return { content, durationMs, delivery, label };\n }\n private readonly classifiers = new Map();\n private readonly markdownDocuments = new Map();\n private readonly destroyRef = inject(DestroyRef);\n private readonly injector = inject(Injector);\n private readonly composerDocument = inject(DOCUMENT);\n private readonly lifecycle = (inject(CHAT_LIFECYCLE, { optional: true }) ?? createChatLifecycle()) as ChatLifecycleInternal;\n private eventsSubscribed = false;\n protected readonly liveSurfaceStore: A2uiSurfaceStore = createA2uiSurfaceStore();\n private readonly partialBridge: PartialArgsBridge = createPartialArgsBridge(this.liveSurfaceStore);\n private partialEventsLastIndex = 0;\n private readonly scrollContainer = viewChild>('scrollContainer');\n private readonly composer = viewChild('composer');\n protected welcomeHadFocus = false;\n protected errorHadFocus = false;\n private readonly stoppedTurn = signal<{\n userCount: number;\n prompt: string;\n } | null>(null);\n protected readonly showStoppedNotice = computed(() => {\n const agent = this.agent();\n if (agent.isLoading())\n return false;\n const msgs = agent.messages();\n const last = msgs[msgs.length - 1];\n const delivery = last?.role === 'assistant' ? last.delivery : undefined;\n if (delivery?.phase === 'complete' && delivery.outcome === 'aborted')\n return true;\n const stopped = this.stoppedTurn();\n if (!stopped || (delivery?.phase === 'complete' && delivery.outcome === 'success'))\n return false;\n const users = msgs.filter((m) => m.role === 'user');\n const prompt = users[users.length - 1];\n return users.length === stopped.userCount && !!prompt && messageContent(prompt) === stopped.prompt;\n });\n protected readonly runAnnouncement = signal('');\n private readonly messageCount = computed(() => this.agent().messages().length);\n private prevMessageCount = 0;\n private wasLoading = false;\n protected readonly pinned = signal(true);\n private programmaticScrollCount = 0;\n private programmaticScrollTop = 0;\n private upwardWheel: {\n events: WheelEvent[];\n scrollTop: number;\n settled: boolean;\n supported: boolean;\n followDeferred: boolean;\n } | undefined;\n private static readonly PIN_TOLERANCE_PX = 150;\n protected readonly currentAssistantStreaming = computed(() => {\n const msgs = this.agent().messages();\n if (msgs.length === 0)\n return false;\n const last = msgs[msgs.length - 1];\n return last?.role === 'assistant' && last.delivery.phase === 'streaming';\n });\n constructor() {\n ensureChatRootStyles();\n effect(() => {\n if (this.eventsSubscribed)\n return;\n let agent: ReturnType;\n try {\n agent = this.agent();\n }\n catch {\n return;\n }\n this.eventsSubscribed = true;\n this.lifecycle._internal.componentReady.set(true);\n agent.events$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {\n if (event.type !== 'state_update')\n return;\n const store = this.resolvedStore();\n if (!store)\n return;\n store.update(event.data);\n });\n });\n effect(() => {\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n const stateFn = (agentRef as unknown as {\n state?: () => unknown;\n }).state;\n if (typeof stateFn !== 'function')\n return;\n const state = stateFn.call(agentRef);\n const store = this.resolvedStore();\n if (!store || state == null || typeof state !== 'object' || Array.isArray(state))\n return;\n const updates: Record = {};\n for (const [k, v] of Object.entries(state as Record)) {\n if (k === 'messages')\n continue;\n updates['/' + k] = v;\n }\n if (Object.keys(updates).length > 0)\n store.update(updates);\n });\n effect(() => {\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n const lc = (agentRef as unknown as {\n lifecycle?: {\n streamStartedAt?: () => number | null;\n };\n }).lifecycle;\n const streamStartedAt = lc?.streamStartedAt?.();\n if (streamStartedAt != null && !this.lifecycle._internal.firstMessageSent()) {\n this.lifecycle._internal.firstMessageSent.set(true);\n }\n });\n effect(() => {\n if (!this.composer() || !this.welcomeHadFocus)\n return;\n this.welcomeHadFocus = false;\n this.continueInComposer();\n });\n effect(() => {\n let error: unknown;\n try {\n error = this.agent().error();\n }\n catch {\n return;\n }\n if (error || !this.errorHadFocus)\n return;\n this.errorHadFocus = false;\n this.continueInComposer();\n });\n let runActive = false;\n effect(() => {\n let loading: boolean;\n try {\n loading = this.agent().isLoading();\n }\n catch {\n return;\n }\n if (loading) {\n const startsRun = !runActive;\n runActive = true;\n untracked(() => {\n this.stoppedTurn.set(null);\n this.runAnnouncement.set('');\n const msgs = this.agent().messages();\n if (startsRun && msgs[msgs.length - 1]?.role === 'user' && !this.pinned()) {\n this.upwardWheel = undefined;\n this.pinned.set(true);\n this.followIfPinned();\n }\n });\n return;\n }\n if (!runActive)\n return;\n runActive = false;\n untracked(() => {\n if (this.agent().error() || this.agent().interrupt?.())\n return;\n this.runAnnouncement.set(this.showStoppedNotice() ? 'Response stopped.' : 'Response complete.');\n });\n });\n effect((onCleanup) => {\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const dispatchTasks = new Set>();\n const onWheel = (event: WheelEvent) => {\n if (event.deltaY >= 0 || event.ctrlKey || event.defaultPrevented)\n return;\n for (const target of event.composedPath()) {\n if (target === el)\n break;\n if (!(target instanceof HTMLElement) || target.scrollTop <= 0 || target.scrollHeight <= target.clientHeight)\n continue;\n if (/^(auto|scroll|overlay)$/.test(getComputedStyle(target).overflowY))\n return;\n }\n const previous = this.upwardWheel;\n if (previous && !previous.settled) {\n previous.events.push(event);\n return;\n }\n const intent = {\n events: [event], scrollTop: Math.max(this.programmaticScrollTop, el.scrollTop, previous?.scrollTop ?? 0),\n settled: false, supported: previous?.supported ?? false, followDeferred: previous?.followDeferred ?? false,\n };\n this.upwardWheel = intent;\n const task = setTimeout(() => {\n dispatchTasks.delete(task);\n if (this.upwardWheel !== intent || this.scrollContainer()?.nativeElement !== el)\n return;\n intent.settled = true;\n intent.supported ||= intent.events.some((wheel) => !wheel.defaultPrevented);\n intent.events = [];\n if (!intent.supported)\n this.upwardWheel = undefined;\n else if (el.scrollTop < intent.scrollTop)\n this.onScroll();\n const followDeferred = intent.followDeferred;\n intent.followDeferred = false;\n if (followDeferred)\n this.followIfPinned();\n }, 0);\n dispatchTasks.add(task);\n };\n el.addEventListener('wheel', onWheel, { passive: true });\n onCleanup(() => {\n el.removeEventListener('wheel', onWheel);\n for (const task of dispatchTasks)\n clearTimeout(task);\n dispatchTasks.clear();\n this.upwardWheel = undefined;\n });\n });\n effect(() => {\n let count: number;\n let msgs: ReturnType['messages']>;\n try {\n count = this.messageCount();\n msgs = this.agent().messages();\n }\n catch {\n return;\n }\n const lastContent = msgs.length > 0 ? (msgs[msgs.length - 1] as unknown as Record)['content'] : undefined;\n void lastContent;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const previousCount = this.prevMessageCount;\n const isNewMessage = count !== previousCount;\n this.prevMessageCount = count;\n const replaced = previousCount === 0 || count < previousCount;\n if (isNewMessage && (replaced || msgs[msgs.length - 1]?.role === 'user')) {\n this.upwardWheel = undefined;\n untracked(() => this.pinned.set(true));\n }\n if (isNewMessage || this.pinned())\n this.followIfPinned();\n });\n effect(() => {\n let loading: boolean;\n try {\n loading = this.agent().isLoading();\n }\n catch {\n return;\n }\n if (loading) {\n this.wasLoading = true;\n return;\n }\n if (!this.wasLoading)\n return;\n this.wasLoading = false;\n if (this.pinned()) {\n requestAnimationFrame(() => this.followIfPinned());\n }\n });\n effect((onCleanup) => {\n const regions = [this.runActivityRegion()?.nativeElement, this.interruptRegion()?.nativeElement]\n .filter((el): el is HTMLElement => !!el);\n if (regions.length === 0)\n return;\n if (typeof ResizeObserver === 'undefined') {\n requestAnimationFrame(() => this.followIfPinned());\n return;\n }\n const observer = new ResizeObserver(() => this.followIfPinned());\n for (const el of regions)\n observer.observe(el);\n onCleanup(() => observer.disconnect());\n });\n effect(() => {\n let agent: ReturnType;\n try {\n agent = this.agent();\n }\n catch {\n return;\n }\n const customSig = (agent as unknown as {\n customEvents?: () => readonly {\n name: string;\n data: unknown;\n }[];\n }).customEvents;\n if (typeof customSig !== 'function')\n return;\n const events = customSig();\n for (let i = this.partialEventsLastIndex; i < events.length; i++) {\n const e = events[i];\n if (e.name !== 'a2ui-partial')\n continue;\n const d = e.data as {\n tool_call_id?: string;\n args_so_far?: string;\n } | null;\n if (!d || typeof d.tool_call_id !== 'string' || typeof d.args_so_far !== 'string')\n continue;\n this.partialBridge.push(d.tool_call_id, d.args_so_far);\n }\n this.partialEventsLastIndex = events.length;\n });\n let connected: unknown;\n effect(() => {\n const coord = this.coordinator();\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n if (!coord || !agentRef)\n return;\n if (connected === coord)\n return;\n connected = coord;\n queueMicrotask(() => {\n runInInjectionContext(this.injector, () => coord.connect(agentRef));\n });\n });\n effect(() => {\n let liveIds: Set;\n try {\n liveIds = new Set();\n for (const m of this.agent().messages()) {\n const id = (m as unknown as {\n id?: string;\n }).id;\n if (id)\n liveIds.add(id);\n }\n }\n catch {\n return;\n }\n for (const key of [...this.classifiers.keys()]) {\n if (!liveIds.has(key)) {\n this.classifiers.get(key)?.classifier.dispose();\n this.classifiers.delete(key);\n }\n }\n for (const key of [...this.markdownDocuments.keys()]) {\n if (!liveIds.has(key))\n this.markdownDocuments.delete(key);\n }\n for (const key of [...this.activityContexts.keys()]) {\n if (!liveIds.has(key))\n this.activityContexts.delete(key);\n }\n });\n }\n prevRole(index: number): ChatMessageRole | undefined {\n if (index === 0)\n return undefined;\n const prev = this.agent().messages()[index - 1];\n if (!prev)\n return undefined;\n const role = (prev as unknown as {\n role?: string;\n }).role;\n if (role === 'user')\n return 'user';\n if (role === 'assistant')\n return 'assistant';\n if (role === 'system')\n return 'system';\n if (role === 'tool')\n return 'tool';\n return undefined;\n }\n protected onScroll(event?: Event): void {\n const el = this.scrollContainer()?.nativeElement;\n if (!el || (event && event.target !== el))\n return;\n const intent = this.upwardWheel;\n if (intent && !intent.settled)\n return;\n const userScrolledUp = intent && intent.supported && el.scrollTop < intent.scrollTop;\n if (intent && el.scrollTop !== intent.scrollTop)\n this.upwardWheel = undefined;\n if (this.programmaticScrollCount > 0 && !userScrolledUp)\n return;\n const nextPinned = isPinned(el.scrollHeight, el.scrollTop, el.clientHeight, ChatComponent.PIN_TOLERANCE_PX);\n if (nextPinned !== this.pinned())\n this.pinned.set(nextPinned);\n }\n scrollToBottom(): void {\n this.upwardWheel = undefined;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n this.programmaticScrollCount++;\n el.scrollTop = el.scrollHeight;\n this.programmaticScrollTop = el.scrollTop;\n requestAnimationFrame(() => { this.programmaticScrollCount--; });\n this.pinned.set(true);\n }\n private followIfPinned(): void {\n if (!untracked(() => this.pinned()))\n return;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const intent = this.upwardWheel;\n if (intent && el.scrollTop < intent.scrollTop) {\n if (!intent.settled) {\n intent.followDeferred = true;\n return;\n }\n this.onScroll();\n if (!untracked(() => this.pinned()))\n return;\n }\n this.programmaticScrollCount++;\n el.scrollTop = el.scrollHeight;\n this.programmaticScrollTop = el.scrollTop;\n if (intent && this.upwardWheel === intent)\n intent.scrollTop = this.programmaticScrollTop;\n requestAnimationFrame(() => { this.programmaticScrollCount--; });\n }\n protected onUserStopped(): void {\n if (typeof (this.agent() as {\n stop?: unknown;\n }).stop !== 'function')\n return;\n const users = this.agent().messages().filter((m) => m.role === 'user');\n const prompt = users[users.length - 1];\n this.stoppedTurn.set(prompt ? { userCount: users.length, prompt: messageContent(prompt) } : null);\n }\n protected onWelcomeFocusOut(event: FocusEvent): void {\n onFocusMovedAway(event, () => { this.welcomeHadFocus = false; });\n }\n protected onErrorFocusOut(event: FocusEvent): void {\n onFocusMovedAway(event, () => { this.errorHadFocus = false; });\n }\n protected isKeyboardFocus(event: FocusEvent): boolean {\n return matchesFocusVisible(event.target);\n }\n private continueInComposer(): void {\n requestAnimationFrame(() => {\n const active = this.composerDocument.activeElement;\n if (!active || active === this.composerDocument.body)\n this.composer()?.focusTextarea();\n });\n }\n protected onScrollBubbleClick(): void {\n const active = this.composerDocument.activeElement;\n const fromKeyboard = !!active?.closest('chat-scroll-bubble') && matchesFocusVisible(active);\n this.scrollToBottom();\n if (fromKeyboard)\n this.continueInComposer();\n }\n protected onUserSubmitted(): void {\n this.upwardWheel = undefined;\n this.pinned.set(true);\n this.recordSubmit();\n }\n submitMessage(text: string): void {\n const trimmed = text.trim();\n if (!trimmed || this.agent().isInputBlocked?.())\n return;\n void this.agent().submit({ message: trimmed });\n this.recordSubmit();\n }\n clearThread(): void {\n this.clearClassifiers();\n this.lifecycle._internal.messageCount.set(0);\n this.lifecycle._internal.inputSubmittedAt.set(null);\n }\n private recordSubmit(): void {\n if (!this.lifecycle._internal.firstMessageSent()) {\n this.lifecycle._internal.firstMessageSent.set(true);\n }\n this.lifecycle._internal.messageCount.update((c) => c + 1);\n this.lifecycle._internal.inputSubmittedAt.set(Date.now());\n }\n protected prevMessage(index: number): unknown {\n if (index === 0)\n return undefined;\n return this.agent().messages()[index - 1];\n }\n protected isGenuiTurn(message: unknown, _prevMsg: unknown, index?: number): boolean {\n const names = new Set(this.genuiToolNames());\n const m = message as {\n extra?: Record;\n } | null | undefined;\n if (!m)\n return false;\n const calls = (m.extra?.['tool_calls'] as Array<{\n name?: string;\n }> | undefined) ?? [];\n if (calls.some(c => c.name != null && names.has(c.name)))\n return true;\n const rawContent = m.extra?.['content'];\n if (Array.isArray(rawContent)) {\n for (const block of rawContent) {\n if (block != null\n && typeof block === 'object'\n && (block as {\n type?: unknown;\n }).type === 'function_call'\n && typeof (block as {\n name?: unknown;\n }).name === 'string'\n && names.has((block as {\n name: string;\n }).name)) {\n return true;\n }\n }\n }\n const projectedContent = (m as {\n content?: unknown;\n }).content;\n if (typeof projectedContent === 'string' && projectedContent.length > 0) {\n if (projectedContent.includes('\"createSurface\"')\n || projectedContent.includes('\"updateComponents\"')\n || projectedContent.includes('\"updateDataModel\"')) {\n return true;\n }\n if (projectedContent.includes('\"root\"') && projectedContent.includes('\"elements\"')) {\n return true;\n }\n }\n const p = _prevMsg as {\n role?: string;\n name?: string;\n extra?: Record;\n } | null | undefined;\n if (p && p.role === 'tool') {\n const toolName = (p.extra?.['name'] as string | undefined) ?? p.name;\n if (typeof toolName === 'string' && names.has(toolName))\n return true;\n }\n if (typeof index === 'number' && index > 0) {\n const msgs = this.agent().messages();\n for (let i = index - 1; i >= 0; i--) {\n const prev = msgs[i] as {\n role?: string;\n extra?: Record;\n };\n if (!prev)\n break;\n if (prev.role === 'user')\n break;\n const prevCalls = (prev.extra?.['tool_calls'] as Array<{\n name?: string;\n }> | undefined) ?? [];\n if (prevCalls.some(c => c.name != null && names.has(c.name)))\n return true;\n const prevRaw = prev.extra?.['content'];\n if (Array.isArray(prevRaw)) {\n for (const block of prevRaw) {\n if (block != null\n && typeof block === 'object'\n && (block as {\n type?: unknown;\n }).type === 'function_call'\n && typeof (block as {\n name?: unknown;\n }).name === 'string'\n && names.has((block as {\n name: string;\n }).name)) {\n return true;\n }\n }\n }\n }\n }\n return false;\n }\n classifyMessage(content: string, message: Pick): ContentClassifier {\n const generation = message.delivery.generation;\n let entry = this.classifiers.get(message.id);\n if (!entry || entry.generation !== generation) {\n entry?.classifier.dispose();\n entry = { generation, classifier: createContentClassifier() };\n this.classifiers.set(message.id, entry);\n }\n if (entry.content !== content) {\n entry.classifier.update(content);\n entry.content = content;\n }\n return entry.classifier;\n }\n protected markdownDocumentFor(content: string, message: Pick): StreamingMarkdownDocument {\n const prior = this.markdownDocuments.get(message.id);\n const delivery = message.delivery;\n if (prior?.generation === delivery.generation &&\n prior.phase === delivery.phase &&\n prior.content === content) {\n return prior;\n }\n const document = markdownDocument(content, delivery);\n this.markdownDocuments.set(message.id, document);\n return document;\n }\n clearClassifiers(): void {\n for (const [, entry] of this.classifiers) {\n entry.classifier.dispose();\n }\n this.classifiers.clear();\n this.markdownDocuments.clear();\n this.activityContexts.clear();\n }\n onSpecEvent(event: RenderEvent, messageIndex: number): void {\n this.renderEvent.emit({ messageIndex, event });\n }\n protected onClientToolEvent(event: RenderEvent): void {\n const coord = this.coordinator();\n if (!coord)\n return;\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n coord.handleRenderEvent(agentRef, event);\n }\n onA2uiAction(message: A2uiActionMessage): void {\n if (this.agent().isInputBlocked?.())\n return;\n void this.agent().submit({ message: JSON.stringify(message) });\n }\n onA2uiEvent(event: RenderEvent, messageIndex: number, surfaceId: string): void {\n this.renderEvent.emit({ messageIndex, surfaceId, event });\n }\n onRegenerate(messageIndex: number): void {\n this.upwardWheel = undefined;\n this.pinned.set(true);\n void this.agent().regenerate(messageIndex);\n this.regenerate.emit();\n }\n onRate(message: unknown, value: 'up' | 'down'): void {\n const idx = this.agent().messages().indexOf(message as never);\n this.rate.emit({ messageIndex: idx, rating: value });\n }\n onCopy(message: unknown, content: string): void {\n const idx = this.agent().messages().indexOf(message as never);\n this.messageCopy.emit({ messageIndex: idx, content });\n }\n}" }, { "id": "component:libs/chat/src/lib/markdown/markdown-children.component.ts#MarkdownChildrenComponent", @@ -1855,7 +1851,7 @@ "decorators": [ "Component" ], - "signature": "@Component({\n selector: 'chat-input',\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_INPUT_STYLES],\n template: `\n
\n \n \n
\n \n \n
\n \n \n @if (isLoading() && canStop()) {\n \n \n \n \n \n } @else {\n \n \n \n \n \n \n }\n
\n
\n \n
\n `,\n})\nexport class ChatInputComponent {\n readonly agent = input.required();\n readonly submitOnEnter = input(true);\n readonly placeholder = input('');\n readonly showStopButton = input(true);\n readonly submitted = output();\n readonly stopped = output();\n readonly messageText = signal('');\n readonly isLoading = computed(() => this.agent().isLoading());\n protected readonly composing = signal(false);\n readonly focused = signal(false);\n readonly canSubmit = computed(() => {\n if (this.isLoading() || this.agent().isInputBlocked?.())\n return false;\n return this.messageText().trim().length > 0;\n });\n readonly canStop = computed(() => this.showStopButton());\n private readonly textareaEl = viewChild>('textareaEl');\n constructor() {\n effect(() => {\n const text = this.messageText();\n const el = this.textareaEl()?.nativeElement;\n if (!el)\n return;\n const viewportH = typeof window === 'undefined' ? 600 : window.innerHeight;\n const cap = Math.min(viewportH * 0.4, 320);\n el.style.height = 'auto';\n const next = Math.min(el.scrollHeight, cap);\n el.style.height = `${next}px`;\n el.style.overflowY = el.scrollHeight > cap ? 'auto' : 'hidden';\n void text;\n });\n }\n focusTextarea(): void {\n this.textareaEl()?.nativeElement.focus();\n }\n onSubmit(): void {\n const submitted = submitMessage(this.agent(), this.messageText());\n if (submitted !== null) {\n this.submitted.emit(submitted);\n this.messageText.set('');\n const el = this.textareaEl()?.nativeElement;\n if (el)\n el.value = '';\n requestAnimationFrame(() => this.textareaEl()?.nativeElement.focus());\n }\n }\n protected onInput(event: Event): void {\n this.messageText.set((event.target as HTMLTextAreaElement).value);\n }\n onStop(): void {\n const a = this.agent() as unknown as {\n stop?: () => void | Promise;\n };\n if (typeof a.stop === 'function') {\n void a.stop();\n }\n this.stopped.emit();\n }\n onKeydown(event: KeyboardEvent): void {\n if (!this.submitOnEnter() || event.shiftKey)\n return;\n if (this.composing() || event.isComposing || event.keyCode === 229)\n return;\n event.preventDefault();\n this.onSubmit();\n }\n}" + "signature": "@Component({\n selector: 'chat-input',\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_INPUT_STYLES],\n template: `\n
\n \n \n
\n \n \n
\n \n \n @if (isLoading() && canStop()) {\n \n \n \n \n \n } @else {\n \n \n \n \n \n \n }\n
\n
\n \n
\n @if (showRunningHint()) {\n A response is still running. Stop it to send now, or send when it finishes.\n }\n
\n \n
\n `,\n})\nexport class ChatInputComponent {\n readonly agent = input.required();\n readonly submitOnEnter = input(true);\n readonly placeholder = input('');\n readonly showStopButton = input(true);\n readonly submitted = output();\n readonly stopped = output();\n readonly messageText = signal('');\n readonly isLoading = computed(() => this.agent().isLoading());\n protected readonly composing = signal(false);\n readonly focused = signal(false);\n readonly canSubmit = computed(() => {\n if (this.isLoading() || this.agent().isInputBlocked?.())\n return false;\n return this.messageText().trim().length > 0;\n });\n readonly canStop = computed(() => this.showStopButton());\n private readonly textareaEl = viewChild>('textareaEl');\n protected stopHadFocus = false;\n private readonly enterBlockedByRun = signal(false);\n protected readonly showRunningHint = computed(() => this.enterBlockedByRun() && this.isLoading());\n constructor() {\n effect(() => {\n const text = this.messageText();\n const el = this.textareaEl()?.nativeElement;\n if (!el)\n return;\n const viewportH = typeof window === 'undefined' ? 600 : window.innerHeight;\n const cap = Math.min(viewportH * 0.4, 320);\n el.style.height = 'auto';\n const next = Math.min(el.scrollHeight, cap);\n el.style.height = `${next}px`;\n el.style.overflowY = el.scrollHeight > cap ? 'auto' : 'hidden';\n void text;\n });\n effect(() => {\n if (!this.isLoading())\n this.enterBlockedByRun.set(false);\n });\n effect(() => {\n if (this.isLoading() || !this.stopHadFocus)\n return;\n this.stopHadFocus = false;\n requestAnimationFrame(() => {\n const active = this.textareaEl()?.nativeElement.ownerDocument.activeElement;\n if (!active || active === active.ownerDocument.body)\n this.focusTextarea();\n });\n });\n }\n focusTextarea(): void {\n this.textareaEl()?.nativeElement.focus();\n }\n onSubmit(): void {\n if (!this.canSubmit()) {\n if (this.isLoading() && this.messageText().trim())\n this.enterBlockedByRun.set(true);\n return;\n }\n const submitted = submitMessage(this.agent(), this.messageText());\n if (submitted !== null) {\n this.submitted.emit(submitted);\n this.messageText.set('');\n const el = this.textareaEl()?.nativeElement;\n if (el)\n el.value = '';\n requestAnimationFrame(() => this.textareaEl()?.nativeElement.focus());\n }\n }\n protected onInput(event: Event): void {\n this.messageText.set((event.target as HTMLTextAreaElement).value);\n }\n protected onStopFocus(event: FocusEvent): void {\n try {\n this.stopHadFocus = (event.target as Element).matches(':focus-visible');\n }\n catch {\n this.stopHadFocus = false;\n }\n }\n protected onStopBlur(event: FocusEvent): void {\n if (event.relatedTarget) {\n this.stopHadFocus = false;\n return;\n }\n const button = event.target as Node;\n setTimeout(() => { if (button.isConnected)\n this.stopHadFocus = false; }, 0);\n }\n onStop(): void {\n const a = this.agent() as unknown as {\n stop?: () => void | Promise;\n };\n if (typeof a.stop === 'function') {\n void a.stop();\n }\n this.stopped.emit();\n }\n onKeydown(event: KeyboardEvent): void {\n if (!this.submitOnEnter() || event.shiftKey)\n return;\n if (this.composing() || event.isComposing || event.keyCode === 229)\n return;\n event.preventDefault();\n this.onSubmit();\n }\n}" }, { "id": "component:libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts#ChatInterruptComponent", @@ -1915,7 +1911,7 @@ "decorators": [ "Component" ], - "signature": "@Component({\n selector: 'chat-message',\n standalone: true,\n imports: [ChatCitationsComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_STYLES],\n providers: [CitationsResolverService],\n host: {\n '[attr.data-role]': 'role()',\n '[attr.data-current]': 'currentStr()',\n '[attr.data-streaming]': 'streamingStr()',\n '[attr.data-prev-role]': 'prevRole() ?? null',\n },\n template: `\n
\n \n \n
\n @if (message()?.role === 'assistant' && message(); as msg) {\n \n }\n
\n \n
\n `,\n})\nexport class ChatMessageComponent {\n readonly role = input.required();\n readonly current = input(false);\n readonly streaming = input(false);\n readonly prevRole = input(undefined);\n readonly message = input(undefined);\n private readonly resolver = inject(CitationsResolverService);\n constructor() {\n effect(() => {\n this.resolver.message.set(this.message() ?? null);\n });\n }\n readonly currentStr = computed(() => String(this.current()));\n readonly streamingStr = computed(() => String(this.streaming()));\n readonly bodyClass = computed(() => {\n switch (this.role()) {\n case 'user': return 'chat-message__bubble';\n case 'assistant': return 'chat-message__assistant-body';\n default: return 'chat-message__plain';\n }\n });\n}" + "signature": "@Component({\n selector: 'chat-message',\n standalone: true,\n imports: [ChatCitationsComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_STYLES],\n providers: [CitationsResolverService],\n host: {\n '[attr.data-role]': 'role()',\n '[attr.data-current]': 'currentStr()',\n '[attr.data-streaming]': 'streamingStr()',\n '[attr.data-prev-role]': 'prevRole() ?? null',\n },\n template: `\n @if (speakerText(); as speaker) {\n {{ speaker }}\n }\n \n
\n \n \n
\n @if (message()?.role === 'assistant' && message(); as msg) {\n \n }\n
\n \n
\n `,\n})\nexport class ChatMessageComponent {\n readonly role = input.required();\n readonly current = input(false);\n readonly streaming = input(false);\n readonly prevRole = input(undefined);\n readonly message = input(undefined);\n readonly speaker = input(undefined);\n private readonly resolver = inject(CitationsResolverService);\n constructor() {\n effect(() => {\n this.resolver.message.set(this.message() ?? null);\n });\n }\n protected readonly speakerText = computed(() => {\n const name = this.speaker() ?? DEFAULT_SPEAKERS[this.role()];\n return name ? `${name}:` : undefined;\n });\n readonly currentStr = computed(() => String(this.current()));\n readonly streamingStr = computed(() => String(this.streaming()));\n readonly bodyClass = computed(() => {\n switch (this.role()) {\n case 'user': return 'chat-message__bubble';\n case 'assistant': return 'chat-message__assistant-body';\n default: return 'chat-message__plain';\n }\n });\n}" }, { "id": "component:libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts#ChatOverflowMenuComponent", @@ -1955,7 +1951,7 @@ "decorators": [ "Component" ], - "signature": "@Component({\n selector: 'chat-scroll-bubble',\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_SCROLL_BUBBLE_STYLES],\n template: `\n \n @if (mode() === 'streaming') {\n \n \n \n \n \n } @else {\n \n \n \n \n }\n \n `,\n})\nexport class ChatScrollBubbleComponent {\n readonly mode = input.required();\n readonly clicked = output();\n protected readonly ariaLabel = computed(() => this.mode() === 'streaming' ? 'Latest activity' : 'Scroll to latest');\n}" + "signature": "@Component({\n selector: 'chat-scroll-bubble',\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_SCROLL_BUBBLE_STYLES],\n template: `\n \n @if (mode() === 'streaming') {\n \n \n \n \n \n } @else {\n \n \n \n \n }\n \n `,\n})\nexport class ChatScrollBubbleComponent {\n readonly mode = input.required();\n readonly clicked = output();\n protected readonly ariaLabel = computed(() => this.mode() === 'streaming' ? 'Scroll to latest, response in progress' : 'Scroll to latest');\n}" }, { "id": "component:libs/chat/src/lib/primitives/chat-select/chat-select.component.ts#ChatSelectComponent", @@ -5500,7 +5496,7 @@ "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", "symbol": "ChatComponent", "syntaxKind": "ClassDeclaration", - "signature": "@Component({\n selector: 'chat',\n standalone: true,\n imports: [\n KeyValuePipe, NgTemplateOutlet,\n ChatWindowComponent, ChatMessageListComponent, MessageTemplateDirective, ChatMessageComponent,\n ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent,\n ChatThreadListComponent, ChatGenerativeUiComponent,\n ChatStreamingMdComponent, ChatToolCallsComponent, ChatToolViewsComponent, A2uiSurfaceComponent,\n ChatMessageActionsComponent, ChatWelcomeComponent, ChatSelectComponent, ChatReasoningComponent,\n ChatScrollBubbleComponent,\n ],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n { provide: CHAT_LIFECYCLE, useFactory: createChatLifecycle },\n {\n provide: DEVELOPMENT_COLLECTION_POLICY,\n useFactory: () => {\n const host = inject(ChatComponent);\n const parent = inject(DEVELOPMENT_COLLECTION_POLICY, { optional: true, skipSelf: true });\n return () => (parent?.() ?? true) && isDevelopmentRuntimeEnabled(host.agent());\n },\n },\n ],\n styles: [CHAT_HOST_TOKENS, `\n :host {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n height: 100%;\n min-height: 0;\n max-height: 100%;\n overflow: hidden;\n background: var(--tplane-chat-bg);\n }\n :host > chat-welcome {\n display: flex;\n flex: 1 1 auto;\n width: 100%;\n }\n .chat-shell { display: flex; flex: 1; min-height: 0; overflow: hidden; }\n .chat-shell__sidebar {\n width: 240px;\n flex-shrink: 0;\n border-right: 1px solid var(--tplane-chat-separator);\n background: var(--tplane-chat-surface-alt);\n overflow-y: auto;\n display: none;\n }\n @media (min-width: 768px) { .chat-shell__sidebar { display: block; } }\n .chat-shell__main { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; }\n .chat-empty {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n padding: 60px 20px;\n color: var(--tplane-chat-text-muted);\n text-align: center;\n flex: 1;\n min-height: 0;\n }\n .chat-empty[hidden] { display: none; }\n .chat-empty__title { font-size: 1.125rem; font-weight: 500; color: var(--tplane-chat-text); margin: 0; }\n .chat-empty__sub { margin: 0; font-size: var(--tplane-chat-font-size-sm); }\n .chat-empty__title { font-size: 1.125rem; font-weight: 500; color: var(--tplane-chat-text); margin: 0; }\n .chat-empty__sub { margin: 0; font-size: var(--tplane-chat-font-size-sm); }\n .chat-scroll { flex: 1; min-height: 0; overflow-y: auto; padding-top: var(--tplane-chat-edge-pad); }\n .chat-scroll::-webkit-scrollbar { width: 6px; }\n .chat-scroll::-webkit-scrollbar-thumb { background: var(--tplane-chat-separator); border-radius: 10px; }\n [chatFooter] {\n padding-bottom: var(--tplane-chat-edge-pad);\n }\n .chat-footer-wrap { position: relative; }\n .chat-run-activity, .chat-interrupt-region { display: block; }\n `],\n template: `\n @if (showWelcome()) {\n \n \n @if (showModelPicker() && modelOptions().length > 0) {\n \n }\n \n \n \n \n \n } @else {\n
\n @if (threads().length > 0) {\n \n }\n
\n \n \n
\n \n \n {{ humanContent(message) }}\n \n\n \n @let content = messageContent(message);\n @let classified = classifyMessage(content, message);\n \n \n @if (activityTemplate(); as activity) {\n \n } @else {\n \n @if (message.reasoning && reasoningRunStart(i)) {\n @let run = reasoningRun(i);\n \n }\n \n \n }\n \n @if (classified.markdown(); as md) {\n \n }\n @if (classified.spec(); as spec) {\n \n \n }\n @if (classified.type() === 'a2ui' && views(); as catalog) {\n @for (entry of classified.a2uiSurfaces() | keyvalue; track entry.key) {\n \n }\n }\n \n @if (content.trim()) {\n \n }\n \n \n\n \n \n \n\n \n {{ messageContent(message) }}\n \n \n\n \n @if (runActivityContext(); as runContext) {\n
\n \n
\n } @else if (pinned() && !currentAssistantStreaming()) {\n \n \n }\n\n \n @if (interruptContext(); as pending) {\n
\n \n
\n }\n
\n \n
\n
\n
\n }\n `,\n})\nexport class ChatComponent {\n readonly agent = input.required();\n readonly views = input(undefined);\n readonly clientTools = input(undefined);\n readonly store = input(undefined);\n readonly handlers = input) => unknown | Promise>>({});\n readonly threads = input([]);\n readonly activeThreadId = input('');\n readonly welcomeDisabled = input(false);\n readonly modelOptions = input([]);\n readonly showModelPicker = input(true);\n readonly selectedModel = model('');\n readonly modelPickerPlaceholder = input('Choose a model');\n readonly genuiToolNames = input([\n 'generate_a2ui_schema',\n 'generate_json_render_spec',\n 'render_spec',\n ]);\n readonly clientToolExecutionGuard = input(undefined);\n readonly clientToolContinuationPolicy = input(undefined);\n readonly showWelcome = computed(() => {\n if (this.welcomeDisabled())\n return false;\n const a = this.agent() as unknown as {\n isThreadLoading?: () => boolean;\n };\n if (a.isThreadLoading?.())\n return false;\n return this.agent().messages().length === 0;\n });\n readonly threadSelected = output();\n readonly renderEvent = output();\n readonly clientToolContinuationLimit = output();\n readonly regenerate = output();\n readonly rate = output<{\n messageIndex: number;\n rating: 'up' | 'down';\n }>();\n readonly messageCopy = output<{\n messageIndex: number;\n content: string;\n }>();\n private readonly _internalStore = signalStateStore({});\n readonly resolvedStore = computed(() => {\n const explicit = this.store();\n if (explicit)\n return explicit;\n if (this.effectiveViews())\n return this._internalStore;\n return undefined;\n });\n private readonly coordinator = computed(() => {\n const reg = this.clientTools();\n const policy = this.clientToolContinuationPolicy();\n return reg ? createClientToolsCoordinator(reg, {\n executionGuard: this.clientToolExecutionGuard(),\n continuationPolicy: {\n ...policy,\n onLimit: (event) => {\n policy?.onLimit?.(event);\n this.clientToolContinuationLimit.emit(event);\n },\n },\n }) : undefined;\n });\n protected readonly effectiveViews = computed(() => {\n const base = this.views();\n const coord = this.coordinator();\n if (!coord)\n return base;\n return base ? withViews(base, coord.viewRegistry) : coord.viewRegistry;\n });\n readonly renderRegistry = computed(() => {\n const v = this.views();\n return v ? toRenderRegistry(v) : undefined;\n });\n readonly viewToolNames = computed(() => Object.keys(this.effectiveViews() ?? {}));\n readonly excludedToolNames = computed(() => [\n ...this.genuiToolNames(),\n ...this.viewToolNames(),\n ]);\n readonly messageContent = messageContent;\n readonly activityTemplate = contentChild(ChatActivityTemplateDirective);\n readonly toolCallTemplates = contentChildren(ChatToolCallTemplateDirective);\n private readonly activityContexts = new Map();\n readonly runActivityTemplate = contentChild(ChatRunActivityTemplateDirective);\n readonly interruptTemplate = contentChild(ChatInterruptTemplateDirective);\n private readonly currentRunMessages = computed(() => {\n const msgs = this.agent().messages();\n let start = msgs.length;\n while (start > 0 && msgs[start - 1].role !== 'user')\n start--;\n return msgs.slice(start);\n }, { equal: sameItems });\n protected readonly runActivityContext = computed(() => {\n if (!this.runActivityTemplate())\n return undefined;\n const agent = this.agent();\n if (!agent.isLoading())\n return undefined;\n const messages = this.currentRunMessages();\n if (messages.some((m) => m.role === 'assistant'))\n return undefined;\n return { $implicit: agent, running: true, hasAssistantMessage: false, messages };\n }, {\n equal: (a, b) => a === b ||\n (!!a && !!b && a.$implicit === b.$implicit && sameItems(a.messages, b.messages)),\n });\n protected readonly interruptContext = computed(() => {\n if (!this.interruptTemplate())\n return undefined;\n const agent = this.agent();\n const interrupt = agent.interrupt?.();\n return interrupt ? { $implicit: agent, interrupt } : undefined;\n }, {\n equal: (a, b) => a === b || (!!a && !!b && a.$implicit === b.$implicit && a.interrupt === b.interrupt),\n });\n private readonly runActivityRegion = viewChild>('runActivityRegion');\n private readonly interruptRegion = viewChild>('interruptRegion');\n protected activityContextFor(message: Message, index: number): ChatActivityTemplateContext {\n const agent = this.agent();\n const excluded = new Set(this.excludedToolNames());\n const toolCalls = resolveMessageToolCalls(agent, message).filter((tc) => !excluded.has(tc.name));\n const byCallId = new Map();\n agent.subagents?.().forEach((sa) => byCallId.set(sa.toolCallId, sa));\n const subagents: Subagent[] = [];\n for (const tc of toolCalls) {\n const sa = byCallId.get(tc.id);\n if (sa)\n subagents.push(sa);\n }\n const prior = this.activityContexts.get(message.id);\n if (prior &&\n prior.$implicit === message &&\n prior.index === index &&\n prior.agent === agent &&\n sameItems(prior.toolCalls, toolCalls) &&\n sameItems(prior.subagents, subagents)) {\n return prior;\n }\n const context: ChatActivityTemplateContext = { $implicit: message, index, toolCalls, subagents, agent };\n this.activityContexts.set(message.id, context);\n return context;\n }\n protected humanContent(message: {\n content: unknown;\n }): string {\n const raw = messageContent(message);\n return a2uiActionLabel(raw) ?? raw;\n }\n private prevAssistant(msgs: Message[], index: number): Message | undefined {\n for (let j = index - 1; j >= 0; j--) {\n if (msgs[j].role === 'tool')\n continue;\n return msgs[j].role === 'assistant' ? msgs[j] : undefined;\n }\n return undefined;\n }\n protected reasoningRunStart(index: number): boolean {\n const msgs = this.agent().messages();\n if (!msgs[index]?.reasoning)\n return false;\n return !this.prevAssistant(msgs, index)?.reasoning;\n }\n protected reasoningRun(index: number): {\n content: string;\n durationMs: number | undefined;\n delivery: MessageDelivery;\n label: string | undefined;\n } {\n const msgs = this.agent().messages();\n const steps: Message[] = [];\n for (let j = index; j < msgs.length; j++) {\n const m = msgs[j];\n if (m.role === 'tool')\n continue;\n if (m.role === 'assistant' && m.reasoning) {\n steps.push(m);\n continue;\n }\n break;\n }\n const content = steps.map((step) => step.reasoning ?? '').filter(Boolean).join('\\n\\n');\n const durations = steps\n .map((step) => step.reasoningDurationMs)\n .filter((d): d is number => typeof d === 'number');\n const durationMs = durations.length ? durations.reduce((a, b) => a + b, 0) : undefined;\n const last = steps[steps.length - 1];\n const delivery = last?.delivery ?? msgs[index].delivery;\n const label = steps.length > 1\n ? durationMs !== undefined\n ? `Thought for ${formatDuration(durationMs)} · ${steps.length} steps`\n : `${steps.length} steps`\n : undefined;\n return { content, durationMs, delivery, label };\n }\n private readonly classifiers = new Map();\n private readonly markdownDocuments = new Map();\n private readonly destroyRef = inject(DestroyRef);\n private readonly injector = inject(Injector);\n private readonly lifecycle = (inject(CHAT_LIFECYCLE, { optional: true }) ?? createChatLifecycle()) as ChatLifecycleInternal;\n private eventsSubscribed = false;\n protected readonly liveSurfaceStore: A2uiSurfaceStore = createA2uiSurfaceStore();\n private readonly partialBridge: PartialArgsBridge = createPartialArgsBridge(this.liveSurfaceStore);\n private partialEventsLastIndex = 0;\n private readonly scrollContainer = viewChild>('scrollContainer');\n private readonly messageCount = computed(() => this.agent().messages().length);\n private prevMessageCount = 0;\n private wasLoading = false;\n protected readonly pinned = signal(true);\n private programmaticScrollCount = 0;\n private programmaticScrollTop = 0;\n private upwardWheel: {\n events: WheelEvent[];\n scrollTop: number;\n settled: boolean;\n supported: boolean;\n followDeferred: boolean;\n } | undefined;\n private static readonly PIN_TOLERANCE_PX = 150;\n protected readonly currentAssistantStreaming = computed(() => {\n const msgs = this.agent().messages();\n if (msgs.length === 0)\n return false;\n const last = msgs[msgs.length - 1];\n return last?.role === 'assistant' && last.delivery.phase === 'streaming';\n });\n constructor() {\n ensureChatRootStyles();\n effect(() => {\n if (this.eventsSubscribed)\n return;\n let agent: ReturnType;\n try {\n agent = this.agent();\n }\n catch {\n return;\n }\n this.eventsSubscribed = true;\n this.lifecycle._internal.componentReady.set(true);\n agent.events$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {\n if (event.type !== 'state_update')\n return;\n const store = this.resolvedStore();\n if (!store)\n return;\n store.update(event.data);\n });\n });\n effect(() => {\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n const stateFn = (agentRef as unknown as {\n state?: () => unknown;\n }).state;\n if (typeof stateFn !== 'function')\n return;\n const state = stateFn.call(agentRef);\n const store = this.resolvedStore();\n if (!store || state == null || typeof state !== 'object' || Array.isArray(state))\n return;\n const updates: Record = {};\n for (const [k, v] of Object.entries(state as Record)) {\n if (k === 'messages')\n continue;\n updates['/' + k] = v;\n }\n if (Object.keys(updates).length > 0)\n store.update(updates);\n });\n effect(() => {\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n const lc = (agentRef as unknown as {\n lifecycle?: {\n streamStartedAt?: () => number | null;\n };\n }).lifecycle;\n const streamStartedAt = lc?.streamStartedAt?.();\n if (streamStartedAt != null && !this.lifecycle._internal.firstMessageSent()) {\n this.lifecycle._internal.firstMessageSent.set(true);\n }\n });\n effect((onCleanup) => {\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const dispatchTasks = new Set>();\n const onWheel = (event: WheelEvent) => {\n if (event.deltaY >= 0 || event.ctrlKey || event.defaultPrevented)\n return;\n for (const target of event.composedPath()) {\n if (target === el)\n break;\n if (!(target instanceof HTMLElement) || target.scrollTop <= 0 || target.scrollHeight <= target.clientHeight)\n continue;\n if (/^(auto|scroll|overlay)$/.test(getComputedStyle(target).overflowY))\n return;\n }\n const previous = this.upwardWheel;\n if (previous && !previous.settled) {\n previous.events.push(event);\n return;\n }\n const intent = {\n events: [event], scrollTop: Math.max(this.programmaticScrollTop, el.scrollTop, previous?.scrollTop ?? 0),\n settled: false, supported: previous?.supported ?? false, followDeferred: previous?.followDeferred ?? false,\n };\n this.upwardWheel = intent;\n const task = setTimeout(() => {\n dispatchTasks.delete(task);\n if (this.upwardWheel !== intent || this.scrollContainer()?.nativeElement !== el)\n return;\n intent.settled = true;\n intent.supported ||= intent.events.some((wheel) => !wheel.defaultPrevented);\n intent.events = [];\n if (!intent.supported)\n this.upwardWheel = undefined;\n else if (el.scrollTop < intent.scrollTop)\n this.onScroll();\n const followDeferred = intent.followDeferred;\n intent.followDeferred = false;\n if (followDeferred)\n this.followIfPinned();\n }, 0);\n dispatchTasks.add(task);\n };\n el.addEventListener('wheel', onWheel, { passive: true });\n onCleanup(() => {\n el.removeEventListener('wheel', onWheel);\n for (const task of dispatchTasks)\n clearTimeout(task);\n dispatchTasks.clear();\n this.upwardWheel = undefined;\n });\n });\n effect(() => {\n let count: number;\n let msgs: ReturnType['messages']>;\n try {\n count = this.messageCount();\n msgs = this.agent().messages();\n }\n catch {\n return;\n }\n const lastContent = msgs.length > 0 ? (msgs[msgs.length - 1] as unknown as Record)['content'] : undefined;\n void lastContent;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const isNewMessage = count !== this.prevMessageCount;\n this.prevMessageCount = count;\n if (isNewMessage) {\n this.upwardWheel = undefined;\n untracked(() => this.pinned.set(true));\n }\n if (isNewMessage || this.pinned())\n this.followIfPinned();\n });\n effect(() => {\n let loading: boolean;\n try {\n loading = this.agent().isLoading();\n }\n catch {\n return;\n }\n if (loading) {\n this.wasLoading = true;\n return;\n }\n if (!this.wasLoading)\n return;\n this.wasLoading = false;\n if (this.pinned()) {\n requestAnimationFrame(() => this.followIfPinned());\n }\n });\n effect((onCleanup) => {\n const regions = [this.runActivityRegion()?.nativeElement, this.interruptRegion()?.nativeElement]\n .filter((el): el is HTMLElement => !!el);\n if (regions.length === 0)\n return;\n if (typeof ResizeObserver === 'undefined') {\n requestAnimationFrame(() => this.followIfPinned());\n return;\n }\n const observer = new ResizeObserver(() => this.followIfPinned());\n for (const el of regions)\n observer.observe(el);\n onCleanup(() => observer.disconnect());\n });\n effect(() => {\n let agent: ReturnType;\n try {\n agent = this.agent();\n }\n catch {\n return;\n }\n const customSig = (agent as unknown as {\n customEvents?: () => readonly {\n name: string;\n data: unknown;\n }[];\n }).customEvents;\n if (typeof customSig !== 'function')\n return;\n const events = customSig();\n for (let i = this.partialEventsLastIndex; i < events.length; i++) {\n const e = events[i];\n if (e.name !== 'a2ui-partial')\n continue;\n const d = e.data as {\n tool_call_id?: string;\n args_so_far?: string;\n } | null;\n if (!d || typeof d.tool_call_id !== 'string' || typeof d.args_so_far !== 'string')\n continue;\n this.partialBridge.push(d.tool_call_id, d.args_so_far);\n }\n this.partialEventsLastIndex = events.length;\n });\n let connected: unknown;\n effect(() => {\n const coord = this.coordinator();\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n if (!coord || !agentRef)\n return;\n if (connected === coord)\n return;\n connected = coord;\n queueMicrotask(() => {\n runInInjectionContext(this.injector, () => coord.connect(agentRef));\n });\n });\n effect(() => {\n let liveIds: Set;\n try {\n liveIds = new Set();\n for (const m of this.agent().messages()) {\n const id = (m as unknown as {\n id?: string;\n }).id;\n if (id)\n liveIds.add(id);\n }\n }\n catch {\n return;\n }\n for (const key of [...this.classifiers.keys()]) {\n if (!liveIds.has(key)) {\n this.classifiers.get(key)?.classifier.dispose();\n this.classifiers.delete(key);\n }\n }\n for (const key of [...this.markdownDocuments.keys()]) {\n if (!liveIds.has(key))\n this.markdownDocuments.delete(key);\n }\n for (const key of [...this.activityContexts.keys()]) {\n if (!liveIds.has(key))\n this.activityContexts.delete(key);\n }\n });\n }\n prevRole(index: number): ChatMessageRole | undefined {\n if (index === 0)\n return undefined;\n const prev = this.agent().messages()[index - 1];\n if (!prev)\n return undefined;\n const role = (prev as unknown as {\n role?: string;\n }).role;\n if (role === 'user')\n return 'user';\n if (role === 'assistant')\n return 'assistant';\n if (role === 'system')\n return 'system';\n if (role === 'tool')\n return 'tool';\n return undefined;\n }\n protected onScroll(event?: Event): void {\n const el = this.scrollContainer()?.nativeElement;\n if (!el || (event && event.target !== el))\n return;\n const intent = this.upwardWheel;\n if (intent && !intent.settled)\n return;\n const userScrolledUp = intent && intent.supported && el.scrollTop < intent.scrollTop;\n if (intent && el.scrollTop !== intent.scrollTop)\n this.upwardWheel = undefined;\n if (this.programmaticScrollCount > 0 && !userScrolledUp)\n return;\n const nextPinned = isPinned(el.scrollHeight, el.scrollTop, el.clientHeight, ChatComponent.PIN_TOLERANCE_PX);\n if (nextPinned !== this.pinned())\n this.pinned.set(nextPinned);\n }\n scrollToBottom(): void {\n this.upwardWheel = undefined;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n this.programmaticScrollCount++;\n el.scrollTop = el.scrollHeight;\n this.programmaticScrollTop = el.scrollTop;\n requestAnimationFrame(() => { this.programmaticScrollCount--; });\n this.pinned.set(true);\n }\n private followIfPinned(): void {\n if (!untracked(() => this.pinned()))\n return;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const intent = this.upwardWheel;\n if (intent && el.scrollTop < intent.scrollTop) {\n if (!intent.settled) {\n intent.followDeferred = true;\n return;\n }\n this.onScroll();\n if (!untracked(() => this.pinned()))\n return;\n }\n this.programmaticScrollCount++;\n el.scrollTop = el.scrollHeight;\n this.programmaticScrollTop = el.scrollTop;\n if (intent && this.upwardWheel === intent)\n intent.scrollTop = this.programmaticScrollTop;\n requestAnimationFrame(() => { this.programmaticScrollCount--; });\n }\n protected onScrollBubbleClick(): void {\n this.scrollToBottom();\n }\n protected onUserSubmitted(): void {\n this.upwardWheel = undefined;\n this.pinned.set(true);\n this.recordSubmit();\n }\n submitMessage(text: string): void {\n const trimmed = text.trim();\n if (!trimmed || this.agent().isInputBlocked?.())\n return;\n void this.agent().submit({ message: trimmed });\n this.recordSubmit();\n }\n clearThread(): void {\n this.clearClassifiers();\n this.lifecycle._internal.messageCount.set(0);\n this.lifecycle._internal.inputSubmittedAt.set(null);\n }\n private recordSubmit(): void {\n if (!this.lifecycle._internal.firstMessageSent()) {\n this.lifecycle._internal.firstMessageSent.set(true);\n }\n this.lifecycle._internal.messageCount.update((c) => c + 1);\n this.lifecycle._internal.inputSubmittedAt.set(Date.now());\n }\n protected prevMessage(index: number): unknown {\n if (index === 0)\n return undefined;\n return this.agent().messages()[index - 1];\n }\n protected isGenuiTurn(message: unknown, _prevMsg: unknown, index?: number): boolean {\n const names = new Set(this.genuiToolNames());\n const m = message as {\n extra?: Record;\n } | null | undefined;\n if (!m)\n return false;\n const calls = (m.extra?.['tool_calls'] as Array<{\n name?: string;\n }> | undefined) ?? [];\n if (calls.some(c => c.name != null && names.has(c.name)))\n return true;\n const rawContent = m.extra?.['content'];\n if (Array.isArray(rawContent)) {\n for (const block of rawContent) {\n if (block != null\n && typeof block === 'object'\n && (block as {\n type?: unknown;\n }).type === 'function_call'\n && typeof (block as {\n name?: unknown;\n }).name === 'string'\n && names.has((block as {\n name: string;\n }).name)) {\n return true;\n }\n }\n }\n const projectedContent = (m as {\n content?: unknown;\n }).content;\n if (typeof projectedContent === 'string' && projectedContent.length > 0) {\n if (projectedContent.includes('\"createSurface\"')\n || projectedContent.includes('\"updateComponents\"')\n || projectedContent.includes('\"updateDataModel\"')) {\n return true;\n }\n if (projectedContent.includes('\"root\"') && projectedContent.includes('\"elements\"')) {\n return true;\n }\n }\n const p = _prevMsg as {\n role?: string;\n name?: string;\n extra?: Record;\n } | null | undefined;\n if (p && p.role === 'tool') {\n const toolName = (p.extra?.['name'] as string | undefined) ?? p.name;\n if (typeof toolName === 'string' && names.has(toolName))\n return true;\n }\n if (typeof index === 'number' && index > 0) {\n const msgs = this.agent().messages();\n for (let i = index - 1; i >= 0; i--) {\n const prev = msgs[i] as {\n role?: string;\n extra?: Record;\n };\n if (!prev)\n break;\n if (prev.role === 'user')\n break;\n const prevCalls = (prev.extra?.['tool_calls'] as Array<{\n name?: string;\n }> | undefined) ?? [];\n if (prevCalls.some(c => c.name != null && names.has(c.name)))\n return true;\n const prevRaw = prev.extra?.['content'];\n if (Array.isArray(prevRaw)) {\n for (const block of prevRaw) {\n if (block != null\n && typeof block === 'object'\n && (block as {\n type?: unknown;\n }).type === 'function_call'\n && typeof (block as {\n name?: unknown;\n }).name === 'string'\n && names.has((block as {\n name: string;\n }).name)) {\n return true;\n }\n }\n }\n }\n }\n return false;\n }\n classifyMessage(content: string, message: Pick): ContentClassifier {\n const generation = message.delivery.generation;\n let entry = this.classifiers.get(message.id);\n if (!entry || entry.generation !== generation) {\n entry?.classifier.dispose();\n entry = { generation, classifier: createContentClassifier() };\n this.classifiers.set(message.id, entry);\n }\n if (entry.content !== content) {\n entry.classifier.update(content);\n entry.content = content;\n }\n return entry.classifier;\n }\n protected markdownDocumentFor(content: string, message: Pick): StreamingMarkdownDocument {\n const prior = this.markdownDocuments.get(message.id);\n const delivery = message.delivery;\n if (prior?.generation === delivery.generation &&\n prior.phase === delivery.phase &&\n prior.content === content) {\n return prior;\n }\n const document = markdownDocument(content, delivery);\n this.markdownDocuments.set(message.id, document);\n return document;\n }\n clearClassifiers(): void {\n for (const [, entry] of this.classifiers) {\n entry.classifier.dispose();\n }\n this.classifiers.clear();\n this.markdownDocuments.clear();\n this.activityContexts.clear();\n }\n onSpecEvent(event: RenderEvent, messageIndex: number): void {\n this.renderEvent.emit({ messageIndex, event });\n }\n protected onClientToolEvent(event: RenderEvent): void {\n const coord = this.coordinator();\n if (!coord)\n return;\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n coord.handleRenderEvent(agentRef, event);\n }\n onA2uiAction(message: A2uiActionMessage): void {\n if (this.agent().isInputBlocked?.())\n return;\n void this.agent().submit({ message: JSON.stringify(message) });\n }\n onA2uiEvent(event: RenderEvent, messageIndex: number, surfaceId: string): void {\n this.renderEvent.emit({ messageIndex, surfaceId, event });\n }\n onRegenerate(messageIndex: number): void {\n void this.agent().regenerate(messageIndex);\n this.regenerate.emit();\n }\n onRate(message: unknown, value: 'up' | 'down'): void {\n const idx = this.agent().messages().indexOf(message as never);\n this.rate.emit({ messageIndex: idx, rating: value });\n }\n onCopy(message: unknown, content: string): void {\n const idx = this.agent().messages().indexOf(message as never);\n this.messageCopy.emit({ messageIndex: idx, content });\n }\n}" + "signature": "@Component({\n selector: 'chat',\n standalone: true,\n imports: [\n KeyValuePipe, NgTemplateOutlet,\n ChatWindowComponent, ChatMessageListComponent, MessageTemplateDirective, ChatMessageComponent,\n ChatInputComponent, ChatTypingIndicatorComponent, ChatErrorComponent,\n ChatThreadListComponent, ChatGenerativeUiComponent,\n ChatStreamingMdComponent, ChatToolCallsComponent, ChatToolViewsComponent, A2uiSurfaceComponent,\n ChatMessageActionsComponent, ChatWelcomeComponent, ChatSelectComponent, ChatReasoningComponent,\n ChatScrollBubbleComponent,\n ],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n { provide: CHAT_LIFECYCLE, useFactory: createChatLifecycle },\n {\n provide: DEVELOPMENT_COLLECTION_POLICY,\n useFactory: () => {\n const host = inject(ChatComponent);\n const parent = inject(DEVELOPMENT_COLLECTION_POLICY, { optional: true, skipSelf: true });\n return () => (parent?.() ?? true) && isDevelopmentRuntimeEnabled(host.agent());\n },\n },\n ],\n styles: [CHAT_HOST_TOKENS, `\n :host {\n display: flex;\n flex-direction: column;\n flex: 1 1 auto;\n height: 100%;\n min-height: 0;\n max-height: 100%;\n overflow: hidden;\n background: var(--tplane-chat-bg);\n }\n :host > chat-welcome {\n display: flex;\n flex: 1 1 auto;\n width: 100%;\n }\n .chat-shell { display: flex; flex: 1; min-height: 0; overflow: hidden; }\n .chat-shell__sidebar {\n width: 240px;\n flex-shrink: 0;\n border-right: 1px solid var(--tplane-chat-separator);\n background: var(--tplane-chat-surface-alt);\n overflow-y: auto;\n display: none;\n }\n @media (min-width: 768px) { .chat-shell__sidebar { display: block; } }\n .chat-shell__main { flex: 1; min-width: 0; display: flex; flex-direction: column; min-height: 0; }\n .chat-empty {\n display: flex;\n flex-direction: column;\n align-items: center;\n justify-content: center;\n gap: 12px;\n padding: 60px 20px;\n color: var(--tplane-chat-text-muted);\n text-align: center;\n flex: 1;\n min-height: 0;\n }\n .chat-empty[hidden] { display: none; }\n .chat-empty__title { font-size: 1.125rem; font-weight: 500; color: var(--tplane-chat-text); margin: 0; }\n .chat-empty__sub { margin: 0; font-size: var(--tplane-chat-font-size-sm); }\n .chat-empty__title { font-size: 1.125rem; font-weight: 500; color: var(--tplane-chat-text); margin: 0; }\n .chat-empty__sub { margin: 0; font-size: var(--tplane-chat-font-size-sm); }\n .chat-scroll { flex: 1; min-height: 0; overflow-y: auto; padding-top: var(--tplane-chat-edge-pad); }\n .chat-scroll::-webkit-scrollbar { width: 6px; }\n .chat-scroll::-webkit-scrollbar-thumb { background: var(--tplane-chat-separator); border-radius: 10px; }\n [chatFooter] {\n padding-bottom: var(--tplane-chat-edge-pad);\n }\n .chat-footer-wrap { position: relative; }\n .chat-run-activity, .chat-interrupt-region { display: block; }\n .chat-scroll:focus-visible { outline: 2px solid var(--tplane-chat-text-muted); outline-offset: -2px; }\n .chat-stopped-notice {\n display: flex;\n align-items: center;\n gap: var(--tplane-chat-space-2);\n box-sizing: border-box;\n width: 100%;\n max-width: var(--tplane-chat-max-width);\n margin: 0 auto;\n /* Same column and inset as chat-message-list, so it aligns with replies. */\n padding: 0 var(--tplane-chat-space-6);\n color: var(--tplane-chat-text-muted);\n font-size: var(--tplane-chat-font-size-sm);\n }\n .chat-stopped-notice svg { width: 12px; height: 12px; flex: none; }\n .chat-sr-only {\n position: absolute;\n width: 1px;\n height: 1px;\n margin: -1px;\n overflow: hidden;\n clip-path: inset(50%);\n white-space: nowrap;\n }\n `],\n template: `\n @if (showWelcome()) {\n \n \n @if (showModelPicker() && modelOptions().length > 0) {\n \n }\n \n \n \n \n \n } @else {\n
\n @if (threads().length > 0) {\n \n }\n
\n \n \n \n
\n \n \n {{ humanContent(message) }}\n \n\n \n @let content = messageContent(message);\n @let classified = classifyMessage(content, message);\n \n \n @if (activityTemplate(); as activity) {\n \n } @else {\n \n @if (message.reasoning && reasoningRunStart(i)) {\n @let run = reasoningRun(i);\n \n }\n \n \n }\n \n @if (classified.markdown(); as md) {\n \n }\n @if (classified.spec(); as spec) {\n \n \n }\n @if (classified.type() === 'a2ui' && views(); as catalog) {\n @for (entry of classified.a2uiSurfaces() | keyvalue; track entry.key) {\n \n }\n }\n \n @if (content.trim()) {\n \n }\n \n \n\n \n \n \n\n \n {{ messageContent(message) }}\n \n \n\n \n @if (runActivityContext(); as runContext) {\n
\n \n
\n } @else if (pinned() && !currentAssistantStreaming()) {\n \n \n }\n\n \n @if (interruptContext(); as pending) {\n
\n \n
\n }\n\n \n @if (showStoppedNotice()) {\n

\n \n Response stopped\n

\n }\n
\n \n
\n \n
{{ runAnnouncement() }}
\n
\n
\n }\n `,\n})\nexport class ChatComponent {\n readonly agent = input.required();\n readonly views = input(undefined);\n readonly clientTools = input(undefined);\n readonly store = input(undefined);\n readonly handlers = input) => unknown | Promise>>({});\n readonly threads = input([]);\n readonly activeThreadId = input('');\n readonly welcomeDisabled = input(false);\n readonly modelOptions = input([]);\n readonly showModelPicker = input(true);\n readonly selectedModel = model('');\n readonly modelPickerPlaceholder = input('Choose a model');\n readonly genuiToolNames = input([\n 'generate_a2ui_schema',\n 'generate_json_render_spec',\n 'render_spec',\n ]);\n readonly clientToolExecutionGuard = input(undefined);\n readonly clientToolContinuationPolicy = input(undefined);\n readonly showWelcome = computed(() => {\n if (this.welcomeDisabled())\n return false;\n const a = this.agent() as unknown as {\n isThreadLoading?: () => boolean;\n };\n if (a.isThreadLoading?.())\n return false;\n return this.agent().messages().length === 0;\n });\n readonly threadSelected = output();\n readonly renderEvent = output();\n readonly clientToolContinuationLimit = output();\n readonly regenerate = output();\n readonly rate = output<{\n messageIndex: number;\n rating: 'up' | 'down';\n }>();\n readonly messageCopy = output<{\n messageIndex: number;\n content: string;\n }>();\n private readonly _internalStore = signalStateStore({});\n readonly resolvedStore = computed(() => {\n const explicit = this.store();\n if (explicit)\n return explicit;\n if (this.effectiveViews())\n return this._internalStore;\n return undefined;\n });\n private readonly coordinator = computed(() => {\n const reg = this.clientTools();\n const policy = this.clientToolContinuationPolicy();\n return reg ? createClientToolsCoordinator(reg, {\n executionGuard: this.clientToolExecutionGuard(),\n continuationPolicy: {\n ...policy,\n onLimit: (event) => {\n policy?.onLimit?.(event);\n this.clientToolContinuationLimit.emit(event);\n },\n },\n }) : undefined;\n });\n protected readonly effectiveViews = computed(() => {\n const base = this.views();\n const coord = this.coordinator();\n if (!coord)\n return base;\n return base ? withViews(base, coord.viewRegistry) : coord.viewRegistry;\n });\n readonly renderRegistry = computed(() => {\n const v = this.views();\n return v ? toRenderRegistry(v) : undefined;\n });\n readonly viewToolNames = computed(() => Object.keys(this.effectiveViews() ?? {}));\n readonly excludedToolNames = computed(() => [\n ...this.genuiToolNames(),\n ...this.viewToolNames(),\n ]);\n readonly messageContent = messageContent;\n readonly activityTemplate = contentChild(ChatActivityTemplateDirective);\n readonly toolCallTemplates = contentChildren(ChatToolCallTemplateDirective);\n private readonly activityContexts = new Map();\n readonly runActivityTemplate = contentChild(ChatRunActivityTemplateDirective);\n readonly interruptTemplate = contentChild(ChatInterruptTemplateDirective);\n private readonly currentRunMessages = computed(() => {\n const msgs = this.agent().messages();\n let start = msgs.length;\n while (start > 0 && msgs[start - 1].role !== 'user')\n start--;\n return msgs.slice(start);\n }, { equal: sameItems });\n protected readonly runActivityContext = computed(() => {\n if (!this.runActivityTemplate())\n return undefined;\n const agent = this.agent();\n if (!agent.isLoading())\n return undefined;\n const messages = this.currentRunMessages();\n if (messages.some((m) => m.role === 'assistant'))\n return undefined;\n return { $implicit: agent, running: true, hasAssistantMessage: false, messages };\n }, {\n equal: (a, b) => a === b ||\n (!!a && !!b && a.$implicit === b.$implicit && sameItems(a.messages, b.messages)),\n });\n protected readonly interruptContext = computed(() => {\n if (!this.interruptTemplate())\n return undefined;\n const agent = this.agent();\n const interrupt = agent.interrupt?.();\n return interrupt ? { $implicit: agent, interrupt } : undefined;\n }, {\n equal: (a, b) => a === b || (!!a && !!b && a.$implicit === b.$implicit && a.interrupt === b.interrupt),\n });\n private readonly runActivityRegion = viewChild>('runActivityRegion');\n private readonly interruptRegion = viewChild>('interruptRegion');\n protected activityContextFor(message: Message, index: number): ChatActivityTemplateContext {\n const agent = this.agent();\n const excluded = new Set(this.excludedToolNames());\n const toolCalls = resolveMessageToolCalls(agent, message).filter((tc) => !excluded.has(tc.name));\n const byCallId = new Map();\n agent.subagents?.().forEach((sa) => byCallId.set(sa.toolCallId, sa));\n const subagents: Subagent[] = [];\n for (const tc of toolCalls) {\n const sa = byCallId.get(tc.id);\n if (sa)\n subagents.push(sa);\n }\n const prior = this.activityContexts.get(message.id);\n if (prior &&\n prior.$implicit === message &&\n prior.index === index &&\n prior.agent === agent &&\n sameItems(prior.toolCalls, toolCalls) &&\n sameItems(prior.subagents, subagents)) {\n return prior;\n }\n const context: ChatActivityTemplateContext = { $implicit: message, index, toolCalls, subagents, agent };\n this.activityContexts.set(message.id, context);\n return context;\n }\n protected humanContent(message: {\n content: unknown;\n }): string {\n const raw = messageContent(message);\n return a2uiActionLabel(raw) ?? raw;\n }\n private prevAssistant(msgs: Message[], index: number): Message | undefined {\n for (let j = index - 1; j >= 0; j--) {\n if (msgs[j].role === 'tool')\n continue;\n return msgs[j].role === 'assistant' ? msgs[j] : undefined;\n }\n return undefined;\n }\n protected reasoningRunStart(index: number): boolean {\n const msgs = this.agent().messages();\n if (!msgs[index]?.reasoning)\n return false;\n return !this.prevAssistant(msgs, index)?.reasoning;\n }\n protected reasoningRun(index: number): {\n content: string;\n durationMs: number | undefined;\n delivery: MessageDelivery;\n label: string | undefined;\n } {\n const msgs = this.agent().messages();\n const steps: Message[] = [];\n for (let j = index; j < msgs.length; j++) {\n const m = msgs[j];\n if (m.role === 'tool')\n continue;\n if (m.role === 'assistant' && m.reasoning) {\n steps.push(m);\n continue;\n }\n break;\n }\n const content = steps.map((step) => step.reasoning ?? '').filter(Boolean).join('\\n\\n');\n const durations = steps\n .map((step) => step.reasoningDurationMs)\n .filter((d): d is number => typeof d === 'number');\n const durationMs = durations.length ? durations.reduce((a, b) => a + b, 0) : undefined;\n const last = steps[steps.length - 1];\n const delivery = last?.delivery ?? msgs[index].delivery;\n const label = steps.length > 1\n ? durationMs !== undefined\n ? `Thought for ${formatDuration(durationMs)} · ${steps.length} steps`\n : `${steps.length} steps`\n : undefined;\n return { content, durationMs, delivery, label };\n }\n private readonly classifiers = new Map();\n private readonly markdownDocuments = new Map();\n private readonly destroyRef = inject(DestroyRef);\n private readonly injector = inject(Injector);\n private readonly composerDocument = inject(DOCUMENT);\n private readonly lifecycle = (inject(CHAT_LIFECYCLE, { optional: true }) ?? createChatLifecycle()) as ChatLifecycleInternal;\n private eventsSubscribed = false;\n protected readonly liveSurfaceStore: A2uiSurfaceStore = createA2uiSurfaceStore();\n private readonly partialBridge: PartialArgsBridge = createPartialArgsBridge(this.liveSurfaceStore);\n private partialEventsLastIndex = 0;\n private readonly scrollContainer = viewChild>('scrollContainer');\n private readonly composer = viewChild('composer');\n protected welcomeHadFocus = false;\n protected errorHadFocus = false;\n private readonly stoppedTurn = signal<{\n userCount: number;\n prompt: string;\n } | null>(null);\n protected readonly showStoppedNotice = computed(() => {\n const agent = this.agent();\n if (agent.isLoading())\n return false;\n const msgs = agent.messages();\n const last = msgs[msgs.length - 1];\n const delivery = last?.role === 'assistant' ? last.delivery : undefined;\n if (delivery?.phase === 'complete' && delivery.outcome === 'aborted')\n return true;\n const stopped = this.stoppedTurn();\n if (!stopped || (delivery?.phase === 'complete' && delivery.outcome === 'success'))\n return false;\n const users = msgs.filter((m) => m.role === 'user');\n const prompt = users[users.length - 1];\n return users.length === stopped.userCount && !!prompt && messageContent(prompt) === stopped.prompt;\n });\n protected readonly runAnnouncement = signal('');\n private readonly messageCount = computed(() => this.agent().messages().length);\n private prevMessageCount = 0;\n private wasLoading = false;\n protected readonly pinned = signal(true);\n private programmaticScrollCount = 0;\n private programmaticScrollTop = 0;\n private upwardWheel: {\n events: WheelEvent[];\n scrollTop: number;\n settled: boolean;\n supported: boolean;\n followDeferred: boolean;\n } | undefined;\n private static readonly PIN_TOLERANCE_PX = 150;\n protected readonly currentAssistantStreaming = computed(() => {\n const msgs = this.agent().messages();\n if (msgs.length === 0)\n return false;\n const last = msgs[msgs.length - 1];\n return last?.role === 'assistant' && last.delivery.phase === 'streaming';\n });\n constructor() {\n ensureChatRootStyles();\n effect(() => {\n if (this.eventsSubscribed)\n return;\n let agent: ReturnType;\n try {\n agent = this.agent();\n }\n catch {\n return;\n }\n this.eventsSubscribed = true;\n this.lifecycle._internal.componentReady.set(true);\n agent.events$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((event) => {\n if (event.type !== 'state_update')\n return;\n const store = this.resolvedStore();\n if (!store)\n return;\n store.update(event.data);\n });\n });\n effect(() => {\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n const stateFn = (agentRef as unknown as {\n state?: () => unknown;\n }).state;\n if (typeof stateFn !== 'function')\n return;\n const state = stateFn.call(agentRef);\n const store = this.resolvedStore();\n if (!store || state == null || typeof state !== 'object' || Array.isArray(state))\n return;\n const updates: Record = {};\n for (const [k, v] of Object.entries(state as Record)) {\n if (k === 'messages')\n continue;\n updates['/' + k] = v;\n }\n if (Object.keys(updates).length > 0)\n store.update(updates);\n });\n effect(() => {\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n const lc = (agentRef as unknown as {\n lifecycle?: {\n streamStartedAt?: () => number | null;\n };\n }).lifecycle;\n const streamStartedAt = lc?.streamStartedAt?.();\n if (streamStartedAt != null && !this.lifecycle._internal.firstMessageSent()) {\n this.lifecycle._internal.firstMessageSent.set(true);\n }\n });\n effect(() => {\n if (!this.composer() || !this.welcomeHadFocus)\n return;\n this.welcomeHadFocus = false;\n this.continueInComposer();\n });\n effect(() => {\n let error: unknown;\n try {\n error = this.agent().error();\n }\n catch {\n return;\n }\n if (error || !this.errorHadFocus)\n return;\n this.errorHadFocus = false;\n this.continueInComposer();\n });\n let runActive = false;\n effect(() => {\n let loading: boolean;\n try {\n loading = this.agent().isLoading();\n }\n catch {\n return;\n }\n if (loading) {\n const startsRun = !runActive;\n runActive = true;\n untracked(() => {\n this.stoppedTurn.set(null);\n this.runAnnouncement.set('');\n const msgs = this.agent().messages();\n if (startsRun && msgs[msgs.length - 1]?.role === 'user' && !this.pinned()) {\n this.upwardWheel = undefined;\n this.pinned.set(true);\n this.followIfPinned();\n }\n });\n return;\n }\n if (!runActive)\n return;\n runActive = false;\n untracked(() => {\n if (this.agent().error() || this.agent().interrupt?.())\n return;\n this.runAnnouncement.set(this.showStoppedNotice() ? 'Response stopped.' : 'Response complete.');\n });\n });\n effect((onCleanup) => {\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const dispatchTasks = new Set>();\n const onWheel = (event: WheelEvent) => {\n if (event.deltaY >= 0 || event.ctrlKey || event.defaultPrevented)\n return;\n for (const target of event.composedPath()) {\n if (target === el)\n break;\n if (!(target instanceof HTMLElement) || target.scrollTop <= 0 || target.scrollHeight <= target.clientHeight)\n continue;\n if (/^(auto|scroll|overlay)$/.test(getComputedStyle(target).overflowY))\n return;\n }\n const previous = this.upwardWheel;\n if (previous && !previous.settled) {\n previous.events.push(event);\n return;\n }\n const intent = {\n events: [event], scrollTop: Math.max(this.programmaticScrollTop, el.scrollTop, previous?.scrollTop ?? 0),\n settled: false, supported: previous?.supported ?? false, followDeferred: previous?.followDeferred ?? false,\n };\n this.upwardWheel = intent;\n const task = setTimeout(() => {\n dispatchTasks.delete(task);\n if (this.upwardWheel !== intent || this.scrollContainer()?.nativeElement !== el)\n return;\n intent.settled = true;\n intent.supported ||= intent.events.some((wheel) => !wheel.defaultPrevented);\n intent.events = [];\n if (!intent.supported)\n this.upwardWheel = undefined;\n else if (el.scrollTop < intent.scrollTop)\n this.onScroll();\n const followDeferred = intent.followDeferred;\n intent.followDeferred = false;\n if (followDeferred)\n this.followIfPinned();\n }, 0);\n dispatchTasks.add(task);\n };\n el.addEventListener('wheel', onWheel, { passive: true });\n onCleanup(() => {\n el.removeEventListener('wheel', onWheel);\n for (const task of dispatchTasks)\n clearTimeout(task);\n dispatchTasks.clear();\n this.upwardWheel = undefined;\n });\n });\n effect(() => {\n let count: number;\n let msgs: ReturnType['messages']>;\n try {\n count = this.messageCount();\n msgs = this.agent().messages();\n }\n catch {\n return;\n }\n const lastContent = msgs.length > 0 ? (msgs[msgs.length - 1] as unknown as Record)['content'] : undefined;\n void lastContent;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const previousCount = this.prevMessageCount;\n const isNewMessage = count !== previousCount;\n this.prevMessageCount = count;\n const replaced = previousCount === 0 || count < previousCount;\n if (isNewMessage && (replaced || msgs[msgs.length - 1]?.role === 'user')) {\n this.upwardWheel = undefined;\n untracked(() => this.pinned.set(true));\n }\n if (isNewMessage || this.pinned())\n this.followIfPinned();\n });\n effect(() => {\n let loading: boolean;\n try {\n loading = this.agent().isLoading();\n }\n catch {\n return;\n }\n if (loading) {\n this.wasLoading = true;\n return;\n }\n if (!this.wasLoading)\n return;\n this.wasLoading = false;\n if (this.pinned()) {\n requestAnimationFrame(() => this.followIfPinned());\n }\n });\n effect((onCleanup) => {\n const regions = [this.runActivityRegion()?.nativeElement, this.interruptRegion()?.nativeElement]\n .filter((el): el is HTMLElement => !!el);\n if (regions.length === 0)\n return;\n if (typeof ResizeObserver === 'undefined') {\n requestAnimationFrame(() => this.followIfPinned());\n return;\n }\n const observer = new ResizeObserver(() => this.followIfPinned());\n for (const el of regions)\n observer.observe(el);\n onCleanup(() => observer.disconnect());\n });\n effect(() => {\n let agent: ReturnType;\n try {\n agent = this.agent();\n }\n catch {\n return;\n }\n const customSig = (agent as unknown as {\n customEvents?: () => readonly {\n name: string;\n data: unknown;\n }[];\n }).customEvents;\n if (typeof customSig !== 'function')\n return;\n const events = customSig();\n for (let i = this.partialEventsLastIndex; i < events.length; i++) {\n const e = events[i];\n if (e.name !== 'a2ui-partial')\n continue;\n const d = e.data as {\n tool_call_id?: string;\n args_so_far?: string;\n } | null;\n if (!d || typeof d.tool_call_id !== 'string' || typeof d.args_so_far !== 'string')\n continue;\n this.partialBridge.push(d.tool_call_id, d.args_so_far);\n }\n this.partialEventsLastIndex = events.length;\n });\n let connected: unknown;\n effect(() => {\n const coord = this.coordinator();\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n if (!coord || !agentRef)\n return;\n if (connected === coord)\n return;\n connected = coord;\n queueMicrotask(() => {\n runInInjectionContext(this.injector, () => coord.connect(agentRef));\n });\n });\n effect(() => {\n let liveIds: Set;\n try {\n liveIds = new Set();\n for (const m of this.agent().messages()) {\n const id = (m as unknown as {\n id?: string;\n }).id;\n if (id)\n liveIds.add(id);\n }\n }\n catch {\n return;\n }\n for (const key of [...this.classifiers.keys()]) {\n if (!liveIds.has(key)) {\n this.classifiers.get(key)?.classifier.dispose();\n this.classifiers.delete(key);\n }\n }\n for (const key of [...this.markdownDocuments.keys()]) {\n if (!liveIds.has(key))\n this.markdownDocuments.delete(key);\n }\n for (const key of [...this.activityContexts.keys()]) {\n if (!liveIds.has(key))\n this.activityContexts.delete(key);\n }\n });\n }\n prevRole(index: number): ChatMessageRole | undefined {\n if (index === 0)\n return undefined;\n const prev = this.agent().messages()[index - 1];\n if (!prev)\n return undefined;\n const role = (prev as unknown as {\n role?: string;\n }).role;\n if (role === 'user')\n return 'user';\n if (role === 'assistant')\n return 'assistant';\n if (role === 'system')\n return 'system';\n if (role === 'tool')\n return 'tool';\n return undefined;\n }\n protected onScroll(event?: Event): void {\n const el = this.scrollContainer()?.nativeElement;\n if (!el || (event && event.target !== el))\n return;\n const intent = this.upwardWheel;\n if (intent && !intent.settled)\n return;\n const userScrolledUp = intent && intent.supported && el.scrollTop < intent.scrollTop;\n if (intent && el.scrollTop !== intent.scrollTop)\n this.upwardWheel = undefined;\n if (this.programmaticScrollCount > 0 && !userScrolledUp)\n return;\n const nextPinned = isPinned(el.scrollHeight, el.scrollTop, el.clientHeight, ChatComponent.PIN_TOLERANCE_PX);\n if (nextPinned !== this.pinned())\n this.pinned.set(nextPinned);\n }\n scrollToBottom(): void {\n this.upwardWheel = undefined;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n this.programmaticScrollCount++;\n el.scrollTop = el.scrollHeight;\n this.programmaticScrollTop = el.scrollTop;\n requestAnimationFrame(() => { this.programmaticScrollCount--; });\n this.pinned.set(true);\n }\n private followIfPinned(): void {\n if (!untracked(() => this.pinned()))\n return;\n const el = this.scrollContainer()?.nativeElement;\n if (!el)\n return;\n const intent = this.upwardWheel;\n if (intent && el.scrollTop < intent.scrollTop) {\n if (!intent.settled) {\n intent.followDeferred = true;\n return;\n }\n this.onScroll();\n if (!untracked(() => this.pinned()))\n return;\n }\n this.programmaticScrollCount++;\n el.scrollTop = el.scrollHeight;\n this.programmaticScrollTop = el.scrollTop;\n if (intent && this.upwardWheel === intent)\n intent.scrollTop = this.programmaticScrollTop;\n requestAnimationFrame(() => { this.programmaticScrollCount--; });\n }\n protected onUserStopped(): void {\n if (typeof (this.agent() as {\n stop?: unknown;\n }).stop !== 'function')\n return;\n const users = this.agent().messages().filter((m) => m.role === 'user');\n const prompt = users[users.length - 1];\n this.stoppedTurn.set(prompt ? { userCount: users.length, prompt: messageContent(prompt) } : null);\n }\n protected onWelcomeFocusOut(event: FocusEvent): void {\n onFocusMovedAway(event, () => { this.welcomeHadFocus = false; });\n }\n protected onErrorFocusOut(event: FocusEvent): void {\n onFocusMovedAway(event, () => { this.errorHadFocus = false; });\n }\n protected isKeyboardFocus(event: FocusEvent): boolean {\n return matchesFocusVisible(event.target);\n }\n private continueInComposer(): void {\n requestAnimationFrame(() => {\n const active = this.composerDocument.activeElement;\n if (!active || active === this.composerDocument.body)\n this.composer()?.focusTextarea();\n });\n }\n protected onScrollBubbleClick(): void {\n const active = this.composerDocument.activeElement;\n const fromKeyboard = !!active?.closest('chat-scroll-bubble') && matchesFocusVisible(active);\n this.scrollToBottom();\n if (fromKeyboard)\n this.continueInComposer();\n }\n protected onUserSubmitted(): void {\n this.upwardWheel = undefined;\n this.pinned.set(true);\n this.recordSubmit();\n }\n submitMessage(text: string): void {\n const trimmed = text.trim();\n if (!trimmed || this.agent().isInputBlocked?.())\n return;\n void this.agent().submit({ message: trimmed });\n this.recordSubmit();\n }\n clearThread(): void {\n this.clearClassifiers();\n this.lifecycle._internal.messageCount.set(0);\n this.lifecycle._internal.inputSubmittedAt.set(null);\n }\n private recordSubmit(): void {\n if (!this.lifecycle._internal.firstMessageSent()) {\n this.lifecycle._internal.firstMessageSent.set(true);\n }\n this.lifecycle._internal.messageCount.update((c) => c + 1);\n this.lifecycle._internal.inputSubmittedAt.set(Date.now());\n }\n protected prevMessage(index: number): unknown {\n if (index === 0)\n return undefined;\n return this.agent().messages()[index - 1];\n }\n protected isGenuiTurn(message: unknown, _prevMsg: unknown, index?: number): boolean {\n const names = new Set(this.genuiToolNames());\n const m = message as {\n extra?: Record;\n } | null | undefined;\n if (!m)\n return false;\n const calls = (m.extra?.['tool_calls'] as Array<{\n name?: string;\n }> | undefined) ?? [];\n if (calls.some(c => c.name != null && names.has(c.name)))\n return true;\n const rawContent = m.extra?.['content'];\n if (Array.isArray(rawContent)) {\n for (const block of rawContent) {\n if (block != null\n && typeof block === 'object'\n && (block as {\n type?: unknown;\n }).type === 'function_call'\n && typeof (block as {\n name?: unknown;\n }).name === 'string'\n && names.has((block as {\n name: string;\n }).name)) {\n return true;\n }\n }\n }\n const projectedContent = (m as {\n content?: unknown;\n }).content;\n if (typeof projectedContent === 'string' && projectedContent.length > 0) {\n if (projectedContent.includes('\"createSurface\"')\n || projectedContent.includes('\"updateComponents\"')\n || projectedContent.includes('\"updateDataModel\"')) {\n return true;\n }\n if (projectedContent.includes('\"root\"') && projectedContent.includes('\"elements\"')) {\n return true;\n }\n }\n const p = _prevMsg as {\n role?: string;\n name?: string;\n extra?: Record;\n } | null | undefined;\n if (p && p.role === 'tool') {\n const toolName = (p.extra?.['name'] as string | undefined) ?? p.name;\n if (typeof toolName === 'string' && names.has(toolName))\n return true;\n }\n if (typeof index === 'number' && index > 0) {\n const msgs = this.agent().messages();\n for (let i = index - 1; i >= 0; i--) {\n const prev = msgs[i] as {\n role?: string;\n extra?: Record;\n };\n if (!prev)\n break;\n if (prev.role === 'user')\n break;\n const prevCalls = (prev.extra?.['tool_calls'] as Array<{\n name?: string;\n }> | undefined) ?? [];\n if (prevCalls.some(c => c.name != null && names.has(c.name)))\n return true;\n const prevRaw = prev.extra?.['content'];\n if (Array.isArray(prevRaw)) {\n for (const block of prevRaw) {\n if (block != null\n && typeof block === 'object'\n && (block as {\n type?: unknown;\n }).type === 'function_call'\n && typeof (block as {\n name?: unknown;\n }).name === 'string'\n && names.has((block as {\n name: string;\n }).name)) {\n return true;\n }\n }\n }\n }\n }\n return false;\n }\n classifyMessage(content: string, message: Pick): ContentClassifier {\n const generation = message.delivery.generation;\n let entry = this.classifiers.get(message.id);\n if (!entry || entry.generation !== generation) {\n entry?.classifier.dispose();\n entry = { generation, classifier: createContentClassifier() };\n this.classifiers.set(message.id, entry);\n }\n if (entry.content !== content) {\n entry.classifier.update(content);\n entry.content = content;\n }\n return entry.classifier;\n }\n protected markdownDocumentFor(content: string, message: Pick): StreamingMarkdownDocument {\n const prior = this.markdownDocuments.get(message.id);\n const delivery = message.delivery;\n if (prior?.generation === delivery.generation &&\n prior.phase === delivery.phase &&\n prior.content === content) {\n return prior;\n }\n const document = markdownDocument(content, delivery);\n this.markdownDocuments.set(message.id, document);\n return document;\n }\n clearClassifiers(): void {\n for (const [, entry] of this.classifiers) {\n entry.classifier.dispose();\n }\n this.classifiers.clear();\n this.markdownDocuments.clear();\n this.activityContexts.clear();\n }\n onSpecEvent(event: RenderEvent, messageIndex: number): void {\n this.renderEvent.emit({ messageIndex, event });\n }\n protected onClientToolEvent(event: RenderEvent): void {\n const coord = this.coordinator();\n if (!coord)\n return;\n let agentRef: ReturnType;\n try {\n agentRef = this.agent();\n }\n catch {\n return;\n }\n coord.handleRenderEvent(agentRef, event);\n }\n onA2uiAction(message: A2uiActionMessage): void {\n if (this.agent().isInputBlocked?.())\n return;\n void this.agent().submit({ message: JSON.stringify(message) });\n }\n onA2uiEvent(event: RenderEvent, messageIndex: number, surfaceId: string): void {\n this.renderEvent.emit({ messageIndex, surfaceId, event });\n }\n onRegenerate(messageIndex: number): void {\n this.upwardWheel = undefined;\n this.pinned.set(true);\n void this.agent().regenerate(messageIndex);\n this.regenerate.emit();\n }\n onRate(message: unknown, value: 'up' | 'down'): void {\n const idx = this.agent().messages().indexOf(message as never);\n this.rate.emit({ messageIndex: idx, rating: value });\n }\n onCopy(message: unknown, content: string): void {\n const idx = this.agent().messages().indexOf(message as never);\n this.messageCopy.emit({ messageIndex: idx, content });\n }\n}" } ] }, @@ -5598,7 +5594,7 @@ "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", "symbol": "ChatInputComponent", "syntaxKind": "ClassDeclaration", - "signature": "@Component({\n selector: 'chat-input',\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_INPUT_STYLES],\n template: `\n
\n \n \n
\n \n \n
\n \n \n @if (isLoading() && canStop()) {\n \n \n \n \n \n } @else {\n \n \n \n \n \n \n }\n
\n
\n \n
\n `,\n})\nexport class ChatInputComponent {\n readonly agent = input.required();\n readonly submitOnEnter = input(true);\n readonly placeholder = input('');\n readonly showStopButton = input(true);\n readonly submitted = output();\n readonly stopped = output();\n readonly messageText = signal('');\n readonly isLoading = computed(() => this.agent().isLoading());\n protected readonly composing = signal(false);\n readonly focused = signal(false);\n readonly canSubmit = computed(() => {\n if (this.isLoading() || this.agent().isInputBlocked?.())\n return false;\n return this.messageText().trim().length > 0;\n });\n readonly canStop = computed(() => this.showStopButton());\n private readonly textareaEl = viewChild>('textareaEl');\n constructor() {\n effect(() => {\n const text = this.messageText();\n const el = this.textareaEl()?.nativeElement;\n if (!el)\n return;\n const viewportH = typeof window === 'undefined' ? 600 : window.innerHeight;\n const cap = Math.min(viewportH * 0.4, 320);\n el.style.height = 'auto';\n const next = Math.min(el.scrollHeight, cap);\n el.style.height = `${next}px`;\n el.style.overflowY = el.scrollHeight > cap ? 'auto' : 'hidden';\n void text;\n });\n }\n focusTextarea(): void {\n this.textareaEl()?.nativeElement.focus();\n }\n onSubmit(): void {\n const submitted = submitMessage(this.agent(), this.messageText());\n if (submitted !== null) {\n this.submitted.emit(submitted);\n this.messageText.set('');\n const el = this.textareaEl()?.nativeElement;\n if (el)\n el.value = '';\n requestAnimationFrame(() => this.textareaEl()?.nativeElement.focus());\n }\n }\n protected onInput(event: Event): void {\n this.messageText.set((event.target as HTMLTextAreaElement).value);\n }\n onStop(): void {\n const a = this.agent() as unknown as {\n stop?: () => void | Promise;\n };\n if (typeof a.stop === 'function') {\n void a.stop();\n }\n this.stopped.emit();\n }\n onKeydown(event: KeyboardEvent): void {\n if (!this.submitOnEnter() || event.shiftKey)\n return;\n if (this.composing() || event.isComposing || event.keyCode === 229)\n return;\n event.preventDefault();\n this.onSubmit();\n }\n}" + "signature": "@Component({\n selector: 'chat-input',\n standalone: true,\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_INPUT_STYLES],\n template: `\n
\n \n \n
\n \n \n
\n \n \n @if (isLoading() && canStop()) {\n \n \n \n \n \n } @else {\n \n \n \n \n \n \n }\n
\n
\n \n
\n @if (showRunningHint()) {\n A response is still running. Stop it to send now, or send when it finishes.\n }\n
\n \n
\n `,\n})\nexport class ChatInputComponent {\n readonly agent = input.required();\n readonly submitOnEnter = input(true);\n readonly placeholder = input('');\n readonly showStopButton = input(true);\n readonly submitted = output();\n readonly stopped = output();\n readonly messageText = signal('');\n readonly isLoading = computed(() => this.agent().isLoading());\n protected readonly composing = signal(false);\n readonly focused = signal(false);\n readonly canSubmit = computed(() => {\n if (this.isLoading() || this.agent().isInputBlocked?.())\n return false;\n return this.messageText().trim().length > 0;\n });\n readonly canStop = computed(() => this.showStopButton());\n private readonly textareaEl = viewChild>('textareaEl');\n protected stopHadFocus = false;\n private readonly enterBlockedByRun = signal(false);\n protected readonly showRunningHint = computed(() => this.enterBlockedByRun() && this.isLoading());\n constructor() {\n effect(() => {\n const text = this.messageText();\n const el = this.textareaEl()?.nativeElement;\n if (!el)\n return;\n const viewportH = typeof window === 'undefined' ? 600 : window.innerHeight;\n const cap = Math.min(viewportH * 0.4, 320);\n el.style.height = 'auto';\n const next = Math.min(el.scrollHeight, cap);\n el.style.height = `${next}px`;\n el.style.overflowY = el.scrollHeight > cap ? 'auto' : 'hidden';\n void text;\n });\n effect(() => {\n if (!this.isLoading())\n this.enterBlockedByRun.set(false);\n });\n effect(() => {\n if (this.isLoading() || !this.stopHadFocus)\n return;\n this.stopHadFocus = false;\n requestAnimationFrame(() => {\n const active = this.textareaEl()?.nativeElement.ownerDocument.activeElement;\n if (!active || active === active.ownerDocument.body)\n this.focusTextarea();\n });\n });\n }\n focusTextarea(): void {\n this.textareaEl()?.nativeElement.focus();\n }\n onSubmit(): void {\n if (!this.canSubmit()) {\n if (this.isLoading() && this.messageText().trim())\n this.enterBlockedByRun.set(true);\n return;\n }\n const submitted = submitMessage(this.agent(), this.messageText());\n if (submitted !== null) {\n this.submitted.emit(submitted);\n this.messageText.set('');\n const el = this.textareaEl()?.nativeElement;\n if (el)\n el.value = '';\n requestAnimationFrame(() => this.textareaEl()?.nativeElement.focus());\n }\n }\n protected onInput(event: Event): void {\n this.messageText.set((event.target as HTMLTextAreaElement).value);\n }\n protected onStopFocus(event: FocusEvent): void {\n try {\n this.stopHadFocus = (event.target as Element).matches(':focus-visible');\n }\n catch {\n this.stopHadFocus = false;\n }\n }\n protected onStopBlur(event: FocusEvent): void {\n if (event.relatedTarget) {\n this.stopHadFocus = false;\n return;\n }\n const button = event.target as Node;\n setTimeout(() => { if (button.isConnected)\n this.stopHadFocus = false; }, 0);\n }\n onStop(): void {\n const a = this.agent() as unknown as {\n stop?: () => void | Promise;\n };\n if (typeof a.stop === 'function') {\n void a.stop();\n }\n this.stopped.emit();\n }\n onKeydown(event: KeyboardEvent): void {\n if (!this.submitOnEnter() || event.shiftKey)\n return;\n if (this.composing() || event.isComposing || event.keyCode === 229)\n return;\n event.preventDefault();\n this.onSubmit();\n }\n}" } ] }, @@ -5710,7 +5706,7 @@ "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", "symbol": "ChatMessageComponent", "syntaxKind": "ClassDeclaration", - "signature": "@Component({\n selector: 'chat-message',\n standalone: true,\n imports: [ChatCitationsComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_STYLES],\n providers: [CitationsResolverService],\n host: {\n '[attr.data-role]': 'role()',\n '[attr.data-current]': 'currentStr()',\n '[attr.data-streaming]': 'streamingStr()',\n '[attr.data-prev-role]': 'prevRole() ?? null',\n },\n template: `\n
\n \n \n
\n @if (message()?.role === 'assistant' && message(); as msg) {\n \n }\n
\n \n
\n `,\n})\nexport class ChatMessageComponent {\n readonly role = input.required();\n readonly current = input(false);\n readonly streaming = input(false);\n readonly prevRole = input(undefined);\n readonly message = input(undefined);\n private readonly resolver = inject(CitationsResolverService);\n constructor() {\n effect(() => {\n this.resolver.message.set(this.message() ?? null);\n });\n }\n readonly currentStr = computed(() => String(this.current()));\n readonly streamingStr = computed(() => String(this.streaming()));\n readonly bodyClass = computed(() => {\n switch (this.role()) {\n case 'user': return 'chat-message__bubble';\n case 'assistant': return 'chat-message__assistant-body';\n default: return 'chat-message__plain';\n }\n });\n}" + "signature": "@Component({\n selector: 'chat-message',\n standalone: true,\n imports: [ChatCitationsComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_MESSAGE_STYLES],\n providers: [CitationsResolverService],\n host: {\n '[attr.data-role]': 'role()',\n '[attr.data-current]': 'currentStr()',\n '[attr.data-streaming]': 'streamingStr()',\n '[attr.data-prev-role]': 'prevRole() ?? null',\n },\n template: `\n @if (speakerText(); as speaker) {\n {{ speaker }}\n }\n \n
\n \n \n
\n @if (message()?.role === 'assistant' && message(); as msg) {\n \n }\n
\n \n
\n `,\n})\nexport class ChatMessageComponent {\n readonly role = input.required();\n readonly current = input(false);\n readonly streaming = input(false);\n readonly prevRole = input(undefined);\n readonly message = input(undefined);\n readonly speaker = input(undefined);\n private readonly resolver = inject(CitationsResolverService);\n constructor() {\n effect(() => {\n this.resolver.message.set(this.message() ?? null);\n });\n }\n protected readonly speakerText = computed(() => {\n const name = this.speaker() ?? DEFAULT_SPEAKERS[this.role()];\n return name ? `${name}:` : undefined;\n });\n readonly currentStr = computed(() => String(this.current()));\n readonly streamingStr = computed(() => String(this.streaming()));\n readonly bodyClass = computed(() => {\n switch (this.role()) {\n case 'user': return 'chat-message__bubble';\n case 'assistant': return 'chat-message__assistant-body';\n default: return 'chat-message__plain';\n }\n });\n}" } ] }, @@ -5864,7 +5860,7 @@ "path": "libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts", "symbol": "ChatScrollBubbleComponent", "syntaxKind": "ClassDeclaration", - "signature": "@Component({\n selector: 'chat-scroll-bubble',\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_SCROLL_BUBBLE_STYLES],\n template: `\n \n @if (mode() === 'streaming') {\n \n \n \n \n \n } @else {\n \n \n \n \n }\n \n `,\n})\nexport class ChatScrollBubbleComponent {\n readonly mode = input.required();\n readonly clicked = output();\n protected readonly ariaLabel = computed(() => this.mode() === 'streaming' ? 'Latest activity' : 'Scroll to latest');\n}" + "signature": "@Component({\n selector: 'chat-scroll-bubble',\n standalone: true,\n changeDetection: ChangeDetectionStrategy.OnPush,\n styles: [CHAT_HOST_TOKENS, CHAT_SCROLL_BUBBLE_STYLES],\n template: `\n \n @if (mode() === 'streaming') {\n \n \n \n \n \n } @else {\n \n \n \n \n }\n \n `,\n})\nexport class ChatScrollBubbleComponent {\n readonly mode = input.required();\n readonly clicked = output();\n protected readonly ariaLabel = computed(() => this.mode() === 'streaming' ? 'Scroll to latest, response in progress' : 'Scroll to latest');\n}" } ] }, @@ -13656,7 +13652,7 @@ "id": "source:libs/chat/src/lib/compositions/chat/chat.component.ts", "kind": "source", "path": "libs/chat/src/lib/compositions/chat/chat.component.ts", - "sha256": "ce8a284c8c865ca7ace5b239a35cacc2203b0263a8e7d23ebd2965c896ae534e" + "sha256": "9d4c822c50879809df1b3438423a9fa1523921014bb38c3f6f7b9b45586bf97e" }, { "id": "source:libs/chat/src/lib/compositions/shared/message-utils.ts", @@ -13944,7 +13940,7 @@ "id": "source:libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", "kind": "source", "path": "libs/chat/src/lib/primitives/chat-input/chat-input.component.ts", - "sha256": "1c57b73fb02cb79578b8e178016841e568d85172c9ec81dcd0a8de4e3f2178a2" + "sha256": "0b0557f2de3741142450e27e3da8259c5e2181e5193c9f94feccc6dc4496afae" }, { "id": "source:libs/chat/src/lib/primitives/chat-interrupt/chat-interrupt.component.ts", @@ -13980,7 +13976,7 @@ "id": "source:libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", "kind": "source", "path": "libs/chat/src/lib/primitives/chat-message/chat-message.component.ts", - "sha256": "ffe3423e638c2fbf6aa5be38b54ee469bbbc97566e700107560e633203738bac" + "sha256": "07563dd198aedf9ba53767ef4d16899ce1624402bab314d0a53d73270495a376" }, { "id": "source:libs/chat/src/lib/primitives/chat-overflow-menu/chat-overflow-menu.component.ts", @@ -14004,7 +14000,7 @@ "id": "source:libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts", "kind": "source", "path": "libs/chat/src/lib/primitives/chat-scroll-bubble/chat-scroll-bubble.component.ts", - "sha256": "332b5f9d894f54451c286a57d6ac70705f1fe2e4c492fd0dd0a1a22a1202bedd" + "sha256": "c4d0a592298ebd56ec8fb210d830b3d9b205f1ffeedc3ae3aa4e77b08fd5f061" }, { "id": "source:libs/chat/src/lib/primitives/chat-select/chat-select.component.ts", @@ -14172,7 +14168,7 @@ "id": "source:libs/chat/src/lib/styles/chat-error.styles.ts", "kind": "source", "path": "libs/chat/src/lib/styles/chat-error.styles.ts", - "sha256": "140ef42c4088e1fd6fadb0f91904e49451f6efe80cee32245ace8c2794797ef7" + "sha256": "89c698a9e6ae65b57653584c924bd4e39db5acfa544aec9e0b937213024e2ec8" }, { "id": "source:libs/chat/src/lib/styles/chat-generative-ui.styles.ts", @@ -14196,7 +14192,7 @@ "id": "source:libs/chat/src/lib/styles/chat-input.styles.ts", "kind": "source", "path": "libs/chat/src/lib/styles/chat-input.styles.ts", - "sha256": "236a90847ba5a12e10deac5ea2e3b491a47d6ebc34b5aefb2bf0c55cf1368f6b" + "sha256": "6b2250ffd09d9f56989c17f4c964d773819d10f437fd8d984c2a2cc77ecb1959" }, { "id": "source:libs/chat/src/lib/styles/chat-interrupt.styles.ts", @@ -14220,7 +14216,7 @@ "id": "source:libs/chat/src/lib/styles/chat-message-actions.styles.ts", "kind": "source", "path": "libs/chat/src/lib/styles/chat-message-actions.styles.ts", - "sha256": "daaac0f3ef234594e985371155c152b99ba75e0d1e337bc41d4c9e83eaab8db4" + "sha256": "b9986bb3616f9ed1aca8fbaed9b68b14339862f4a4f9fca44b6c621b0f34556b" }, { "id": "source:libs/chat/src/lib/styles/chat-message-list.styles.ts", @@ -14232,7 +14228,7 @@ "id": "source:libs/chat/src/lib/styles/chat-message.styles.ts", "kind": "source", "path": "libs/chat/src/lib/styles/chat-message.styles.ts", - "sha256": "410d4477a99828be94464a491eae0a1789ed777782914d196e98c401cebea9e3" + "sha256": "e62e796b427174eef6c5a3bf0732e37688d201981915c781799d0e4743861b4d" }, { "id": "source:libs/chat/src/lib/styles/chat-overflow-menu.styles.ts", @@ -14286,7 +14282,7 @@ "id": "source:libs/chat/src/lib/styles/chat-tokens.ts", "kind": "source", "path": "libs/chat/src/lib/styles/chat-tokens.ts", - "sha256": "e66391d2826a93642a3f180f928b35bad7ee3782dac3091983efc06526450668" + "sha256": "c787062467830bf44601b5781f55f1cef3551264ad9ea4ce88adf6795cc4372b" }, { "id": "source:libs/chat/src/lib/styles/chat-trace.styles.ts", @@ -15042,7 +15038,7 @@ "id": "source:libs/langgraph/src/lib/agent.fn.ts", "kind": "source", "path": "libs/langgraph/src/lib/agent.fn.ts", - "sha256": "923e541fe5365584bfa67e1c047d67046fa8427ad83fe4618648233be85cdbd6" + "sha256": "9a01d7ea99d43c939c066a2dd11176efccf8a6e5de4d8048ac4523c54390ca6b" }, { "id": "source:libs/langgraph/src/lib/agent.provider.ts",