Skip to content

Commit 86683ee

Browse files
committed
Merge remote-tracking branch 'origin/main' into blove/growth-dawn-vercel
2 parents 11312c2 + d30fd1e commit 86683ee

4 files changed

Lines changed: 55 additions & 12 deletions

File tree

apps/website/content/docs/chat/api/api-docs.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2318,6 +2318,12 @@
23182318
}
23192319
]
23202320
},
2321+
{
2322+
"name": "scrollToBottom",
2323+
"signature": "scrollToBottom(): void",
2324+
"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.",
2325+
"params": []
2326+
},
23212327
{
23222328
"name": "submitMessage",
23232329
"signature": "submitMessage(text: string): void",

examples/chat/angular/src/app/stage/stage-mode.component.ts

Lines changed: 5 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import {
22
ChangeDetectionStrategy,
33
Component,
44
DestroyRef,
5-
ElementRef,
65
EnvironmentInjector,
76
InjectionToken,
87
afterNextRender,
@@ -282,6 +281,7 @@ export class StageMode {
282281
protected readonly storageKey = `stage-debug-${Date.now()}`;
283282
protected readonly dock = signal<StageDock>(readStageDock());
284283
private readonly debugPanel = viewChild(ChatDebugComponent);
284+
private readonly chat = viewChild(ChatComponent);
285285

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

295-
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
296295
private lastPosted = '';
297296
private seekTarget: number | null = null;
298297
private seekFrame: number | null = null;
@@ -444,13 +443,9 @@ export class StageMode {
444443
* stage is a scrubbed display surface, not a reading surface, so a viewer
445444
* who scrolls up is re-pinned on the next applied seek.
446445
*
447-
* `.chat-scroll` is the chat's own scroll container
448-
* (libs/chat/.../chat.component.ts, `#scrollContainer`); it exposes no
449-
* scroll API.
450-
* TODO: replace with a public scrollToBottom() on ChatComponent
451-
* (libs/chat/src/lib/compositions/chat/chat.component.ts, today protected
452-
* onScrollBubbleClick) so this stops depending on the private .chat-scroll
453-
* class.
446+
* The write goes through `ChatComponent.scrollToBottom()`, which counts it
447+
* as programmatic so the chat's own scroll handler does not read it as the
448+
* user unpinning.
454449
*/
455450
private pinTranscript(): void {
456451
if (typeof requestAnimationFrame !== 'function') return;
@@ -463,8 +458,7 @@ export class StageMode {
463458
this.pinPending = false;
464459
this.pinFrame = requestAnimationFrame(() => {
465460
this.pinFrame = null;
466-
const el = this.host.nativeElement.querySelector<HTMLElement>('chat .chat-scroll');
467-
if (el) el.scrollTop = el.scrollHeight;
461+
this.chat()?.scrollToBottom();
468462
if (this.pinPending) {
469463
this.pinPending = false;
470464
this.pinTranscript();

libs/chat/src/lib/compositions/chat/chat.component.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1095,3 +1095,33 @@ describe('ChatComponent — markdown delivery', () => {
10951095
expect(markdown.componentInstance.document()).toEqual(expected);
10961096
});
10971097
});
1098+
1099+
describe('ChatComponent — scrollToBottom', () => {
1100+
it('scrolls the transcript to its end, re-pins, and does not read its own scroll as the user unpinning', () => {
1101+
TestBed.configureTestingModule({});
1102+
const fx = TestBed.createComponent(ChatComponent);
1103+
// The transcript (and its scroll container) renders only once a message
1104+
// exists; with none the chat shows its welcome screen.
1105+
fx.componentRef.setInput(
1106+
'agent',
1107+
mockAgent({ messages: [{ id: '1', role: 'assistant', content: 'Hi', delivery: staticDelivery('1') }] }),
1108+
);
1109+
fx.detectChanges();
1110+
const el = (fx.nativeElement as HTMLElement).querySelector<HTMLElement>('.chat-scroll');
1111+
if (!el) throw new Error('scroll container did not render');
1112+
Object.defineProperty(el, 'scrollHeight', { configurable: true, value: 2400 });
1113+
Object.defineProperty(el, 'clientHeight', { configurable: true, value: 400 });
1114+
Object.defineProperty(el, 'scrollTop', { configurable: true, writable: true, value: 0 });
1115+
const cmp = fx.componentInstance as unknown as { pinned: { set(v: boolean): void; (): boolean }; onScroll(): void };
1116+
// A user scrolled away: pinned is false and a scroll event would keep it so.
1117+
cmp.pinned.set(false);
1118+
fx.componentInstance.scrollToBottom();
1119+
expect(el.scrollTop).toBe(2400);
1120+
expect(cmp.pinned()).toBe(true);
1121+
// The scroll event raised by the programmatic write must not unpin, even
1122+
// though scrollTop is read back mid-frame before layout settles.
1123+
Object.defineProperty(el, 'scrollTop', { configurable: true, writable: true, value: 0 });
1124+
cmp.onScroll();
1125+
expect(cmp.pinned()).toBe(true);
1126+
});
1127+
});

libs/chat/src/lib/compositions/chat/chat.component.ts

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -806,7 +806,16 @@ export class ChatComponent {
806806
if (nextPinned !== this.pinned()) this.pinned.set(nextPinned);
807807
}
808808

809-
protected onScrollBubbleClick(): void {
809+
/**
810+
* Scrolls the transcript to its newest content and re-pins it, exactly as
811+
* the scroll-to-bottom bubble does. Hosts that apply many messages in one
812+
* pass (a replay seeking to a recorded time, a restored thread rendered
813+
* after tool views mount) call this once their layout has settled: the
814+
* chat's own pin runs when messages change, which can be before the views
815+
* that follow them have rendered. The write is counted as programmatic so
816+
* the scroll event it raises does not read as the user unpinning.
817+
*/
818+
scrollToBottom(): void {
810819
const el = this.scrollContainer()?.nativeElement;
811820
if (!el) return;
812821
this.programmaticScrollCount++;
@@ -815,6 +824,10 @@ export class ChatComponent {
815824
this.pinned.set(true);
816825
}
817826

827+
protected onScrollBubbleClick(): void {
828+
this.scrollToBottom();
829+
}
830+
818831
protected onUserSubmitted(): void {
819832
this.pinned.set(true);
820833
this.recordSubmit();

0 commit comments

Comments
 (0)