diff --git a/apps/website/content/docs/chat/api/api-docs.json b/apps/website/content/docs/chat/api/api-docs.json index aa5af03ea..aee9000e8 100644 --- a/apps/website/content/docs/chat/api/api-docs.json +++ b/apps/website/content/docs/chat/api/api-docs.json @@ -2318,6 +2318,12 @@ } ] }, + { + "name": "scrollToBottom", + "signature": "scrollToBottom(): void", + "description": "Scrolls the transcript to its newest content and re-pins it, exactly as\nthe scroll-to-bottom bubble does. Hosts that apply many messages in one\npass (a replay seeking to a recorded time, a restored thread rendered\nafter tool views mount) call this once their layout has settled: the\nchat's own pin runs when messages change, which can be before the views\nthat follow them have rendered. The write is counted as programmatic so\nthe scroll event it raises does not read as the user unpinning.", + "params": [] + }, { "name": "submitMessage", "signature": "submitMessage(text: string): void", diff --git a/examples/chat/angular/src/app/stage/stage-mode.component.ts b/examples/chat/angular/src/app/stage/stage-mode.component.ts index 7e1427c57..bb978868e 100644 --- a/examples/chat/angular/src/app/stage/stage-mode.component.ts +++ b/examples/chat/angular/src/app/stage/stage-mode.component.ts @@ -2,7 +2,6 @@ import { ChangeDetectionStrategy, Component, DestroyRef, - ElementRef, EnvironmentInjector, InjectionToken, afterNextRender, @@ -282,6 +281,7 @@ export class StageMode { protected readonly storageKey = `stage-debug-${Date.now()}`; protected readonly dock = signal(readStageDock()); private readonly debugPanel = viewChild(ChatDebugComponent); + private readonly chat = viewChild(ChatComponent); readonly timeline = signal(null); readonly controller = signal(null); @@ -292,7 +292,6 @@ export class StageMode { ? { onSeek: () => () => undefined, postReady: () => undefined, postState: () => undefined } : browserStageBridge(); - private readonly host = inject>(ElementRef); private lastPosted = ''; private seekTarget: number | null = null; private seekFrame: number | null = null; @@ -444,13 +443,9 @@ export class StageMode { * stage is a scrubbed display surface, not a reading surface, so a viewer * who scrolls up is re-pinned on the next applied seek. * - * `.chat-scroll` is the chat's own scroll container - * (libs/chat/.../chat.component.ts, `#scrollContainer`); it exposes no - * scroll API. - * TODO: replace with a public scrollToBottom() on ChatComponent - * (libs/chat/src/lib/compositions/chat/chat.component.ts, today protected - * onScrollBubbleClick) so this stops depending on the private .chat-scroll - * class. + * The write goes through `ChatComponent.scrollToBottom()`, which counts it + * as programmatic so the chat's own scroll handler does not read it as the + * user unpinning. */ private pinTranscript(): void { if (typeof requestAnimationFrame !== 'function') return; @@ -463,8 +458,7 @@ export class StageMode { this.pinPending = false; this.pinFrame = requestAnimationFrame(() => { this.pinFrame = null; - const el = this.host.nativeElement.querySelector('chat .chat-scroll'); - if (el) el.scrollTop = el.scrollHeight; + this.chat()?.scrollToBottom(); if (this.pinPending) { this.pinPending = false; this.pinTranscript(); 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 e32d927af..30e3a9106 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.spec.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.spec.ts @@ -1095,3 +1095,33 @@ describe('ChatComponent — markdown delivery', () => { expect(markdown.componentInstance.document()).toEqual(expected); }); }); + +describe('ChatComponent — scrollToBottom', () => { + it('scrolls the transcript to its end, re-pins, and does not read its own scroll as the user unpinning', () => { + TestBed.configureTestingModule({}); + const fx = TestBed.createComponent(ChatComponent); + // The transcript (and its scroll container) renders only once a message + // exists; with none the chat shows its welcome screen. + fx.componentRef.setInput( + 'agent', + mockAgent({ messages: [{ id: '1', role: 'assistant', content: 'Hi', delivery: staticDelivery('1') }] }), + ); + fx.detectChanges(); + const el = (fx.nativeElement as HTMLElement).querySelector('.chat-scroll'); + if (!el) throw new Error('scroll container did not render'); + Object.defineProperty(el, 'scrollHeight', { configurable: true, value: 2400 }); + Object.defineProperty(el, 'clientHeight', { configurable: true, value: 400 }); + Object.defineProperty(el, 'scrollTop', { configurable: true, writable: true, value: 0 }); + const cmp = fx.componentInstance as unknown as { pinned: { set(v: boolean): void; (): boolean }; onScroll(): void }; + // A user scrolled away: pinned is false and a scroll event would keep it so. + cmp.pinned.set(false); + fx.componentInstance.scrollToBottom(); + expect(el.scrollTop).toBe(2400); + expect(cmp.pinned()).toBe(true); + // The scroll event raised by the programmatic write must not unpin, even + // though scrollTop is read back mid-frame before layout settles. + Object.defineProperty(el, 'scrollTop', { configurable: true, writable: true, value: 0 }); + cmp.onScroll(); + expect(cmp.pinned()).toBe(true); + }); +}); diff --git a/libs/chat/src/lib/compositions/chat/chat.component.ts b/libs/chat/src/lib/compositions/chat/chat.component.ts index 586119e37..1228dcb89 100644 --- a/libs/chat/src/lib/compositions/chat/chat.component.ts +++ b/libs/chat/src/lib/compositions/chat/chat.component.ts @@ -806,7 +806,16 @@ export class ChatComponent { if (nextPinned !== this.pinned()) this.pinned.set(nextPinned); } - protected onScrollBubbleClick(): void { + /** + * Scrolls the transcript to its newest content and re-pins it, exactly as + * the scroll-to-bottom bubble does. Hosts that apply many messages in one + * pass (a replay seeking to a recorded time, a restored thread rendered + * after tool views mount) call this once their layout has settled: the + * chat's own pin runs when messages change, which can be before the views + * that follow them have rendered. The write is counted as programmatic so + * the scroll event it raises does not read as the user unpinning. + */ + scrollToBottom(): void { const el = this.scrollContainer()?.nativeElement; if (!el) return; this.programmaticScrollCount++; @@ -815,6 +824,10 @@ export class ChatComponent { this.pinned.set(true); } + protected onScrollBubbleClick(): void { + this.scrollToBottom(); + } + protected onUserSubmitted(): void { this.pinned.set(true); this.recordSubmit();