Skip to content

Commit 85bff92

Browse files
google-genai-botcopybara-github
authored andcommitted
ADK changes
PiperOrigin-RevId: 841664815
1 parent c40f1b4 commit 85bff92

15 files changed

+429
-118
lines changed

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,12 @@
1616

1717
@let isSessionLoading = uiStateService.isSessionLoading() | async;
1818
@if (appName != "" && !isSessionLoading) {
19-
<div #autoScroll class="chat-messages">
19+
<div #autoScroll class="chat-messages" (scroll)="onScroll.next($event)">
20+
@if(uiStateService.isMessagesLoading() | async) {
21+
<div class="messages-loading-container">
22+
<mat-progress-bar mode="indeterminate"></mat-progress-bar>
23+
</div>
24+
}
2025
<div #videoContainer></div>
2126
@for (message of messages; track message; let i = $index) {
2227
<div class="message-column-container">

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,3 +436,8 @@ button.video-rec-btn {
436436
width: 12px;
437437
}
438438
}
439+
440+
.messages-loading-container {
441+
margin-top: 1em;
442+
margin-bottom: 1em;
443+
}

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

Lines changed: 62 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,30 +18,27 @@
1818
import {HttpClientTestingModule} from '@angular/common/http/testing';
1919
import {SimpleChange} from '@angular/core';
2020
import {ComponentFixture, TestBed} from '@angular/core/testing';
21-
// 1p-ONLY-IMPORTS: import {beforeEach, describe, expect, it}
22-
import {fakeAsync, initTestBed, tick} from '../../testing/utils';
2321
import {MatDialogModule} from '@angular/material/dialog';
2422
import {By} from '@angular/platform-browser';
2523
import {NoopAnimationsModule} from '@angular/platform-browser/animations';
24+
// 1p-ONLY-IMPORTS: import {beforeEach, describe, expect, it}
25+
import {of} from 'rxjs';
2626

2727
import {AGENT_SERVICE} from '../../core/services/interfaces/agent';
2828
import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag';
29+
import {SESSION_SERVICE} from '../../core/services/interfaces/session';
2930
import {STRING_TO_COLOR_SERVICE} from '../../core/services/interfaces/string-to-color';
3031
import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state';
31-
import {
32-
MockFeatureFlagService
33-
} from '../../core/services/testing/mock-feature-flag.service';
34-
import {
35-
MockStringToColorService
36-
} from '../../core/services/testing/mock-string-to-color.service';
37-
import {
38-
MockUiStateService
39-
} from '../../core/services/testing/mock-ui-state.service';
32+
import {MockAgentService} from '../../core/services/testing/mock-agent.service';
33+
import {MockFeatureFlagService} from '../../core/services/testing/mock-feature-flag.service';
34+
import {MockSessionService} from '../../core/services/testing/mock-session.service';
35+
import {MockStringToColorService} from '../../core/services/testing/mock-string-to-color.service';
36+
import {MockUiStateService} from '../../core/services/testing/mock-ui-state.service';
37+
import {fakeAsync, initTestBed, tick} from '../../testing/utils';
4038
import {MARKDOWN_COMPONENT} from '../markdown/markdown.component.interface';
4139
import {MockMarkdownComponent} from '../markdown/testing/mock-markdown.component';
4240

4341
import {ChatPanelComponent} from './chat-panel.component';
44-
import {MockAgentService} from '../../core/services/testing/mock-agent.service';
4542

4643
describe('ChatPanelComponent', () => {
4744
let component: ChatPanelComponent;
@@ -50,16 +47,19 @@ describe('ChatPanelComponent', () => {
5047
let mockUiStateService: MockUiStateService;
5148
let mockStringToColorService: MockStringToColorService;
5249
let mockAgentService: MockAgentService;
50+
let mockSessionService: MockSessionService;
5351

5452
beforeEach(async () => {
5553
mockFeatureFlagService = new MockFeatureFlagService();
5654
mockUiStateService = new MockUiStateService();
5755
mockAgentService = new MockAgentService();
56+
mockSessionService = new MockSessionService();
5857

5958
mockFeatureFlagService.isMessageFileUploadEnabledResponse.next(true);
6059
mockFeatureFlagService.isManualStateUpdateEnabledResponse.next(true);
6160
mockFeatureFlagService.isBidiStreamingEnabledResponse.next(true);
6261
mockFeatureFlagService.isFeedbackServiceEnabledResponse.next(true);
62+
mockFeatureFlagService.isInfinityMessageScrollingEnabledResponse.next(true);
6363

6464
mockStringToColorService = new MockStringToColorService();
6565
mockStringToColorService.stc.and.returnValue('rgb(255, 0, 0)');
@@ -84,6 +84,7 @@ describe('ChatPanelComponent', () => {
8484
{provide: FEATURE_FLAG_SERVICE, useValue: mockFeatureFlagService},
8585
{provide: UI_STATE_SERVICE, useValue: mockUiStateService},
8686
{provide: AGENT_SERVICE, useValue: mockAgentService},
87+
{provide: SESSION_SERVICE, useValue: mockSessionService},
8788
],
8889
})
8990
.compileComponents();
@@ -364,6 +365,55 @@ describe('ChatPanelComponent', () => {
364365
expect(component.scrollInterrupted).toBeFalse();
365366
expect(scrollContainerElement.scrollTo).toHaveBeenCalled();
366367
}));
368+
369+
it(
370+
'should call uiStateService.lazyLoadMessages when scrolled to top',
371+
fakeAsync(() => {
372+
// Given
373+
const initialMessageCount = 50;
374+
const initialMessages = Array.from(
375+
{length: initialMessageCount},
376+
(_, i) => ({role: 'bot', text: `message ${i}`}));
377+
component.messages = initialMessages;
378+
fixture.detectChanges();
379+
380+
const scrollContainerElement =
381+
component.scrollContainer.nativeElement;
382+
// Make sure the scroll height is greater than the client height
383+
scrollContainerElement.style.height = '100px';
384+
scrollContainerElement.style.overflow = 'auto';
385+
scrollContainerElement.scrollTop = 100;
386+
fixture.detectChanges();
387+
388+
// Initialize nextPageToken to allow loading more messages
389+
mockUiStateService.newMessagesLoadedResponse.next(
390+
{items: [], nextPageToken: 'initial-token'});
391+
tick();
392+
393+
// When
394+
scrollContainerElement.scrollTop = 0;
395+
scrollContainerElement.dispatchEvent(new Event('scroll'));
396+
tick(200); // Wait for debounce
397+
398+
// Then
399+
expect(mockUiStateService.lazyLoadMessages).toHaveBeenCalled();
400+
401+
mockUiStateService.lazyLoadMessagesResponse.next();
402+
403+
// When more messages are loaded
404+
const newMessages = Array.from(
405+
{length: 20}, (_, i) => ({role: 'bot', text: `new ${i}`}));
406+
component.messages = [...newMessages, ...component.messages];
407+
mockUiStateService.newMessagesLoadedResponse.next(
408+
{items: newMessages, nextPageToken: 'next'});
409+
tick();
410+
fixture.detectChanges();
411+
412+
// Then
413+
expect(component.messages.length)
414+
.toBe(initialMessageCount + newMessages.length);
415+
expect(component.messages[0]).toEqual(newMessages[0]);
416+
}));
367417
});
368418

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

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

Lines changed: 78 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@
1717

1818
import {TextFieldModule} from '@angular/cdk/text-field';
1919
import {CommonModule, NgClass} from '@angular/common';
20-
import {AfterViewInit, Component, ElementRef, EventEmitter, inject, Input, OnChanges, Output, signal, SimpleChanges, Type, ViewChild} from '@angular/core';
20+
import {AfterViewInit, Component, DestroyRef, effect, ElementRef, EventEmitter, inject, input, Input, OnChanges, Output, signal, SimpleChanges, Type, ViewChild} from '@angular/core';
21+
import {takeUntilDestroyed, toSignal} from '@angular/core/rxjs-interop';
2122
import {FormsModule} from '@angular/forms';
2223
import {MatButtonModule} from '@angular/material/button';
2324
import {MatCardModule} from '@angular/material/card';
@@ -30,18 +31,21 @@ import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
3031
import {MatTooltipModule} from '@angular/material/tooltip';
3132
import {DomSanitizer, SafeHtml} from '@angular/platform-browser';
3233
import {NgxJsonViewerModule} from 'ngx-json-viewer';
34+
import {EMPTY, NEVER, of, Subject} from 'rxjs';
35+
import {catchError, first, switchMap, tap} from 'rxjs/operators';
3336

3437
import type {EvalCase} from '../../core/models/Eval';
38+
import {AGENT_SERVICE} from '../../core/services/interfaces/agent';
3539
import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag';
40+
import {SESSION_SERVICE} from '../../core/services/interfaces/session';
3641
import {STRING_TO_COLOR_SERVICE} from '../../core/services/interfaces/string-to-color';
42+
import {ListResponse} from '../../core/services/interfaces/types';
3743
import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state';
3844
import {MediaType,} from '../artifact-tab/artifact-tab.component';
3945
import {AudioPlayerComponent} from '../audio-player/audio-player.component';
4046
import {MARKDOWN_COMPONENT, MarkdownComponentInterface} from '../markdown/markdown.component.interface';
4147

4248
import {ChatPanelMessagesInjectionToken} from './chat-panel.component.i18n';
43-
import {AGENT_SERVICE} from '../../core/services/interfaces/agent';
44-
import {toSignal} from '@angular/core/rxjs-interop';
4549

4650
const ROOT_AGENT = 'root_agent';
4751

@@ -70,6 +74,7 @@ const ROOT_AGENT = 'root_agent';
7074
})
7175
export class ChatPanelComponent implements OnChanges, AfterViewInit {
7276
@Input() appName: string = '';
77+
sessionName = input<string>('');
7378
@Input() messages: any[] = [];
7479
@Input() isChatMode: boolean = true;
7580
@Input() evalCase: EvalCase|null = null;
@@ -109,15 +114,14 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit {
109114
@Output() readonly updateState = new EventEmitter<void>();
110115
@Output() readonly toggleAudioRecording = new EventEmitter<void>();
111116
@Output() readonly toggleVideoRecording = new EventEmitter<void>();
112-
@Output()
113-
readonly feedback =
114-
new EventEmitter<{direction: 'up'|'down'}>();
117+
@Output() readonly feedback = new EventEmitter<{direction: 'up' | 'down'}>();
115118

116119
@ViewChild('videoContainer', {read: ElementRef}) videoContainer!: ElementRef;
117120
@ViewChild('autoScroll') scrollContainer!: ElementRef;
118121
@ViewChild('messageTextarea') public textarea: ElementRef|undefined;
119122
scrollInterrupted = false;
120123
private previousMessageCount = 0;
124+
private nextPageToken = '';
121125
protected readonly i18n = inject(ChatPanelMessagesInjectionToken);
122126
protected readonly uiStateService = inject(UI_STATE_SERVICE);
123127
private readonly stringToColorService = inject(STRING_TO_COLOR_SERVICE);
@@ -126,6 +130,8 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit {
126130
);
127131
private readonly featureFlagService = inject(FEATURE_FLAG_SERVICE);
128132
private readonly agentService = inject(AGENT_SERVICE);
133+
private readonly sessionService = inject(SESSION_SERVICE);
134+
private readonly destroyRef = inject(DestroyRef);
129135
readonly MediaType = MediaType;
130136

131137
readonly isMessageFileUploadEnabledObs =
@@ -135,10 +141,73 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit {
135141
readonly isBidiStreamingEnabledObs =
136142
this.featureFlagService.isBidiStreamingEnabled();
137143
readonly canEditSession = signal(true);
138-
readonly isUserFeedbackEnabled = toSignal(this.featureFlagService.isFeedbackServiceEnabled());
139-
readonly isLoadingAgentResponse = toSignal(this.agentService.getLoadingState());
144+
readonly isUserFeedbackEnabled =
145+
toSignal(this.featureFlagService.isFeedbackServiceEnabled());
146+
readonly isLoadingAgentResponse =
147+
toSignal(this.agentService.getLoadingState());
148+
149+
protected readonly onScroll = new Subject<Event>();
150+
151+
constructor(private sanitizer: DomSanitizer) {
152+
effect(() => {
153+
const sessionName = this.sessionName();
154+
if (sessionName) {
155+
this.nextPageToken = '';
156+
this.uiStateService
157+
.lazyLoadMessages(sessionName, {
158+
pageSize: 100,
159+
pageToken: this.nextPageToken,
160+
})
161+
.pipe(first())
162+
.subscribe();
163+
}
164+
});
165+
}
166+
167+
ngOnInit() {
168+
if (this.featureFlagService.isInfinityMessageScrollingEnabled()) {
169+
this.uiStateService.onNewMessagesLoaded()
170+
.pipe(takeUntilDestroyed(this.destroyRef))
171+
.subscribe((response: ListResponse<any>) => {
172+
this.nextPageToken = response.nextPageToken ?? '';
173+
// Scroll to the last unseen message after the new messages are
174+
// loaded.
175+
if (this.scrollContainer?.nativeElement) {
176+
const oldScrollHeight =
177+
this.scrollContainer.nativeElement.scrollHeight;
178+
setTimeout(() => {
179+
const newScrollHeight =
180+
this.scrollContainer.nativeElement.scrollHeight;
181+
this.scrollContainer.nativeElement.scrollTop =
182+
newScrollHeight - oldScrollHeight;
183+
});
184+
}
185+
});
186+
187+
this.onScroll
188+
.pipe(
189+
takeUntilDestroyed(this.destroyRef),
190+
switchMap((event: Event) => {
191+
const element = event.target as HTMLElement;
192+
if (element.scrollTop !== 0) {
193+
return EMPTY;
194+
}
140195

141-
constructor(private sanitizer: DomSanitizer) {}
196+
if (!this.nextPageToken) {
197+
return EMPTY;
198+
}
199+
200+
return this.uiStateService
201+
.lazyLoadMessages(this.sessionName(), {
202+
pageSize: 100,
203+
pageToken: this.nextPageToken,
204+
})
205+
.pipe(first(), catchError(() => NEVER));
206+
}),
207+
)
208+
.subscribe();
209+
}
210+
}
142211

143212
ngAfterViewInit() {
144213
if (this.scrollContainer?.nativeElement) {

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,7 @@
316316
(updateState)="updateState()"
317317
(toggleAudioRecording)="toggleAudioRecording()"
318318
(toggleVideoRecording)="toggleVideoRecording()"
319+
[sessionName]="sessionId"
319320
></app-chat-panel>
320321
}
321322
</mat-card>

0 commit comments

Comments
 (0)