Skip to content

Commit a709fef

Browse files
google-genai-botcopybara-github
authored andcommitted
ADK changes
PiperOrigin-RevId: 855601238
1 parent 958c8d6 commit a709fef

File tree

10 files changed

+363
-219
lines changed

10 files changed

+363
-219
lines changed

src/app/components/chat-panel/chat-panel.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
@let isSessionLoading = uiStateService.isSessionLoading() | async;
1818
@if (appName != "" && !isSessionLoading) {
1919
<div #autoScroll class="chat-messages" (scroll)="onScroll.next($event)">
20-
@if(uiStateService.isMessagesLoading() | async) {
20+
@if((uiStateService.isMessagesLoading() | async) && (featureFlagService.isInfinityMessageScrollingEnabled() | async)) {
2121
<div class="messages-loading-container">
2222
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
2323
</div>

src/app/components/chat-panel/chat-panel.component.spec.ts

Lines changed: 174 additions & 99 deletions
Original file line numberDiff line numberDiff line change
@@ -181,15 +181,18 @@ describe('ChatPanelComponent', () => {
181181
});
182182

183183
describe('Eval Edit Mode', () => {
184+
beforeEach(() => {
185+
component.evalCase = {
186+
evalId: '1',
187+
conversation: [],
188+
sessionInput: {},
189+
creationTimestamp: 123,
190+
};
191+
component.isEvalEditMode = true;
192+
});
193+
184194
it(
185195
'should show edit/delete buttons for text messages', async () => {
186-
component.evalCase = {
187-
evalId: '1',
188-
conversation: [],
189-
sessionInput: {},
190-
creationTimestamp: 123,
191-
};
192-
component.isEvalEditMode = true;
193196
component.messages =
194197
[{role: 'bot', text: 'eval message', eventId: '1'}];
195198
fixture.detectChanges();
@@ -203,13 +206,6 @@ describe('ChatPanelComponent', () => {
203206
});
204207

205208
it('should show edit button for function calls', async () => {
206-
component.evalCase = {
207-
evalId: '1',
208-
conversation: [],
209-
sessionInput: {},
210-
creationTimestamp: 123,
211-
};
212-
component.isEvalEditMode = true;
213209
component.messages =
214210
[{role: 'bot', functionCall: {name: 'func1'}, eventId: '1'}];
215211
component.isEditFunctionArgsEnabled = true;
@@ -224,13 +220,6 @@ describe('ChatPanelComponent', () => {
224220

225221
it(
226222
'should emit editEvalCaseMessage when edit is clicked', async () => {
227-
component.evalCase = {
228-
evalId: '1',
229-
conversation: [],
230-
sessionInput: {},
231-
creationTimestamp: 123,
232-
};
233-
component.isEvalEditMode = true;
234223
const message = {role: 'bot', text: 'eval message', eventId: '1'};
235224
component.messages = [message];
236225
spyOn(component.editEvalCaseMessage, 'emit');
@@ -247,13 +236,6 @@ describe('ChatPanelComponent', () => {
247236
it(
248237
'should emit deleteEvalCaseMessage when delete is clicked',
249238
async () => {
250-
component.evalCase = {
251-
evalId: '1',
252-
conversation: [],
253-
sessionInput: {},
254-
creationTimestamp: 123,
255-
};
256-
component.isEvalEditMode = true;
257239
const message = {role: 'bot', text: 'eval message', eventId: '1'};
258240
component.messages = [message];
259241
spyOn(component.deleteEvalCaseMessage, 'emit');
@@ -270,13 +252,6 @@ describe('ChatPanelComponent', () => {
270252
it(
271253
'should emit editFunctionArgs when edit on function call is clicked',
272254
async () => {
273-
component.evalCase = {
274-
evalId: '1',
275-
conversation: [],
276-
sessionInput: {},
277-
creationTimestamp: 123,
278-
};
279-
component.isEvalEditMode = true;
280255
const message = {
281256
role: 'bot',
282257
functionCall: {name: 'func1'},
@@ -351,80 +326,180 @@ describe('ChatPanelComponent', () => {
351326
});
352327

353328
describe('Scrolling', () => {
354-
it(
355-
'should scroll to bottom when user sends a message, even if scroll was interrupted',
356-
fakeAsync(() => {
357-
// Given
358-
component.messages = [{role: 'bot', text: 'Bot message'}];
359-
fixture.detectChanges();
360-
const scrollContainerElement =
361-
component.scrollContainer.nativeElement;
362-
spyOn(scrollContainerElement, 'scrollTo');
363-
scrollContainerElement.dispatchEvent(new WheelEvent('wheel'));
364-
expect(component.scrollInterrupted).toBeTrue();
365-
366-
// When
367-
const oldMessages = component.messages;
368-
component.messages = [...oldMessages, {role: 'user', text: 'User'}];
369-
component.ngOnChanges({
370-
'messages': new SimpleChange(oldMessages, component.messages, false)
371-
});
372-
fixture.detectChanges();
373-
tick(50);
329+
describe('basic scrolling behavior', () => {
330+
let scrollContainerElement: HTMLElement;
374331

375-
// Then
376-
expect(component.scrollInterrupted).toBeFalse();
377-
expect(scrollContainerElement.scrollTo).toHaveBeenCalled();
378-
}));
332+
beforeEach(() => {
333+
component.messages = [{role: 'bot', text: 'Bot message'}];
334+
fixture.detectChanges();
335+
scrollContainerElement = component.scrollContainer.nativeElement;
336+
});
379337

380-
it(
381-
'should call uiStateService.lazyLoadMessages when scrolled to top',
382-
fakeAsync(() => {
383-
// Given
384-
const initialMessageCount = 50;
385-
const initialMessages = Array.from(
386-
{length: initialMessageCount},
387-
(_, i) => ({role: 'bot', text: `message ${i}`}));
388-
component.messages = initialMessages;
389-
fixture.detectChanges();
338+
it(
339+
'should scroll to bottom when user sends a message, even if scroll was interrupted',
340+
fakeAsync(() => {
341+
spyOn(scrollContainerElement, 'scrollTo');
342+
scrollContainerElement.dispatchEvent(new WheelEvent('wheel'));
343+
expect(component.scrollInterrupted).toBeTrue();
344+
345+
const oldMessages = component.messages;
346+
component.messages = [...oldMessages, {role: 'user', text: 'User'}];
347+
component.ngOnChanges({
348+
'messages':
349+
new SimpleChange(oldMessages, component.messages, false)
350+
});
351+
fixture.detectChanges();
352+
tick(50);
353+
354+
expect(component.scrollInterrupted).toBeFalse();
355+
expect(scrollContainerElement.scrollTo).toHaveBeenCalled();
356+
}));
357+
358+
it(
359+
'should call uiStateService.lazyLoadMessages when scrolled to top',
360+
fakeAsync(() => {
361+
const initialMessageCount = 50;
362+
const initialMessages = Array.from(
363+
{length: initialMessageCount},
364+
(_, i) => ({role: 'bot', text: `message ${i}`}));
365+
component.messages = initialMessages;
366+
fixture.detectChanges();
367+
368+
scrollContainerElement.style.height = '100px';
369+
scrollContainerElement.style.overflow = 'auto';
370+
scrollContainerElement.scrollTop = 100;
371+
fixture.detectChanges();
372+
373+
mockUiStateService.newMessagesLoadedResponse.next(
374+
{items: [], nextPageToken: 'initial-token'});
375+
tick();
376+
377+
scrollContainerElement.scrollTop = 0;
378+
scrollContainerElement.dispatchEvent(new Event('scroll'));
379+
tick(200);
380+
381+
expect(mockUiStateService.lazyLoadMessages).toHaveBeenCalled();
382+
383+
mockUiStateService.lazyLoadMessagesResponse.next();
384+
385+
const newMessages = Array.from(
386+
{length: 20}, (_, i) => ({role: 'bot', text: `new ${i}`}));
387+
component.messages = [...newMessages, ...component.messages];
388+
mockUiStateService.newMessagesLoadedResponse.next(
389+
{items: newMessages, nextPageToken: 'next'});
390+
tick();
391+
fixture.detectChanges();
392+
393+
expect(component.messages.length)
394+
.toBe(initialMessageCount + newMessages.length);
395+
expect(component.messages[0]).toEqual(newMessages[0]);
396+
}));
397+
});
390398

391-
const scrollContainerElement =
392-
component.scrollContainer.nativeElement;
393-
// Make sure the scroll height is greater than the client height
394-
scrollContainerElement.style.height = '100px';
395-
scrollContainerElement.style.overflow = 'auto';
396-
scrollContainerElement.scrollTop = 100;
397-
fixture.detectChanges();
399+
describe('when infinity scrolling is enabled', () => {
400+
beforeEach(() => {
401+
mockFeatureFlagService.isInfinityMessageScrollingEnabledResponse.next(
402+
true);
403+
});
398404

399-
// Initialize nextPageToken to allow loading more messages
400-
mockUiStateService.newMessagesLoadedResponse.next(
401-
{items: [], nextPageToken: 'initial-token'});
402-
tick();
405+
it('should lazy load messages when session name changes', () => {
406+
mockUiStateService.lazyLoadMessages.calls.reset();
403407

404-
// When
405-
scrollContainerElement.scrollTop = 0;
406-
scrollContainerElement.dispatchEvent(new Event('scroll'));
407-
tick(200); // Wait for debounce
408+
fixture.componentRef.setInput('sessionName', 'new-session-id');
409+
fixture.detectChanges();
408410

409-
// Then
410-
expect(mockUiStateService.lazyLoadMessages).toHaveBeenCalled();
411+
expect(mockUiStateService.lazyLoadMessages)
412+
.toHaveBeenCalledWith('new-session-id', {
413+
pageSize: 100,
414+
pageToken: '',
415+
});
416+
});
411417

412-
mockUiStateService.lazyLoadMessagesResponse.next();
418+
describe('when new messages are loaded', () => {
419+
let scrollContainer: HTMLElement;
420+
const nextToken = 'updated-token-123';
413421

414-
// When more messages are loaded
415-
const newMessages = Array.from(
416-
{length: 20}, (_, i) => ({role: 'bot', text: `new ${i}`}));
417-
component.messages = [...newMessages, ...component.messages];
418-
mockUiStateService.newMessagesLoadedResponse.next(
419-
{items: newMessages, nextPageToken: 'next'});
420-
tick();
421-
fixture.detectChanges();
422+
beforeEach(fakeAsync(() => {
423+
scrollContainer = component.scrollContainer.nativeElement;
422424

423-
// Then
424-
expect(component.messages.length)
425-
.toBe(initialMessageCount + newMessages.length);
426-
expect(component.messages[0]).toEqual(newMessages[0]);
425+
// Define scrollHeight and scrollTop as simple data properties to
426+
// bypass browser layout constraint logic.
427+
Object.defineProperty(scrollContainer, 'scrollHeight', {
428+
value: 1000,
429+
configurable: true,
430+
});
431+
Object.defineProperty(scrollContainer, 'scrollTop', {
432+
value: 0,
433+
writable: true,
434+
configurable: true,
435+
});
436+
437+
mockUiStateService.newMessagesLoadedResponse.next(
438+
{items: [], nextPageToken: nextToken});
427439
}));
440+
441+
it(
442+
'should update nextPageToken and fetch on scroll', fakeAsync(() => {
443+
component['onScroll'].next(
444+
{target: scrollContainer} as unknown as Event);
445+
tick();
446+
447+
expect(mockUiStateService.lazyLoadMessages)
448+
.toHaveBeenCalledWith(
449+
jasmine.anything(),
450+
jasmine.objectContaining({pageToken: nextToken}));
451+
}));
452+
453+
it('should restore scroll position', fakeAsync(() => {
454+
mockUiStateService.newMessagesLoadedResponse.next({
455+
items: [{role: 'bot', text: 'message 1'}],
456+
nextPageToken: nextToken
457+
});
458+
Object.defineProperty(
459+
scrollContainer, 'scrollHeight',
460+
{value: 1500, configurable: true});
461+
462+
tick(50);
463+
464+
expect(scrollContainer.scrollTop).toBe(500);
465+
}));
466+
});
467+
});
468+
469+
describe('when infinity scrolling is disabled', () => {
470+
beforeEach(() => {
471+
mockFeatureFlagService.isInfinityMessageScrollingEnabledResponse.next(
472+
false);
473+
});
474+
475+
it(
476+
'should not lazy load messages when scrolled to top',
477+
fakeAsync(() => {
478+
mockUiStateService.lazyLoadMessages.calls.reset();
479+
480+
component.scrollContainer.nativeElement.scrollTop = 0;
481+
component['onScroll'].next(
482+
{target: component.scrollContainer.nativeElement} as unknown as
483+
Event);
484+
tick();
485+
486+
expect(mockUiStateService.lazyLoadMessages).not.toHaveBeenCalled();
487+
}));
488+
489+
it(
490+
'should not restore scroll position after loading new messages',
491+
fakeAsync(() => {
492+
const scrollContainer = component.scrollContainer.nativeElement;
493+
scrollContainer.scrollTop = 0;
494+
const originalScrollTop = scrollContainer.scrollTop;
495+
496+
mockUiStateService.newMessagesLoadedResponse.next(
497+
{items: [], nextPageToken: ''});
498+
tick();
499+
500+
expect(scrollContainer.scrollTop).toBe(originalScrollTop);
501+
}));
502+
});
428503
});
429504

430505
describe('disabled features', () => {

0 commit comments

Comments
 (0)