diff --git a/src/app/components/chat-panel/chat-panel.component.html b/src/app/components/chat-panel/chat-panel.component.html
index 9cd2958a..0f65bc40 100644
--- a/src/app/components/chat-panel/chat-panel.component.html
+++ b/src/app/components/chat-panel/chat-panel.component.html
@@ -236,7 +236,10 @@
{{ message.actualFinalResponse }}
diff --git a/src/app/components/chat-panel/chat-panel.component.spec.ts b/src/app/components/chat-panel/chat-panel.component.spec.ts
index 37fab07b..33ec40e2 100644
--- a/src/app/components/chat-panel/chat-panel.component.spec.ts
+++ b/src/app/components/chat-panel/chat-panel.component.spec.ts
@@ -184,6 +184,27 @@ describe('ChatPanelComponent', () => {
const canvas = fixture.debugElement.query(By.css('app-a2ui-canvas'));
expect(canvas).toBeTruthy();
});
+
+ it(
+ 'should render failed eval response compare when actual response is empty',
+ async () => {
+ component.messages = [{
+ role: 'bot',
+ evalStatus: 2,
+ failedMetric: 'response_match_score',
+ actualFinalResponse: '',
+ expectedFinalResponse: 'Expected eval response',
+ }];
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const compareContainer =
+ fixture.debugElement.query(By.css('.eval-compare-container'));
+ expect(compareContainer).toBeTruthy();
+ expect(compareContainer.nativeElement.textContent)
+ .toContain('Expected eval response');
+ });
});
it('should display loading bar if message isLoading', async () => {
diff --git a/src/app/components/chat-panel/chat-panel.component.ts b/src/app/components/chat-panel/chat-panel.component.ts
index 37a81977..fdb94cc6 100644
--- a/src/app/components/chat-panel/chat-panel.component.ts
+++ b/src/app/components/chat-panel/chat-panel.component.ts
@@ -30,12 +30,11 @@ import {MatProgressBarModule} from '@angular/material/progress-bar';
import {MatProgressSpinnerModule} from '@angular/material/progress-spinner';
import {MatTooltipModule} from '@angular/material/tooltip';
import {NgxJsonViewerModule} from 'ngx-json-viewer';
-import {EMPTY, merge, NEVER, of, Subject} from 'rxjs';
+import {defer, EMPTY, merge, NEVER, Subject} from 'rxjs';
import {catchError, filter, first, switchMap, tap} from 'rxjs/operators';
import {isComputerUseResponse, isVisibleComputerUseClick} from '../../core/models/ComputerUse';
import type {EvalCase} from '../../core/models/Eval';
-import {FunctionCall, FunctionResponse} from '../../core/models/types';
import {AGENT_SERVICE} from '../../core/services/interfaces/agent';
import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag';
import {SAFE_VALUES_SERVICE} from '../../core/services/interfaces/safevalues';
@@ -157,6 +156,10 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit {
this.featureFlagService.isManualStateUpdateEnabled();
readonly isBidiStreamingEnabledObs =
this.featureFlagService.isBidiStreamingEnabled();
+ readonly isInfinityMessageScrollingEnabled =
+ toSignal(this.featureFlagService.isInfinityMessageScrollingEnabled(), {
+ initialValue: false,
+ });
readonly canEditSession = signal(true);
readonly isUserFeedbackEnabled =
toSignal(this.featureFlagService.isFeedbackServiceEnabled());
@@ -169,24 +172,30 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit {
constructor() {
effect(() => {
const sessionName = this.sessionName();
- if (sessionName) {
- this.nextPageToken = '';
- this.uiStateService
- .lazyLoadMessages(sessionName, {
- pageSize: 100,
- pageToken: this.nextPageToken,
- })
- .pipe(first())
- .subscribe();
+ const isInfinityEnabled = this.isInfinityMessageScrollingEnabled();
+ if (!sessionName || !isInfinityEnabled) {
+ return;
}
+
+ this.loadInitialMessagesPage(sessionName);
});
}
+ private loadInitialMessagesPage(sessionName: string): void {
+ this.nextPageToken = '';
+ defer(() => this.uiStateService.lazyLoadMessages(sessionName, {
+ pageSize: 100,
+ pageToken: this.nextPageToken,
+ }))
+ .pipe(first(), catchError(() => EMPTY))
+ .subscribe();
+ }
+
ngOnInit() {
this.featureFlagService.isInfinityMessageScrollingEnabled()
.pipe(
first(),
- filter((enabled) => enabled),
+ filter((enabled) => enabled === true),
switchMap(
() => merge(
this.uiStateService.onNewMessagesLoaded().pipe(
@@ -208,11 +217,11 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit {
}
this.scrollHeight = element.scrollHeight;
- return this.uiStateService
- .lazyLoadMessages(this.sessionName(), {
+ return defer(() => this.uiStateService.lazyLoadMessages(
+ this.sessionName(), {
pageSize: 100,
pageToken: this.nextPageToken,
- })
+ }))
.pipe(first(), catchError(() => NEVER));
})))),
takeUntilDestroyed(this.destroyRef),
diff --git a/src/app/components/chat/chat.component.spec.ts b/src/app/components/chat/chat.component.spec.ts
index b22b9a3b..879d239a 100644
--- a/src/app/components/chat/chat.component.spec.ts
+++ b/src/app/components/chat/chat.component.spec.ts
@@ -585,6 +585,36 @@ describe('ChatComponent', () => {
});
});
+ describe('when synthetic eval session ID is provided in URL', () => {
+ const EVAL_SYNTHETIC_SESSION_ID = '___eval___session___case-1';
+
+ beforeEach(() => {
+ mockAgentService.listAppsResponse.next([TEST_APP_1_NAME]);
+ mockFeatureFlagService.isSessionUrlEnabledResponse.next(true);
+ mockActivatedRoute.snapshot!.queryParams = {
+ [APP_QUERY_PARAM]: TEST_APP_1_NAME,
+ [SESSION_QUERY_PARAM]: EVAL_SYNTHETIC_SESSION_ID,
+ };
+ });
+
+ it('should create a new session instead of restoring from URL',
+ async () => {
+ mockSessionService.createSession.calls.reset();
+ mockSessionService.getSession.calls.reset();
+ mockFeatureFlagService.isApplicationSelectorEnabledResponse.next(
+ false);
+ fixture = TestBed.createComponent(ChatComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ await fixture.whenStable();
+
+ expect(mockSessionService.getSession).not.toHaveBeenCalledWith(
+ USER_ID, TEST_APP_1_NAME, EVAL_SYNTHETIC_SESSION_ID);
+ expect(mockSessionService.createSession)
+ .toHaveBeenCalledWith(USER_ID, TEST_APP_1_NAME);
+ });
+ });
+
describe('when session in URL is not found', () => {
beforeEach(async () => {
mockActivatedRoute.snapshot!.queryParams = {
@@ -768,6 +798,12 @@ describe('ChatComponent', () => {
id: 'event-2',
author: 'bot',
content: {parts: [{text: 'bot response'}]},
+ evalStatus: 2,
+ failedMetric: 'response_match_score',
+ evalScore: 0.4,
+ evalThreshold: 0.7,
+ actualFinalResponse: '',
+ expectedFinalResponse: 'Expected bot response',
},
],
};
@@ -798,6 +834,18 @@ describe('ChatComponent', () => {
}));
});
+ it('should preserve eval comparison fields on bot messages', () => {
+ expect(component.messages()[1]).toEqual(jasmine.objectContaining({
+ role: 'bot',
+ evalStatus: 2,
+ failedMetric: 'response_match_score',
+ evalScore: 0.4,
+ evalThreshold: 0.7,
+ actualFinalResponse: '',
+ expectedFinalResponse: 'Expected bot response',
+ }));
+ });
+
it('should call getTrace', () => {
expect(mockEventService.getTrace)
.toHaveBeenCalledWith(SESSION_1_ID);
diff --git a/src/app/components/chat/chat.component.ts b/src/app/components/chat/chat.component.ts
index e833fc55..64012dd7 100644
--- a/src/app/components/chat/chat.component.ts
+++ b/src/app/components/chat/chat.component.ts
@@ -20,7 +20,7 @@ import {HttpErrorResponse} from '@angular/common/http';
import {AfterViewInit, ChangeDetectorRef, Component, ElementRef, HostListener, inject, Injectable, OnDestroy, OnInit, Renderer2, signal, viewChild, WritableSignal} from '@angular/core';
import {toSignal} from '@angular/core/rxjs-interop';
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
-import {MatButton, MatFabButton} from '@angular/material/button';
+import {MatButton} from '@angular/material/button';
import {MatCard} from '@angular/material/card';
import {MatDialog} from '@angular/material/dialog';
import {MatDivider} from '@angular/material/divider';
@@ -86,6 +86,17 @@ const A2A_DATA_PART_START_TAG = '
';
const A2A_DATA_PART_END_TAG = '';
const A2UI_MIME_TYPE = 'application/json+a2ui';
+interface EvalCompareFields {
+ evalStatus?: number;
+ failedMetric?: string;
+ evalScore?: number;
+ evalThreshold?: number;
+ actualInvocationToolUses?: any[];
+ expectedInvocationToolUses?: any[];
+ actualFinalResponse?: string;
+ expectedFinalResponse?: string;
+}
+
function fixBase64String(base64: string): string {
// Replace URL-safe characters if they exist
base64 = base64.replace(/-/g, '+').replace(/_/g, '/');
@@ -119,6 +130,7 @@ class CustomPaginatorIntl extends MatPaginatorIntl {
const BIDI_STREAMING_RESTART_WARNING =
'Restarting bidirectional streaming is not currently supported. Please refresh the page or start a new session.';
+const EVAL_SYNTHETIC_SESSION_PREFIX = '___eval___session___';
@Component({
selector: 'app-chat',
@@ -141,7 +153,6 @@ const BIDI_STREAMING_RESTART_WARNING =
MatSlideToggle,
MatDivider,
MatCard,
- MatFabButton,
ResizableBottomDirective,
TraceEventComponent,
AsyncPipe,
@@ -438,12 +449,15 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
const queryParams = this.activatedRoute.snapshot.queryParams;
const sessionUrl = queryParams['session'];
const userUrl = queryParams['userId'];
+ const isEvalSyntheticSession =
+ typeof sessionUrl === 'string' &&
+ sessionUrl.startsWith(EVAL_SYNTHETIC_SESSION_PREFIX);
if (userUrl) {
this.userId = userUrl;
}
- if (!sessionUrlEnabled || !sessionUrl) {
+ if (!sessionUrlEnabled || !sessionUrl || isEvalSyntheticSession) {
this.createSessionAndReset();
return;
@@ -973,14 +987,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
let message: any = {
role,
- evalStatus: e?.evalStatus,
- failedMetric: e?.failedMetric,
- evalScore: e?.evalScore,
- evalThreshold: e?.evalThreshold,
- actualInvocationToolUses: e?.actualInvocationToolUses,
- expectedInvocationToolUses: e?.expectedInvocationToolUses,
- actualFinalResponse: e?.actualFinalResponse,
- expectedFinalResponse: e?.expectedFinalResponse,
+ ...this.mapEvalCompareFields(e),
invocationIndex: invocationIndex !== undefined ? invocationIndex :
undefined,
finalResponsePartIndex:
@@ -1092,6 +1099,27 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
return `data:${mimeType};base64,${fixedBase64Data}`;
}
+ // Centralized mapper keeps eval compare fields consistent
+ // across streamed and hydrated message construction paths.
+ private mapEvalCompareFields(event: any): EvalCompareFields {
+ return {
+ evalStatus: event?.evalStatus,
+ failedMetric: event?.failedMetric,
+ evalScore: event?.evalScore,
+ evalThreshold: event?.evalThreshold,
+ actualInvocationToolUses: event?.actualInvocationToolUses,
+ expectedInvocationToolUses: event?.expectedInvocationToolUses,
+ actualFinalResponse: event?.actualFinalResponse,
+ expectedFinalResponse: event?.expectedFinalResponse,
+ };
+ }
+
+ private addEvalFieldsToMessage(event: any, message: any) {
+ if (message.role !== 'bot') return;
+
+ Object.assign(message, this.mapEvalCompareFields(event));
+ }
+
private processPartIntoMessage(part: any, event: any, message: any) {
if (!part) return;
@@ -1586,6 +1614,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
role: 'bot',
eventId: event.id
};
+ this.addEvalFieldsToMessage(event, botMessage);
partsToProcess.forEach((part: any) => {
if (isA2aResponse && this.isA2uiDataPart(part)) {
@@ -1654,6 +1683,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy {
role: 'bot',
eventId: event.id
};
+ this.addEvalFieldsToMessage(event, botMessage);
event.content?.parts?.forEach((part: any) => {
this.processPartIntoMessage(part, event, botMessage);
diff --git a/src/app/components/eval-tab/eval-tab.component.html b/src/app/components/eval-tab/eval-tab.component.html
index 9660b004..6427dd0c 100644
--- a/src/app/components/eval-tab/eval-tab.component.html
+++ b/src/app/components/eval-tab/eval-tab.component.html
@@ -111,12 +111,12 @@
}
@if (showEvalHistory()) {
- @for (evalResult of getEvalHistoryOfCurrentSetSorted(); track evalResult) {
+ @for (evalResult of evalHistorySorted; track evalResult.timestamp) {
-
{{ formatTimestamp(evalResult.timestamp) }}
+
{{ evalResult.formattedTimestamp }}
{{ getPassCountForCurrentResult(evalResult.evaluationResults.evaluationResults) }} {{ i18n.passedSuffix }}
@if (getFailCountForCurrentResult(evalResult.evaluationResults.evaluationResults) > 0) {
@@ -126,9 +126,9 @@
}
- @if (getEvalMetrics(evalResult)) {
+ @if (evalResult.metrics.length > 0) {
- @for (evalMetric of getEvalMetrics(evalResult); track evalMetric) {
+ @for (evalMetric of evalResult.metrics; track evalMetric.metricName) {
{{ evalMetric.metricName }}:
{{ evalMetric.threshold }}
diff --git a/src/app/components/eval-tab/eval-tab.component.spec.ts b/src/app/components/eval-tab/eval-tab.component.spec.ts
index b5372c12..97154546 100644
--- a/src/app/components/eval-tab/eval-tab.component.spec.ts
+++ b/src/app/components/eval-tab/eval-tab.component.spec.ts
@@ -20,7 +20,7 @@ import { MatDialog, MatDialogModule } from '@angular/material/dialog';
import { EvalTabComponent } from './eval-tab.component';
import { EvalService } from '../../core/services/eval.service';
import { SessionService } from '../../core/services/session.service';
-import { of } from 'rxjs';
+import { of, throwError } from 'rxjs';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { ActivatedRoute } from '@angular/router';
import {
@@ -33,9 +33,10 @@ import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag'
describe('EvalTabComponent', () => {
let component: EvalTabComponent;
let fixture: ComponentFixture
;
+ let evalService: jasmine.SpyObj;
beforeEach(async () => {
- const evalService = jasmine.createSpyObj([
+ evalService = jasmine.createSpyObj([
'getEvalSets',
'listEvalCases',
'runEval',
@@ -93,4 +94,185 @@ describe('EvalTabComponent', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ it('should build stable sorted eval history entries', () => {
+ evalService.listEvalResults.and.returnValue(of(['result-1', 'result-2']));
+ evalService.getEvalResult.and.callFake((appName: string, evalResultId: string) => {
+ const creationTimestamp =
+ evalResultId === 'result-1' ? 1710000000 : 1720000000;
+
+ return of({
+ evalSetId: 'set-1',
+ creationTimestamp,
+ evalCaseResults: [{
+ id: evalResultId,
+ evalId: `case-${evalResultId}`,
+ finalEvalStatus: 1,
+ evalMetricResults: [],
+ evalMetricResultPerInvocation: [],
+ sessionId: 'session-id',
+ sessionDetails: {},
+ overallEvalMetricResults: [],
+ }],
+ } as any);
+ });
+
+ fixture.componentRef.setInput('appName', 'test-app');
+ fixture.detectChanges();
+ component.selectEvalSet('set-1');
+
+ expect(component['evalHistorySorted'].map((entry: any) => entry.timestamp))
+ .toEqual(['1720000000', '1710000000']);
+ });
+
+ it('should toggle history card without errors after history refresh', () => {
+ fixture.componentRef.setInput('appName', 'test-app');
+ fixture.detectChanges();
+
+ component['appEvaluationResults'] = {
+ 'test-app': {
+ 'set-1': {
+ '1710000000': {isToggled: false, evaluationResults: []},
+ },
+ },
+ } as any;
+ component.selectEvalSet('set-1');
+
+ expect(() => component.toggleHistoryStatusCard('1710000000')).not.toThrow();
+ expect(component.isEvaluationStatusCardToggled('1710000000')).toBeTrue();
+ });
+
+ it('should keep successful eval history entries when one result fails', () => {
+ evalService.listEvalResults.and.returnValue(of(['result-1', 'result-2']));
+ evalService.getEvalResult.and.callFake((appName: string, evalResultId: string) => {
+ if (evalResultId === 'result-2') {
+ return throwError(() => new Error('result failed'));
+ }
+
+ return of({
+ evalSetId: 'set-1',
+ creationTimestamp: 1710000000,
+ evalCaseResults: [{
+ id: evalResultId,
+ evalId: `case-${evalResultId}`,
+ finalEvalStatus: 1,
+ evalMetricResults: [],
+ evalMetricResultPerInvocation: [],
+ sessionId: 'session-id',
+ sessionDetails: {},
+ overallEvalMetricResults: [],
+ }],
+ } as any);
+ });
+
+ fixture.componentRef.setInput('appName', 'test-app');
+ fixture.detectChanges();
+ component.selectEvalSet('set-1');
+
+ expect(component['evalHistorySorted'].length).toBe(1);
+ expect(component['evalHistorySorted'][0].timestamp).toBe('1710000000');
+ });
+
+ it('should safely ignore non-array eval result id responses', () => {
+ evalService.listEvalResults.and.returnValue(of({not: 'an array'} as any));
+
+ fixture.componentRef.setInput('appName', 'test-app');
+ fixture.detectChanges();
+ component.selectEvalSet('set-1');
+
+ expect(evalService.getEvalResult).not.toHaveBeenCalled();
+ expect(component['evalHistorySorted']).toEqual([]);
+ });
+
+ it('should refresh history once after loading all eval results', () => {
+ evalService.listEvalResults.and.returnValue(of(['result-1', 'result-2']));
+ evalService.getEvalResult.and.callFake((appName: string, evalResultId: string) => {
+ const creationTimestamp =
+ evalResultId === 'result-1' ? 1710000000 : 1720000000;
+
+ return of({
+ evalSetId: 'set-1',
+ creationTimestamp,
+ evalCaseResults: [{
+ id: evalResultId,
+ evalId: `case-${evalResultId}`,
+ finalEvalStatus: 1,
+ evalMetricResults: [],
+ evalMetricResultPerInvocation: [],
+ sessionId: 'session-id',
+ sessionDetails: {},
+ overallEvalMetricResults: [],
+ }],
+ } as any);
+ });
+ const refreshSpy =
+ spyOn(component, 'refreshEvalHistorySorted').and.callThrough();
+
+ fixture.componentRef.setInput('appName', 'test-app');
+ fixture.detectChanges();
+ component.selectedEvalSet = 'set-1';
+ refreshSpy.calls.reset();
+ component['getEvaluationResult']();
+
+ expect(refreshSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('treats listEvalResults 404 as empty history', () => {
+ evalService.listEvalResults.and.returnValue(
+ throwError(() => ({status: 404, statusText: 'Not Found'})));
+
+ fixture.componentRef.setInput('appName', 'test-app');
+ fixture.detectChanges();
+ component.selectEvalSet('set-1');
+
+ expect(evalService.getEvalResult).not.toHaveBeenCalled();
+ expect(component['evalHistorySorted']).toEqual([]);
+ });
+
+ it('preserves existing eval results when listEvalResults fails non-404', () => {
+ evalService.listEvalResults.and.returnValue(
+ throwError(() => ({status: 500, statusText: 'Server Error'})));
+ component['appEvaluationResults'] = {
+ 'test-app': {
+ 'set-1': {
+ '1710000000': {
+ isToggled: false,
+ evaluationResults: [{
+ setId: 'set-1',
+ evalId: 'case-1',
+ finalEvalStatus: 1,
+ evalMetricResults: [],
+ overallEvalMetricResults: [],
+ sessionId: 'session-id',
+ sessionDetails: {},
+ }],
+ },
+ },
+ },
+ } as any;
+ component.selectedEvalSet = 'set-1';
+ component['refreshEvalHistorySorted']();
+ const previousHistory = [...component['evalHistorySorted']];
+
+ fixture.componentRef.setInput('appName', 'test-app');
+ fixture.detectChanges();
+ component['getEvaluationResult']();
+
+ expect(evalService.getEvalResult).not.toHaveBeenCalled();
+ expect(component['appEvaluationResults']['test-app']['set-1']['1710000000'])
+ .toBeDefined();
+ expect(component['evalHistorySorted']).toEqual(previousHistory);
+ });
+
+ it('hides eval tab when getEvalSet returns 404 regardless of statusText', () => {
+ const shouldShowTabSpy = spyOn(component.shouldShowTab, 'emit');
+ evalService.getEvalSets.and.returnValue(
+ throwError(() => ({status: 404, statusText: 'Anything'})));
+
+ fixture.componentRef.setInput('appName', 'test-app');
+ fixture.detectChanges();
+ component.getEvalSet();
+
+ expect(shouldShowTabSpy).toHaveBeenCalledWith(false);
+ });
});
diff --git a/src/app/components/eval-tab/eval-tab.component.ts b/src/app/components/eval-tab/eval-tab.component.ts
index 520d1261..0eb0e770 100644
--- a/src/app/components/eval-tab/eval-tab.component.ts
+++ b/src/app/components/eval-tab/eval-tab.component.ts
@@ -24,8 +24,8 @@ import {MatIcon} from '@angular/material/icon';
import {MatProgressSpinner} from '@angular/material/progress-spinner';
import {MatCell, MatCellDef, MatColumnDef, MatHeaderCell, MatHeaderCellDef, MatHeaderRow, MatHeaderRowDef, MatRow, MatRowDef, MatTable, MatTableDataSource} from '@angular/material/table';
import {MatTooltip} from '@angular/material/tooltip';
-import {BehaviorSubject, of} from 'rxjs';
-import {catchError} from 'rxjs/operators';
+import {BehaviorSubject, forkJoin, Observable, of, throwError} from 'rxjs';
+import {catchError, map, switchMap} from 'rxjs/operators';
import {DEFAULT_EVAL_METRICS, EvalCase, EvalMetric, Invocation} from '../../core/models/Eval';
import {Session} from '../../core/models/Session';
@@ -78,6 +78,30 @@ interface AppEvaluationResult {
[key: string]: SetEvaluationResult;
}
+interface EvalHistoryEntry {
+ timestamp: string;
+ formattedTimestamp: string;
+ evaluationResults: UIEvaluationResult;
+ metrics: EvalMetric[];
+}
+
+interface EvalResultApiCaseResult {
+ id: string;
+ evalId: string;
+ finalEvalStatus: number;
+ evalMetricResults: any[];
+ evalMetricResultPerInvocation?: any[];
+ sessionId: string;
+ sessionDetails: any;
+ overallEvalMetricResults?: any[];
+}
+
+interface EvalResultApiResponse {
+ evalSetId: string;
+ creationTimestamp: number|string;
+ evalCaseResults: EvalResultApiCaseResult[];
+}
+
@Component({
selector: 'app-eval-tab',
templateUrl: './eval-tab.component.html',
@@ -140,6 +164,7 @@ export class EvalTabComponent implements OnInit, OnChanges {
readonly dialog = inject(MatDialog);
protected appEvaluationResults: AppEvaluationResult = {};
+ protected evalHistorySorted: EvalHistoryEntry[] = [];
private readonly evalService = inject(EVAL_SERVICE);
private readonly sessionService = inject(SESSION_SERVICE);
@@ -156,14 +181,26 @@ export class EvalTabComponent implements OnInit, OnChanges {
}
ngOnChanges(changes: SimpleChanges): void {
- if (changes['appName']) {
- this.selectedEvalSet = '';
- this.evalCases = [];
- this.getEvalSet();
- this.getEvaluationResult();
+ if (changes['appName']?.currentValue) {
+ this.loadDataForApp(changes['appName'].currentValue);
+ }
+ }
+ ngOnInit(): void {
+ if (this.appName()) {
+ this.loadDataForApp(this.appName());
+ }
+ }
+
+ private loadDataForApp(appName: string) {
+ if (!appName) {
+ return;
}
+ this.selectedEvalSet = '';
+ this.evalCases = [];
+ this.evalHistorySorted = [];
+ this.getEvalSet();
+ this.getEvaluationResult();
}
- ngOnInit(): void {}
selectNewEvalCase(evalCases: string[]) {
let caseToSelect = this.deletedEvalCaseIndex;
@@ -177,18 +214,23 @@ export class EvalTabComponent implements OnInit, OnChanges {
if (this.appName() !== '') {
this.evalService.getEvalSets(this.appName())
.pipe(catchError((error) => {
- if (error.status === 404 && error.statusText === 'Not Found') {
+ if (error.status === 404) {
this.shouldShowTab.emit(false);
return of(null);
}
- return of([]);
+ return throwError(() => error);
}))
- .subscribe((sets) => {
- if (sets !== null) {
- this.shouldShowTab.emit(true);
- this.evalsets = sets;
- this.changeDetectorRef.detectChanges();
- }
+ .subscribe({
+ next: (sets: any[]|null) => {
+ if (sets !== null) {
+ this.shouldShowTab.emit(true);
+ this.evalsets = sets;
+ this.changeDetectorRef.detectChanges();
+ }
+ },
+ error: () => {
+ // Preserve current tab state/data when fetch fails unexpectedly.
+ },
});
;
}
@@ -263,12 +305,12 @@ export class EvalTabComponent implements OnInit, OnChanges {
this.currentEvalResultBySet.set(this.selectedEvalSet, res);
this.getEvaluationResult();
- this.changeDetectorRef.detectChanges();
});
}
selectEvalSet(set: string) {
this.selectedEvalSet = set;
+ this.refreshEvalHistorySorted();
this.listEvalCases();
}
@@ -278,6 +320,7 @@ export class EvalTabComponent implements OnInit, OnChanges {
return;
}
this.selectedEvalSet = '';
+ this.evalHistorySorted = [];
}
isAllSelected() {
@@ -398,19 +441,42 @@ export class EvalTabComponent implements OnInit, OnChanges {
}
protected getEvalHistoryOfCurrentSet() {
- return this.appEvaluationResults[this.appName()][this.selectedEvalSet];
+ const appEvalHistory = this.appEvaluationResults[this.appName()] ?? {};
+ return appEvalHistory[this.selectedEvalSet] ?? {};
}
- protected getEvalHistoryOfCurrentSetSorted(): any[] {
+ private refreshEvalHistorySorted() {
const evalHistory = this.getEvalHistoryOfCurrentSet();
const evalHistorySorted =
Object.keys(evalHistory).sort((a, b) => b.localeCompare(a));
- const evalHistorySortedArray = evalHistorySorted.map((key) => {
- return {timestamp: key, evaluationResults: evalHistory[key]};
+ this.evalHistorySorted = evalHistorySorted.map((key) => {
+ const evaluationResults = evalHistory[key];
+ return {
+ timestamp: key,
+ formattedTimestamp: this.formatTimestamp(key),
+ evaluationResults,
+ metrics: this.resolveEvalMetricsForHistory(evaluationResults),
+ };
});
+ }
+
+ private resolveEvalMetricsForHistory(
+ uiEvaluationResult: UIEvaluationResult): EvalMetric[] {
+ const results = uiEvaluationResult.evaluationResults;
+ if (results.length === 0) {
+ return this.evalMetrics.map((metric) => ({...metric}));
+ }
+
+ const overallEvalMetricResults = results[0].overallEvalMetricResults;
+ if (!overallEvalMetricResults || overallEvalMetricResults.length === 0) {
+ return this.evalMetrics.map((metric) => ({...metric}));
+ }
- return evalHistorySortedArray;
+ return overallEvalMetricResults.map((result: any) => ({
+ metricName: result.metricName,
+ threshold: result.threshold,
+ }));
}
protected getPassCountForCurrentResult(result: any[]) {
@@ -503,61 +569,83 @@ export class EvalTabComponent implements OnInit, OnChanges {
}
protected getEvaluationResult() {
- this.evalService.listEvalResults(this.appName())
+ const appNameSnapshot = this.appName();
+ this.evalService.listEvalResults(appNameSnapshot)
.pipe(catchError((error) => {
- if (error.status === 404 && error.statusText === 'Not Found') {
- this.shouldShowTab.emit(false);
- return of(null);
+ // No eval run history yet is valid; keep Eval tab visible.
+ if (error.status === 404) {
+ return of([]);
}
- return of([]);
- }))
- .subscribe((res) => {
- for (const evalResultId of res) {
- this.evalService.getEvalResult(this.appName(), evalResultId)
- .subscribe((res) => {
- if (!this.appEvaluationResults[this.appName()]) {
- this.appEvaluationResults[this.appName()] = {};
+ return throwError(() => error);
+ }),
+ switchMap((res): Observable> => {
+ if (!Array.isArray(res) || res.length === 0) {
+ return of([]);
+ }
+ const resultRequests: Array> =
+ res.map((evalResultId: string) => {
+ return this.evalService
+ .getEvalResult(appNameSnapshot, evalResultId)
+ .pipe(
+ map((evalResult) =>
+ evalResult as EvalResultApiResponse),
+ catchError(() => of(null)),
+ );
+ });
+ return forkJoin(resultRequests);
+ }),
+ map((evalResults: Array) => {
+ const appEvalResults: SetEvaluationResult = {};
+ for (const evalResult of evalResults) {
+ if (!evalResult) {
+ continue;
}
+ this.mergeEvalResultIntoSet(
+ appEvalResults, evalResult as EvalResultApiResponse);
+ }
+ return appEvalResults;
+ }))
+ .subscribe({
+ next: (appEvalResults: SetEvaluationResult) => {
+ this.appEvaluationResults[appNameSnapshot] = appEvalResults;
+ if (this.appName() === appNameSnapshot) {
+ this.refreshEvalHistorySorted();
+ }
+ },
+ error: () => {
+ // Preserve existing cached results when backend fails unexpectedly.
+ },
+ });
+ }
- if (!this.appEvaluationResults[this.appName()]
- [res.evalSetId]) {
- this.appEvaluationResults[this.appName()][res.evalSetId] =
- {};
- }
-
- const timeStamp = res.creationTimestamp;
-
- if (!this.appEvaluationResults[this.appName()][res.evalSetId]
- [timeStamp]) {
- this.appEvaluationResults[this.appName()][res.evalSetId][timeStamp] =
- {isToggled: false, evaluationResults: []};
- }
+ private mergeEvalResultIntoSet(
+ appEvalResults: SetEvaluationResult,
+ evalResult: EvalResultApiResponse) {
+ if (!appEvalResults[evalResult.evalSetId]) {
+ appEvalResults[evalResult.evalSetId] = {};
+ }
+ const timeStamp = evalResult.creationTimestamp;
+ appEvalResults[evalResult.evalSetId][timeStamp] =
+ this.mapEvalResultToUi(evalResult);
+ }
- const uiEvaluationResult: UIEvaluationResult = {
- isToggled: false,
- evaluationResults:
- res.evalCaseResults.map((result: any) => {
- return {
- setId: result.id,
- evalId: result.evalId,
- finalEvalStatus: result.finalEvalStatus,
- evalMetricResults: result.evalMetricResults,
- evalMetricResultPerInvocation:
- result.evalMetricResultPerInvocation,
- sessionId: result.sessionId,
- sessionDetails: result.sessionDetails,
- overallEvalMetricResults:
- result.overallEvalMetricResults ?? [],
- };
- }),
- };
-
- this.appEvaluationResults[this.appName()][res.evalSetId][timeStamp] =
- uiEvaluationResult;
- this.changeDetectorRef.detectChanges();
- });
- }
- });
+ private mapEvalResultToUi(evalResult: EvalResultApiResponse):
+ UIEvaluationResult {
+ return {
+ isToggled: false,
+ evaluationResults: evalResult.evalCaseResults.map((result) => {
+ return {
+ setId: result.id,
+ evalId: result.evalId,
+ finalEvalStatus: result.finalEvalStatus,
+ evalMetricResults: result.evalMetricResults,
+ evalMetricResultPerInvocation: result.evalMetricResultPerInvocation,
+ sessionId: result.sessionId,
+ sessionDetails: result.sessionDetails,
+ overallEvalMetricResults: result.overallEvalMetricResults ?? [],
+ };
+ }),
+ };
}
protected openEvalConfigDialog() {
@@ -583,31 +671,4 @@ export class EvalTabComponent implements OnInit, OnChanges {
});
}
- protected getEvalMetrics(evalResult: any|undefined) {
- if (!evalResult || !evalResult.evaluationResults ||
- !evalResult.evaluationResults.evaluationResults) {
- return this.evalMetrics;
- }
-
- const results = evalResult.evaluationResults.evaluationResults;
-
- if (results.length === 0) {
- return this.evalMetrics;
- }
-
- if (typeof results[0].overallEvalMetricResults === 'undefined' ||
- !results[0].overallEvalMetricResults ||
- results[0].overallEvalMetricResults.length === 0) {
- return this.evalMetrics;
- }
-
- const overallEvalMetricResults = results[0].overallEvalMetricResults;
-
- return overallEvalMetricResults.map((result: any) => {
- return {
- metricName: result.metricName,
- threshold: result.threshold,
- };
- });
- }
}
diff --git a/src/app/components/side-panel/side-panel.component.spec.ts b/src/app/components/side-panel/side-panel.component.spec.ts
index 229e38b0..2423d7e0 100644
--- a/src/app/components/side-panel/side-panel.component.spec.ts
+++ b/src/app/components/side-panel/side-panel.component.spec.ts
@@ -414,6 +414,37 @@ describe('SidePanelComponent', () => {
});
describe('Eval tab', () => {
+ it('initializes eval tab only once for the same container', () => {
+ const localFixture = TestBed.createComponent(SidePanelComponent);
+ const localComponent = localFixture.componentInstance;
+ localFixture.componentRef.setInput('appName', 'test-app');
+ localFixture.componentRef.setInput('showSidePanel', true);
+ const initEvalTabSpy =
+ spyOn(localComponent, 'initEvalTab').and.callThrough();
+
+ localFixture.detectChanges();
+ localFixture.detectChanges();
+
+ expect(initEvalTabSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('keeps dynamic eval tab inputs in sync', async () => {
+ await switchTab(EVAL_TAB_INDEX);
+
+ fixture.componentRef.setInput('appName', 'updated-app');
+ fixture.componentRef.setInput('userId', 'updated-user');
+ fixture.componentRef.setInput('sessionId', 'updated-session');
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges();
+
+ const evalTab = fixture.debugElement.query(EVAL_TAB_SELECTOR)
+ .componentInstance as EvalTabComponent;
+ expect(evalTab.appName()).toBe('updated-app');
+ expect(evalTab.userId()).toBe('updated-user');
+ expect(evalTab.sessionId()).toBe('updated-session');
+ });
+
describe('Interactions', () => {
beforeEach(async () => {
await switchTab(EVAL_TAB_INDEX);
diff --git a/src/app/components/side-panel/side-panel.component.ts b/src/app/components/side-panel/side-panel.component.ts
index e35f4f02..d6fbac60 100644
--- a/src/app/components/side-panel/side-panel.component.ts
+++ b/src/app/components/side-panel/side-panel.component.ts
@@ -16,7 +16,7 @@
*/
import {AsyncPipe, NgComponentOutlet, NgTemplateOutlet} from '@angular/common';
-import {AfterViewInit, Component, computed, effect, EnvironmentInjector, inject, input, output, runInInjectionContext, signal, Type, viewChild, ViewContainerRef, type WritableSignal} from '@angular/core';
+import {Component, ComponentRef, computed, effect, EnvironmentInjector, inject, input, output, signal, Type, viewChild, ViewContainerRef, type WritableSignal} from '@angular/core';
import {toObservable} from '@angular/core/rxjs-interop';
import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms';
import {MatMiniFabButton} from '@angular/material/button';
@@ -84,7 +84,7 @@ import {SidePanelMessagesInjectionToken} from './side-panel.component.i18n';
MatInput,
],
})
-export class SidePanelComponent implements AfterViewInit {
+export class SidePanelComponent {
protected readonly Object = Object;
appName = input('');
@@ -129,6 +129,9 @@ export class SidePanelComponent implements AfterViewInit {
readonly evalTabComponent = viewChild(EvalTabComponent);
readonly evalTabContainer =
viewChild('evalTabContainer', {read: ViewContainerRef});
+ private initializedEvalTabContainer: ViewContainerRef|undefined;
+ private readonly evalTabComponentRef =
+ signal|undefined>(undefined);
readonly logoComponent: Type|null = inject(LOGO_COMPONENT, {
optional: true,
@@ -204,11 +207,27 @@ export class SidePanelComponent implements AfterViewInit {
return artifacts;
});
- ngAfterViewInit() {
- // Wait one tick until the eval tab container is ready.
- setTimeout(() => {
- this.initEvalTab();
- }, 500);
+ constructor() {
+ // Initialize Eval tab only when its dynamic container becomes available.
+ effect(() => {
+ const container = this.evalTabContainer();
+ if (!container || container === this.initializedEvalTabContainer) {
+ return;
+ }
+ this.initEvalTab(container);
+ this.initializedEvalTabContainer = container;
+ });
+
+ // Keep dynamic eval-tab inputs in sync without nesting effects.
+ effect(() => {
+ const evalTabComponent = this.evalTabComponentRef();
+ if (!evalTabComponent) {
+ return;
+ }
+ evalTabComponent.setInput('appName', this.appName());
+ evalTabComponent.setInput('userId', this.userId());
+ evalTabComponent.setInput('sessionId', this.sessionId());
+ });
}
/**
@@ -216,23 +235,15 @@ export class SidePanelComponent implements AfterViewInit {
* ngComponentOutlet supports input/output bindings:
* https://github.com/angular/angular/issues/63099
*/
- private initEvalTab() {
+ private initEvalTab(container: ViewContainerRef) {
this.isEvalEnabledObs.pipe(first()).subscribe((isEvalEnabled) => {
if (isEvalEnabled) {
- const evalTabComponent = this.evalTabContainer()?.createComponent(
+ const evalTabComponent = container.createComponent(
this.evalTabComponentClass ?? EvalTabComponent, {
environmentInjector: this.environmentInjector,
});
if (!evalTabComponent) return;
-
- runInInjectionContext(this.environmentInjector, () => {
- // Ensure inputs are updated dynamically using effect.
- effect(() => {
- evalTabComponent.setInput('appName', this.appName());
- evalTabComponent.setInput('userId', this.userId());
- evalTabComponent.setInput('sessionId', this.sessionId());
- });
- });
+ this.evalTabComponentRef.set(evalTabComponent);
evalTabComponent.instance.sessionSelected.subscribe(
(session: Session) => {
this.sessionSelected.emit(session);