Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/website/content/docs/chat/api/api-docs.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 5 additions & 11 deletions examples/chat/angular/src/app/stage/stage-mode.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import {
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
EnvironmentInjector,
InjectionToken,
afterNextRender,
Expand Down Expand Up @@ -282,6 +281,7 @@ export class StageMode {
protected readonly storageKey = `stage-debug-${Date.now()}`;
protected readonly dock = signal<StageDock>(readStageDock());
private readonly debugPanel = viewChild(ChatDebugComponent);
private readonly chat = viewChild(ChatComponent);

readonly timeline = signal<StageTimeline | null>(null);
readonly controller = signal<StageController | null>(null);
Expand All @@ -292,7 +292,6 @@ export class StageMode {
? { onSeek: () => () => undefined, postReady: () => undefined, postState: () => undefined }
: browserStageBridge();

private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private lastPosted = '';
private seekTarget: number | null = null;
private seekFrame: number | null = null;
Expand Down Expand Up @@ -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;
Expand All @@ -463,8 +458,7 @@ export class StageMode {
this.pinPending = false;
this.pinFrame = requestAnimationFrame(() => {
this.pinFrame = null;
const el = this.host.nativeElement.querySelector<HTMLElement>('chat .chat-scroll');
if (el) el.scrollTop = el.scrollHeight;
this.chat()?.scrollToBottom();
if (this.pinPending) {
this.pinPending = false;
this.pinTranscript();
Expand Down
30 changes: 30 additions & 0 deletions libs/chat/src/lib/compositions/chat/chat.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<HTMLElement>('.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);
});
});
15 changes: 14 additions & 1 deletion libs/chat/src/lib/compositions/chat/chat.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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++;
Expand All @@ -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();
Expand Down
Loading