diff --git a/_theme-colors.scss b/_theme-colors.scss deleted file mode 100644 index 71e0a395..00000000 --- a/_theme-colors.scss +++ /dev/null @@ -1,185 +0,0 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// This file was generated by running 'ng generate @angular/material:theme-color'. -// Proceed with caution if making changes to this file. - -@use 'sass:map'; -@use '@angular/material' as mat; - -// Note: Color palettes are generated from primary: #9AA0A6, secondary: #89b4f8 -$_palettes: ( - primary: ( - 0: #000000, - 10: #161c21, - 20: #2b3136, - 25: #363c41, - 30: #41474d, - 35: #4d5358, - 40: #595f65, - 50: #72787d, - 60: #8b9197, - 70: #a6acb2, - 80: #c1c7cd, - 90: #dde3e9, - 95: #ecf1f8, - 98: #f6faff, - 99: #fbfcff, - 100: #ffffff, - ), - secondary: ( - 0: #000000, - 10: #001b3c, - 20: #003061, - 25: #003b74, - 30: #0f4784, - 35: #215390, - 40: #305f9d, - 50: #4c78b8, - 60: #6792d4, - 70: #82adf0, - 80: #a7c8ff, - 90: #d5e3ff, - 95: #ebf1ff, - 98: #f9f9ff, - 99: #fdfbff, - 100: #ffffff, - ), - tertiary: ( - 0: #000000, - 10: #1f1a21, - 20: #342e36, - 25: #403941, - 30: #4b454c, - 35: #575058, - 40: #635c64, - 50: #7c757d, - 60: #978e97, - 70: #b2a8b1, - 80: #cdc3cd, - 90: #eadfe9, - 95: #f8edf7, - 98: #fff7fc, - 99: #fffbff, - 100: #ffffff, - ), - neutral: ( - 0: #000000, - 10: #1c1b1c, - 20: #313030, - 25: #3c3b3b, - 30: #474647, - 35: #535252, - 40: #5f5e5e, - 50: #787777, - 60: #929090, - 70: #adabab, - 80: #c8c6c6, - 90: #e5e2e2, - 95: #f3f0f0, - 98: #fcf9f8, - 99: #fffbfb, - 100: #ffffff, - 4: #0e0e0e, - 6: #131314, - 12: #201f20, - 17: #2a2a2a, - 22: #353535, - 24: #393939, - 87: #dcd9d9, - 92: #eae7e7, - 94: #f0eded, - 96: #f6f3f3, - ), - neutral-variant: ( - 0: #000000, - 10: #191c1f, - 20: #2d3134, - 25: #393c3f, - 30: #44474a, - 35: #505356, - 40: #5c5f62, - 50: #74777b, - 60: #8e9194, - 70: #a9abaf, - 80: #c4c7ca, - 90: #e1e2e6, - 95: #eff1f4, - 98: #f8f9fd, - 99: #fbfcff, - 100: #ffffff, - ), - error: ( - 0: #000000, - 10: #410002, - 20: #690005, - 25: #7e0007, - 30: #93000a, - 35: #a80710, - 40: #ba1a1a, - 50: #de3730, - 60: #ff5449, - 70: #ff897d, - 80: #ffb4ab, - 90: #ffdad6, - 95: #ffedea, - 98: #fff8f7, - 99: #fffbff, - 100: #ffffff, - ), -); - -$_rest: ( - secondary: map.get($_palettes, secondary), - neutral: map.get($_palettes, neutral), - neutral-variant: map.get($_palettes, neutral-variant), - error: map.get($_palettes, error), -); - -$primary-palette: map.merge(map.get($_palettes, primary), $_rest); -$tertiary-palette: map.merge(map.get($_palettes, tertiary), $_rest); - -// Light theme - uses lighter tones for backgrounds, darker for text -$light-theme: mat.define-theme(( - color: ( - theme-type: light, - primary: $primary-palette, - tertiary: $tertiary-palette, - ), - typography: ( - brand-family: 'Google Sans', - plain-family: 'Google Sans', - ), - density: ( - scale: 0, - ) -)); - -// Dark theme - uses darker tones for backgrounds, lighter for text -$dark-theme: mat.define-theme(( - color: ( - theme-type: dark, - primary: $primary-palette, - tertiary: $tertiary-palette, - ), - typography: ( - brand-family: 'Google Sans', - plain-family: 'Google Sans', - ), - density: ( - scale: 0, - ) -)); \ No newline at end of file diff --git a/angular.json b/angular.json index c5344f1e..816f38aa 100644 --- a/angular.json +++ b/angular.json @@ -33,7 +33,17 @@ "src/assets" ], "styles": [ - "src/styles.scss" + "src/styles.scss", + { + "input": "node_modules/prismjs/themes/prism.css", + "bundleName": "prism-light", + "inject": false + }, + { + "input": "node_modules/prismjs/themes/prism-tomorrow.css", + "bundleName": "prism-dark", + "inject": false + } ], "scripts": [], "allowedCommonJsDependencies": [ @@ -41,7 +51,15 @@ "json-source-map", "natural-compare-lite", "ajv", - "jmespath" + "jmespath", + "prismjs", + "prismjs/components/prism-javascript", + "prismjs/components/prism-typescript", + "prismjs/components/prism-css", + "prismjs/components/prism-json", + "prismjs/components/prism-bash", + "prismjs/components/prism-python", + "prismjs/components/prism-yaml" ] }, "configurations": { @@ -105,7 +123,17 @@ } ], "styles": [ - "src/styles.scss" + "src/styles.scss", + { + "input": "node_modules/prismjs/themes/prism.css", + "bundleName": "prism-light", + "inject": false + }, + { + "input": "node_modules/prismjs/themes/prism-tomorrow.css", + "bundleName": "prism-dark", + "inject": false + } ], "scripts": [] } diff --git a/fix_vars.js b/fix_vars.js new file mode 100644 index 00000000..c1788207 --- /dev/null +++ b/fix_vars.js @@ -0,0 +1,18 @@ +const fs = require('fs'); +const path = require('path'); + +const chatComponentPath = path.resolve(__dirname, 'src/app/components/chat/chat.component.ts'); + +let content = fs.readFileSync(chatComponentPath, 'utf8'); + +// Fix unrenamed `messages` +content = content.replace(/\.\.\.messages/g, '...uiEvents'); +content = content.replace(/messages\.slice/g, 'uiEvents.slice'); +content = content.replace(/ \= \[\.\.\.messages\];/g, ' = [...uiEvents];'); +content = content.replace(/messages\.findIndex/g, 'uiEvents.findIndex'); +content = content.replace(/uiEvents\.forEach\(msg \=\>/g, 'this.uiEvents().forEach(msg =>'); +// Let's verify if there are any standalone `messages` +content = content.replace(/const existingIndex \= messages\.findIndex/g, 'const existingIndex = uiEvents.findIndex'); + +fs.writeFileSync(chatComponentPath, content); +console.log('Fixed chat.component.ts'); diff --git a/fix_vars_2.js b/fix_vars_2.js new file mode 100644 index 00000000..092896f1 --- /dev/null +++ b/fix_vars_2.js @@ -0,0 +1,23 @@ +const fs = require('fs'); +const path = require('path'); + +const chatComponentPath = path.resolve(__dirname, 'src/app/components/chat/chat.component.ts'); +let content = fs.readFileSync(chatComponentPath, 'utf8'); + +// Fix unrenamed `messages` +content = content.replace(/\.\.\.messages/g, '...uiEvents'); +content = content.replace(/ \= \[\.\.\.messages\];/g, ' = [...uiEvents];'); +content = content.replace(/messages\.findIndex/g, 'uiEvents.findIndex'); +content = content.replace(/uiEvents\.forEach\(msg \=\>/g, 'this.uiEvents().forEach(msg =>'); +content = content.replace(/messages\.slice/g, 'uiEvents.slice'); +fs.writeFileSync(chatComponentPath, content); +console.log('Fixed chat.component.ts'); + +const chatPanelHtmlPath = path.resolve(__dirname, 'src/app/components/chat-panel/chat-panel.component.html'); +let htmlContent = fs.readFileSync(chatPanelHtmlPath, 'utf8'); +htmlContent = htmlContent.replace(/\[allMessages\]\=\"messages\"/g, '[allMessages]="uiEvents"'); +htmlContent = htmlContent.replace(/\[messages\]\=\"messages\"/g, '[messages]="uiEvents"'); +htmlContent = htmlContent.replace(/messages\.length/g, 'uiEvents.length'); +htmlContent = htmlContent.replace(/messages\.includes/g, 'uiEvents.includes'); +fs.writeFileSync(chatPanelHtmlPath, htmlContent); +console.log('Fixed chat-panel.component.html'); diff --git a/rename_messages.js b/rename_messages.js new file mode 100644 index 00000000..37b8d477 --- /dev/null +++ b/rename_messages.js @@ -0,0 +1,51 @@ +const fs = require('fs'); +const path = require('path'); + +const srcDir = path.resolve(__dirname, 'src/app/components'); + +function walkDir(dir, callback) { + fs.readdirSync(dir).forEach(f => { + let dirPath = path.join(dir, f); + let isDirectory = fs.statSync(dirPath).isDirectory(); + isDirectory ? walkDir(dirPath, callback) : callback(path.join(dir, f)); + }); +} + +walkDir(srcDir, function(filePath) { + if (filePath.endsWith('.ts') || filePath.endsWith('.html') || filePath.endsWith('.scss')) { + if (filePath.includes('chat.component') || filePath.includes('chat-panel.component')) { + let originalContent = fs.readFileSync(filePath, 'utf8'); + let newContent = originalContent + .replace(/this\.messages/g, 'this.uiEvents') + .replace(/\bmessages\(\)/g, 'uiEvents()') + .replace(/messages = signal/g, 'uiEvents = signal') + .replace(/messages\.update/g, 'uiEvents.update') + .replace(/\(messages \=\> /g, '(uiEvents => ') + .replace(/messages\.map/g, 'uiEvents.map') + .replace(/messages\.filter/g, 'uiEvents.filter') + .replace(/messages\.forEach/g, 'uiEvents.forEach') + .replace(/messages\.length/g, 'uiEvents.length') + .replace(/\[messages\]="messages\(\)"/g, '[uiEvents]="uiEvents()"') + .replace(/messages\)/g, 'uiEvents)') + .replace(/\[messages\]=/, '[uiEvents]=') + .replace(/@Input\(\) messages\: /g, '@Input() uiEvents: ') + .replace(/messages\[/g, 'uiEvents[') + .replace(/let i = 0; i < messages\./g, 'let i = 0; i < uiEvents.') + .replace(/changes\['messages'\]/g, "changes['uiEvents']") + .replace(/for \(let message of messages/g, 'for (let uiEvent of uiEvents') + .replace(/for \(const message of messages/g, 'for (const uiEvent of uiEvents') + .replace(/for \(let message of this\.messages/g, 'for (let uiEvent of this.uiEvents') + .replace(/\.messages\s{0,9}=\s{0,9}messages;/g, '.uiEvents = uiEvents;') + .replace(/\bconst messages = component.messages\(\);/g, 'const uiEvents = component.uiEvents();') + .replace(/expect\(messages/g, 'expect(uiEvents') + .replace(/component\.messages\(\)/g, 'component.uiEvents()') + .replace(/component\.messages\.set/g, 'component.uiEvents.set') + .replace(/chatPanelComponent\.messages/g, 'chatPanelComponent.uiEvents'); + + if (originalContent !== newContent) { + fs.writeFileSync(filePath, newContent); + console.log('Updated', filePath); + } + } + } +}); diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index da35510f..88a0b12e 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -38,6 +38,7 @@ import {GRAPH_SERVICE} from './core/services/interfaces/graph'; import {LOCAL_FILE_SERVICE} from './core/services/interfaces/localfile'; import {SAFE_VALUES_SERVICE} from './core/services/interfaces/safevalues'; import {STRING_TO_COLOR_SERVICE} from './core/services/interfaces/string-to-color'; +import {THEME_SERVICE} from './core/services/interfaces/theme'; import {LOCATION_SERVICE} from './core/services/location.service'; import {SESSION_SERVICE} from './core/services/interfaces/session'; import {STREAM_CHAT_SERVICE} from './core/services/interfaces/stream-chat'; @@ -55,6 +56,7 @@ import {MockSafeValuesService} from './core/services/testing/mock-safevalues.ser import {MockSessionService} from './core/services/testing/mock-session.service'; import {MockStreamChatService} from './core/services/testing/mock-stream-chat.service'; import {MockStringToColorService} from './core/services/testing/mock-string-to-color.service'; +import {MockThemeService} from './core/services/testing/mock-theme.service'; import {MockTraceService} from './core/services/testing/mock-trace.service'; import {MockUiStateService} from './core/services/testing/mock-ui-state.service'; import {MockVideoService} from './core/services/testing/mock-video.service'; @@ -193,6 +195,10 @@ describe('AppComponent', () => { provide: AGENT_BUILDER_SERVICE, useValue: mockAgentBuilderService, }, + { + provide: THEME_SERVICE, + useClass: MockThemeService, + }, ], }) .compileComponents(); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 05243a37..490830ac 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -19,7 +19,7 @@ import {ChangeDetectionStrategy, Component} from '@angular/core'; import { ChatComponent } from './components/chat/chat.component'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-root', templateUrl: './app.component.html', styleUrl: './app.component.scss', diff --git a/src/app/components/add-callback-dialog/add-callback-dialog.component.scss b/src/app/components/add-callback-dialog/add-callback-dialog.component.scss index 0fc5dbef..04b9f39f 100644 --- a/src/app/components/add-callback-dialog/add-callback-dialog.component.scss +++ b/src/app/components/add-callback-dialog/add-callback-dialog.component.scss @@ -42,12 +42,7 @@ mat-form-field { margin-top: 8px !important; } -.mat-mdc-raised-button.mat-secondary:not([disabled]) { - background-color: #8ab4f8; -} - .callback-info-container { - background-color: rgba(138, 180, 248, 0.08); border: 1px solid rgba(138, 180, 248, 0.2); border-radius: 8px; padding: 16px; diff --git a/src/app/components/add-callback-dialog/add-callback-dialog.component.ts b/src/app/components/add-callback-dialog/add-callback-dialog.component.ts index 23786265..7f047ac3 100644 --- a/src/app/components/add-callback-dialog/add-callback-dialog.component.ts +++ b/src/app/components/add-callback-dialog/add-callback-dialog.component.ts @@ -44,7 +44,7 @@ export class ImmediateErrorStateMatcher implements ErrorStateMatcher { } @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-add-callback-dialog', templateUrl: './add-callback-dialog.component.html', styleUrl: './add-callback-dialog.component.scss', diff --git a/src/app/components/add-item-dialog/add-item-dialog.component.scss b/src/app/components/add-item-dialog/add-item-dialog.component.scss index 2d0dfb8c..7d1a9c8e 100644 --- a/src/app/components/add-item-dialog/add-item-dialog.component.scss +++ b/src/app/components/add-item-dialog/add-item-dialog.component.scss @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@use '@angular/material' as mat; .new-app-title { color: var(--mdc-dialog-subhead-color) !important; diff --git a/src/app/components/add-item-dialog/add-item-dialog.component.ts b/src/app/components/add-item-dialog/add-item-dialog.component.ts index 45e620ee..44a5e7d6 100644 --- a/src/app/components/add-item-dialog/add-item-dialog.component.ts +++ b/src/app/components/add-item-dialog/add-item-dialog.component.ts @@ -34,7 +34,7 @@ import { FormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-add-item-dialog', templateUrl: './add-item-dialog.component.html', styleUrl: './add-item-dialog.component.scss', diff --git a/src/app/components/add-tool-dialog/add-tool-dialog.component.scss b/src/app/components/add-tool-dialog/add-tool-dialog.component.scss index 5021f35b..ffd5212a 100644 --- a/src/app/components/add-tool-dialog/add-tool-dialog.component.scss +++ b/src/app/components/add-tool-dialog/add-tool-dialog.component.scss @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@use '@angular/material' as mat; .dialog-title { color: var(--mdc-dialog-supporting-text-color) !important; @@ -29,7 +28,6 @@ mat-dialog-content { } .tool-info-container { - background-color: rgba(138, 180, 248, 0.08); border: 1px solid rgba(138, 180, 248, 0.2); border-radius: 8px; padding: 16px; diff --git a/src/app/components/add-tool-dialog/add-tool-dialog.component.ts b/src/app/components/add-tool-dialog/add-tool-dialog.component.ts index c4429d62..c02062a8 100644 --- a/src/app/components/add-tool-dialog/add-tool-dialog.component.ts +++ b/src/app/components/add-tool-dialog/add-tool-dialog.component.ts @@ -27,7 +27,7 @@ import { MatIcon } from '@angular/material/icon'; import { TooltipUtil } from '../../../utils/tooltip-util'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-add-tool-dialog', templateUrl: './add-tool-dialog.component.html', styleUrl: './add-tool-dialog.component.scss', diff --git a/src/app/components/agent-structure-graph-dialog/agent-structure-graph-dialog.html b/src/app/components/agent-structure-graph-dialog/agent-structure-graph-dialog.html new file mode 100644 index 00000000..77fa48ac --- /dev/null +++ b/src/app/components/agent-structure-graph-dialog/agent-structure-graph-dialog.html @@ -0,0 +1,84 @@ + + +
+
+
+ + @if (renderedGraph() && breadcrumbs().length > 0) { + + } + + + +
+ Agent + Workflow + ƒ Function + Join + 🔧 Tool +
+ + +
+ +
+
+ @if (isLoading()) { +
+ +

Loading agent structure...

+
+ } @else if (errorMessage()) { +
+ error_outline +

{{ errorMessage() }}

+
+ } @else if (renderedGraph()) { +
+ } @else { +
+ account_tree +

Agent structure graph not available.

+
+ } +
+
+
diff --git a/src/app/components/agent-structure-graph-dialog/agent-structure-graph-dialog.scss b/src/app/components/agent-structure-graph-dialog/agent-structure-graph-dialog.scss new file mode 100644 index 00000000..620c60ce --- /dev/null +++ b/src/app/components/agent-structure-graph-dialog/agent-structure-graph-dialog.scss @@ -0,0 +1,217 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +:host { + display: block; + position: fixed; + inset: 0; + z-index: 1000; + display: flex; + align-items: center; + justify-content: center; +} + +.overlay-backdrop { + position: absolute; + inset: 0; + background-color: rgba(0, 0, 0, 0.7); +} + +.overlay-panel { + position: relative; + width: 100vw; + height: 100vh; + display: flex; + flex-direction: column; + background-color: transparent; + color: var(--mat-sys-on-surface); + border-radius: 0; + overflow: hidden; + box-shadow: none; +} + +.overlay-header { + display: flex; + align-items: center; + height: 48px; + padding: 0 16px; + box-sizing: border-box; + border-bottom: 1px solid var(--mat-sys-outline-variant); + background-color: var(--mat-sys-surface-container); +} + +.overlay-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.graph-container { + display: flex; + flex-direction: column; + flex: 1; + min-height: 0; +} + +.agent-info { + margin: 0 0 8px 0; + font-size: 14px; + color: var(--mdc-dialog-supporting-text-color); + + strong { + font-weight: 600; + color: var(--mdc-dialog-supporting-text-color); + } +} + +.svg-container { + flex: 1; + position: relative; + overflow: hidden; + background-color: transparent; + + ::ng-deep svg { + position: absolute; + top: 0; + left: 0; + transform-origin: 0 0; + cursor: grab; + border-radius: 16px; + box-shadow: 0 4px 12px rgba(0,0,0,0.3); + + /* Hide internal graphviz background so the container's background shines through perfectly */ + > g.graph > polygon:first-child { + fill: transparent !important; + stroke: transparent !important; + } + + opacity: 0; + transition: opacity 0.1s ease-in-out; + + &.ready { + opacity: 1; + } + + &:active { + cursor: grabbing; + } + } +} + +:host-context(.dark-theme) .svg-container ::ng-deep svg { + background-color: #0e172a; +} + +:host-context(.light-theme) .svg-container ::ng-deep svg { + background-color: #f9fafc; +} + +.loading-container, +.error-container, +.no-graph-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 400px; + padding: 40px; +} + +.loading-container p, +.error-container p, +.no-graph-container p { + margin-top: 16px; + font-size: 14px; + color: var(--mdc-dialog-supporting-text-color); +} + +.error-icon { + font-size: 48px; + width: 48px; + height: 48px; + color: #f44336; +} + +.error-message { + color: #f44336 !important; +} + +.large-icon { + font-size: 64px; + width: 64px; + height: 64px; + color: var(--mdc-dialog-supporting-text-color); + opacity: 0.6; +} + +.breadcrumb-container { + display: flex; + align-items: center; + gap: 4px; + margin-left: 8px; + padding: 0; + background-color: transparent; + flex-wrap: wrap; +} + +.breadcrumb-item { + background: none; + border: none; + padding: 4px 8px; + cursor: pointer; + color: var(--mat-sys-primary); + font-size: 13px; + border-radius: 4px; + transition: background-color 0.2s; + + &:hover:not(:disabled) { + background-color: var(--mat-sys-surface-container-high); + } + + &:disabled, &.active { + color: var(--mat-sys-on-surface); + cursor: default; + font-weight: 600; + } +} + +.breadcrumb-separator { + font-size: 16px; + width: 16px; + height: 16px; + color: var(--mat-sys-on-surface-variant); +} + +.graph-legend { + display: flex; + align-items: center; + gap: 16px; + margin-right: 16px; + font-size: 13px; + color: var(--mat-sys-on-surface-variant); + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 8px; + padding: 6px 16px; + background-color: var(--mat-sys-surface-container-lowest); + + .legend-item { + display: flex; + align-items: center; + gap: 4px; + font-weight: 500; + } +} \ No newline at end of file diff --git a/src/app/components/agent-structure-graph-dialog/agent-structure-graph-dialog.ts b/src/app/components/agent-structure-graph-dialog/agent-structure-graph-dialog.ts new file mode 100644 index 00000000..f15fe0e1 --- /dev/null +++ b/src/app/components/agent-structure-graph-dialog/agent-structure-graph-dialog.ts @@ -0,0 +1,413 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, inject, OnInit, signal, Input, Output, EventEmitter } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { CommonModule } from '@angular/common'; +import { SafeHtml } from '@angular/platform-browser'; +import { AGENT_SERVICE } from '../../core/services/interfaces/agent'; +import { GRAPH_SERVICE } from '../../core/services/interfaces/graph'; +import { SAFE_VALUES_SERVICE } from '../../core/services/interfaces/safevalues'; +import { THEME_SERVICE } from '../../core/services/interfaces/theme'; +import { addSvgNodeHoverEffects } from '../../utils/svg-interaction.utils'; +import { NavigationStackItem, hasNestedStructure, findNodeInLevel, getNodesAtLevel } from '../../utils/graph-navigation.utils'; +import { getNodeName } from '../../utils/graph-layout.utils'; + +@Component({ + selector: 'app-agent-structure-graph-dialog', + templateUrl: './agent-structure-graph-dialog.html', + styleUrls: ['./agent-structure-graph-dialog.scss'], + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatIconModule, + MatProgressSpinnerModule, + ], +}) +export class AgentStructureGraphDialogComponent implements OnInit { + @Input() appName!: string; + @Input() preloadedAppData?: any; + @Input() preloadedLightGraphSvg?: Record | null; + @Input() preloadedDarkGraphSvg?: Record | null; + @Input() startPath?: string; + @Output() close = new EventEmitter(); + + private readonly agentService = inject(AGENT_SERVICE); + private readonly graphService = inject(GRAPH_SERVICE); + private readonly sanitizer = inject(SAFE_VALUES_SERVICE); + private readonly themeService = inject(THEME_SERVICE); + + public renderedGraph = signal(null); + public isLoading = signal(true); + public errorMessage = signal(null); + + // Navigation state + private fullAppData: any = null; + private navigationStack: NavigationStackItem[] = []; + public breadcrumbs = signal([]); + + // Panning and zooming state + private isPanning = false; + private wasDragging = false; + private dragStartX = 0; + private dragStartY = 0; + private startPanX = 0; + private startPanY = 0; + private scale = 1; + private translateX = 0; + private translateY = 0; + + private lastMousedownTarget: HTMLElement | null = null; + + onOverlayMouseDown(event: MouseEvent): void { + this.lastMousedownTarget = event.target as HTMLElement; + } + + onBackdropClick(event: MouseEvent): void { + if (this.wasDragging) return; + + if (this.lastMousedownTarget) { + if ( + this.lastMousedownTarget.closest('svg') || + this.lastMousedownTarget.closest('.overlay-header') || + this.lastMousedownTarget.closest('.loading-container') || + this.lastMousedownTarget.closest('.error-container') || + this.lastMousedownTarget.closest('.no-graph-container') + ) { + return; // Mousedown started on a non-backdrop element. Don't close. + } + } + + const target = event.target as HTMLElement; + if ( + !target.closest('svg') && + !target.closest('.overlay-header') && + !target.closest('.loading-container') && + !target.closest('.error-container') && + !target.closest('.no-graph-container') + ) { + this.close.emit(); + } + } + + ngOnInit(): void { + this.loadAgentGraph(); + } + + private loadAgentGraph(): void { + this.isLoading.set(true); + this.errorMessage.set(null); + this.renderedGraph.set(null); + + // If preloaded app data is provided, use it directly + if (this.preloadedAppData) { + this.fullAppData = this.preloadedAppData; + this.navigationStack = [{ + name: this.fullAppData.root_agent?.name || this.appName, + data: this.fullAppData.root_agent + }]; + if (this.startPath) { + let currentData = this.fullAppData.root_agent; + const segments = this.startPath.split('/'); + for (const segment of segments) { + if (!segment) continue; + const nodeData = findNodeInLevel(currentData, segment); + if (nodeData) { + this.navigationStack.push({ name: segment, data: nodeData }); + currentData = nodeData; + } else { + break; + } + } + } + this.updateBreadcrumbs(); + this.renderCurrentLevel(); + return; + } + + // Otherwise fetch full app data for navigation + this.agentService.getAppInfo(this.appName).subscribe({ + next: (appData: any) => { + this.fullAppData = appData; + this.navigationStack = [{ + name: appData.root_agent?.name || this.appName, + data: appData.root_agent + }]; + if (this.startPath) { + let currentData = this.fullAppData.root_agent; + const segments = this.startPath.split('/'); + for (const segment of segments) { + if (!segment) continue; + const nodeData = findNodeInLevel(currentData, segment); + if (nodeData) { + this.navigationStack.push({ name: segment, data: nodeData }); + currentData = nodeData; + } else { + break; + } + } + } + this.updateBreadcrumbs(); + this.renderCurrentLevel(); + }, + error: (error) => { + console.error('Error loading app data:', error); + this.errorMessage.set('Agent structure graph not available.'); + this.isLoading.set(false); + }, + }); + } + + private renderCurrentLevel(): void { + const isDarkMode = this.themeService.currentTheme() === 'dark'; + const currentPath = this.getCurrentPath(); + + const preloadedGraphs = isDarkMode ? this.preloadedDarkGraphSvg : this.preloadedLightGraphSvg; + const preloadedGraph = preloadedGraphs ? preloadedGraphs[currentPath] : null; + + if (preloadedGraph) { + this.renderedGraph.set(this.sanitizer.bypassSecurityTrustHtml(preloadedGraph)); + this.isLoading.set(false); + setTimeout(() => { + const expandableNodes = this.getExpandableNodes(); + addSvgNodeHoverEffects('.svg-container', (nodeName) => { + if (this.wasDragging) return; + this.onNodeClick(nodeName); + }, expandableNodes); + + this.initializeSvgTransform(); + }, 50); + return; + } + + this.agentService.getAppGraphImage(this.appName, isDarkMode, currentPath).subscribe({ + next: async (response: any) => { + try { + if (!response?.dotSrc) { + this.errorMessage.set('Agent structure graph not available.'); + this.isLoading.set(false); + return; + } + const svg = await this.graphService.render(response.dotSrc); + this.renderedGraph.set(this.sanitizer.bypassSecurityTrustHtml(svg)); + this.isLoading.set(false); + + // Add hover effects after rendering + setTimeout(() => { + const expandableNodes = this.getExpandableNodes(); + addSvgNodeHoverEffects('.svg-container', (nodeName) => { + if (this.wasDragging) return; // Prevent click if user was panning graph + this.onNodeClick(nodeName); + }, expandableNodes); + + this.initializeSvgTransform(); + }, 50); + } catch (error) { + console.error('Error rendering graph:', error); + this.errorMessage.set('Agent structure graph not available.'); + this.isLoading.set(false); + } + }, + error: (error) => { + console.error('Error loading agent graph:', error); + this.errorMessage.set('Agent structure graph not available.'); + this.isLoading.set(false); + }, + }); + } + + private getCurrentPath(): string { + if (this.navigationStack.length <= 1) { + return ''; + } + // Skip the first element (root) and join the rest + return this.navigationStack.slice(1).map(item => item.name).join('/'); + } + + private updateBreadcrumbs(): void { + this.breadcrumbs.set(this.navigationStack.map(item => item.name)); + } + + private onNodeClick(nodeName: string): void { + const currentData = this.navigationStack[this.navigationStack.length - 1].data; + const nodeData = findNodeInLevel(currentData, nodeName); + + if (nodeData && hasNestedStructure(nodeData)) { + this.navigateIntoNode(nodeName, nodeData); + } + } + + navigateIntoNode(nodeName: string, nodeData: any): void { + this.navigationStack.push({ name: nodeName, data: nodeData }); + this.updateBreadcrumbs(); + this.isLoading.set(true); + this.renderCurrentLevel(); + } + + navigateToLevel(index: number): void { + if (index >= 0 && index < this.navigationStack.length) { + this.navigationStack = this.navigationStack.slice(0, index + 1); + this.updateBreadcrumbs(); + this.isLoading.set(true); + this.renderCurrentLevel(); + } + } + + private getExpandableNodes(): Set { + const expandableNodes = new Set(); + + if (!this.navigationStack.length) { + return expandableNodes; + } + + const currentData = this.navigationStack[this.navigationStack.length - 1].data; + const currentNodeName = this.navigationStack[this.navigationStack.length - 1].name; + const nodes = getNodesAtLevel(currentData); + + nodes.forEach((node: any) => { + const nodeName = getNodeName(node); + if (nodeName !== currentNodeName && hasNestedStructure(node)) { + expandableNodes.add(nodeName); + } + }); + + return expandableNodes; + } + + // --- Pan and Zoom logic --- + private getSvgElement(): SVGSVGElement | null { + return document.querySelector('.svg-container svg') as SVGSVGElement | null; + } + + private applyTransform() { + const svg = this.getSvgElement(); + if (svg) { + svg.style.transform = `translate(${this.translateX}px, ${this.translateY}px) scale(${this.scale})`; + } + } + + private initializeSvgTransform() { + const svg = this.getSvgElement(); + const container = document.querySelector('.svg-container') as HTMLElement; + if (!svg || !container) return; + + const svgRect = svg.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + // Scale slightly to fit inside container with some padding if it's too big + const padding = 48; + const scaleX = (containerRect.width - padding) / svgRect.width; + const scaleY = (containerRect.height - padding) / svgRect.height; + this.scale = Math.min(1, scaleX, scaleY); + + const scaledWidth = svgRect.width * this.scale; + const scaledHeight = svgRect.height * this.scale; + + // Center properly + this.translateX = (containerRect.width - scaledWidth) / 2; + this.translateY = (containerRect.height - scaledHeight) / 2; + + this.applyTransform(); + + // Reveal visually after transforming to avoid flash! + requestAnimationFrame(() => { + svg.classList.add('ready'); + }); + } + + onWheel(event: WheelEvent) { + const container = document.querySelector('.svg-container') as HTMLElement; + const svg = this.getSvgElement(); + if (!container || !svg) return; + + // Prevent modal scrolling + event.preventDefault(); + + const clampedDelta = Math.max(-100, Math.min(100, event.deltaY)); + const zoomFactor = Math.pow(1.002, -clampedDelta); + const newScale = this.scale * zoomFactor; + + const rect = container.getBoundingClientRect(); + const posX = event.clientX - rect.left; + const posY = event.clientY - rect.top; + + const localX = (posX - this.translateX) / this.scale; + const localY = (posY - this.translateY) / this.scale; + + this.translateX = posX - localX * newScale; + this.translateY = posY - localY * newScale; + this.scale = newScale; + + this.applyTransform(); + } + + onMouseDown(event: MouseEvent) { + if (event.button !== 0) return; // Only target left clicks + const target = event.target as HTMLElement; + if (!target.closest('svg')) return; // Ensure they grabbed the small SVG panel! + + this.isPanning = true; + this.wasDragging = false; + this.dragStartX = event.clientX; + this.dragStartY = event.clientY; + + this.startPanX = event.clientX; + this.startPanY = event.clientY; + + const svg = this.getSvgElement(); + if (svg) svg.style.cursor = 'grabbing'; + } + + onMouseMove(event: MouseEvent) { + if (!this.isPanning) return; + + if (!this.wasDragging) { + const dx = event.clientX - this.dragStartX; + const dy = event.clientY - this.dragStartY; + if (dx * dx + dy * dy > 25) { // 5px threshold for drag vs click + this.wasDragging = true; + } + } + + this.translateX += (event.clientX - this.startPanX); + this.translateY += (event.clientY - this.startPanY); + + this.startPanX = event.clientX; + this.startPanY = event.clientY; + + this.applyTransform(); + } + + onMouseUp() { + this.isPanning = false; + const svg = this.getSvgElement(); + if (svg) svg.style.cursor = ''; + + // Reset wasDragging safely after the event loop handles clicks + setTimeout(() => { + this.wasDragging = false; + }, 50); + } + + resetZoomPan() { + this.initializeSvgTransform(); + } + +} diff --git a/src/app/components/artifact-tab/artifact-tab.component.scss b/src/app/components/artifact-tab/artifact-tab.component.scss index 341bc35a..ba8451e0 100644 --- a/src/app/components/artifact-tab/artifact-tab.component.scss +++ b/src/app/components/artifact-tab/artifact-tab.component.scss @@ -35,7 +35,6 @@ } .download-button { - background-color: var(--artifact-tab-download-button-background-color) !important; margin-left: 35px; width: 130px; height: 28px; @@ -56,13 +55,11 @@ hr.white-separator { } .version-select-container { - background-color: var(--artifact-tab-version-select-container-background-color); width: 80px; margin-left: 15px; } .link-style-button { - background: none; border: none; padding: 0; font: inherit; diff --git a/src/app/components/artifact-tab/artifact-tab.component.ts b/src/app/components/artifact-tab/artifact-tab.component.ts index b89dbf31..d40c3602 100644 --- a/src/app/components/artifact-tab/artifact-tab.component.ts +++ b/src/app/components/artifact-tab/artifact-tab.component.ts @@ -88,7 +88,7 @@ export function isArtifactAudio(mimeType: string): boolean { @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-artifact-tab', templateUrl: './artifact-tab.component.html', styleUrl: './artifact-tab.component.scss', diff --git a/src/app/components/audio-player/audio-player.component.scss b/src/app/components/audio-player/audio-player.component.scss index fce473c1..300a56c6 100644 --- a/src/app/components/audio-player/audio-player.component.scss +++ b/src/app/components/audio-player/audio-player.component.scss @@ -4,7 +4,6 @@ justify-content: center; align-items: center; padding: 15px; - background-color: var(--audio-player-container-background-color); border-radius: 8px; box-shadow: 0 2px 5px var(--audio-player-container-box-shadow-color); margin: 20px auto; @@ -29,7 +28,6 @@ audio { padding: 8px 15px; border: none; border-radius: 5px; - background-color: var(--audio-player-custom-controls-button-background-color); color: var(--audio-player-custom-controls-button-color); cursor: pointer; font-size: 14px; @@ -37,5 +35,4 @@ audio { } .custom-controls button:hover { - background-color: var(--audio-player-custom-controls-button-hover-background-color); } \ No newline at end of file diff --git a/src/app/components/audio-player/audio-player.component.ts b/src/app/components/audio-player/audio-player.component.ts index 3166a92a..cbf97ddb 100644 --- a/src/app/components/audio-player/audio-player.component.ts +++ b/src/app/components/audio-player/audio-player.component.ts @@ -18,7 +18,7 @@ import {ChangeDetectionStrategy, Component, ElementRef, input, OnChanges, SimpleChanges, viewChild} from '@angular/core'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-audio-player', templateUrl: './audio-player.component.html', styleUrls: ['./audio-player.component.scss'], diff --git a/src/app/components/builder-assistant/builder-assistant.component.html b/src/app/components/builder-assistant/builder-assistant.component.html index 26fe388c..9a30469a 100644 --- a/src/app/components/builder-assistant/builder-assistant.component.html +++ b/src/app/components/builder-assistant/builder-assistant.component.html @@ -51,7 +51,11 @@

Assistant Ready

} @else { @if (message.role === 'bot') {
Assistant
- + @if (message.isError) { +
{{message.text}}
+ } @else { + + } } @else {
{{message.text}}
} diff --git a/src/app/components/builder-assistant/builder-assistant.component.scss b/src/app/components/builder-assistant/builder-assistant.component.scss index 6ad4aceb..edc43d5e 100644 --- a/src/app/components/builder-assistant/builder-assistant.component.scss +++ b/src/app/components/builder-assistant/builder-assistant.component.scss @@ -20,10 +20,9 @@ top: 72px; // Leave space for the builder mode action buttons (20px top + 40px height + 12px padding) width: 400px; height: calc(100vh - 72px); // Adjust height to account for top offset - background: var(--builder-assistant-panel-background-color); - border-left: 1px solid var(--builder-assistant-panel-border-color); + background-color: var(--mat-sys-surface-container); + border-left: 1px solid var(--mat-sys-outline-variant); box-shadow: -2px 0 10px rgba(0, 0, 0, 0.4); - z-index: 999; // Lower than action buttons (1000) display: flex; flex-direction: column; transition: transform 0.3s ease; @@ -38,8 +37,7 @@ align-items: center; justify-content: space-between; padding: 16px 20px; - border-bottom: 1px solid var(--builder-assistant-panel-border-color); - background: var(--builder-assistant-panel-header-background-color); + border-bottom: 1px solid var(--mat-sys-outline-variant); } .panel-title { @@ -48,11 +46,11 @@ gap: 8px; font-weight: 400; font-size: 16px; - color: var(--builder-text-primary-color); + color: var(--mat-sys-on-surface); font-family: 'Google Sans', 'Helvetica Neue', sans-serif; mat-icon { - color: var(--builder-text-primary-color); + color: var(--mat-sys-on-surface); font-size: 20px; width: 20px; height: 20px; @@ -60,11 +58,10 @@ } .close-btn { - color: var(--builder-text-tertiary-color); + color: var(--mat-sys-on-surface-variant); &:hover { - color: var(--builder-text-primary-color); - background-color: var(--builder-add-button-background-color); + color: var(--mat-sys-on-surface); } } @@ -72,7 +69,6 @@ flex: 1; display: flex; flex-direction: column; - background: var(--builder-assistant-panel-background-color); overflow: hidden; } @@ -83,21 +79,21 @@ justify-content: center; text-align: center; height: 300px; - color: var(--builder-text-secondary-color); + color: var(--mat-sys-on-surface-variant); .large-icon { font-size: 64px; width: 64px; height: 64px; margin-bottom: 16px; - color: var(--builder-button-primary-background-color); + color: var(--mat-sys-primary); } h3 { margin: 0 0 8px 0; font-size: 20px; font-weight: 500; - color: var(--builder-text-primary-color); + color: var(--mat-sys-on-surface); font-family: 'Google Sans', 'Helvetica Neue', sans-serif; } @@ -105,7 +101,7 @@ margin: 0; font-size: 14px; line-height: 1.5; - color: var(--builder-text-secondary-color); + color: var(--mat-sys-on-surface-variant); } } @@ -120,13 +116,13 @@ .chat-input-container { padding: 16px 20px 20px 20px; border-top: none; - background: var(--builder-assistant-panel-background-color); } .input-wrapper { display: flex; align-items: center; - background-color: var(--builder-assistant-input-background-color); + background-color: var(--mat-sys-surface-container-highest); + border: 1px solid var(--mat-sys-outline-variant); border-radius: 50px; padding: 10px 6px 10px 18px; gap: 8px; @@ -134,10 +130,10 @@ .assistant-input-box { flex: 1; - color: var(--builder-assistant-input-text-color); + color: var(--mat-sys-on-surface); + background-color: transparent; border: none; padding: 0; - background: transparent; resize: none; overflow: hidden; font-family: 'Google Sans', 'Helvetica Neue', sans-serif; @@ -147,7 +143,7 @@ max-height: 120px; &::placeholder { - color: var(--builder-assistant-input-placeholder-color); + color: var(--mat-sys-on-surface-variant); font-size: 14px; } @@ -160,14 +156,13 @@ } &::-webkit-scrollbar-thumb { - background: var(--builder-border-color); + background-color: var(--mat-sys-outline); border-radius: 4px; } } .send-button { - background-color: transparent; - color: var(--builder-assistant-send-button-color); + color: var(--mat-sys-primary); width: 36px; height: 36px; min-width: 36px; @@ -175,22 +170,12 @@ margin: 0; padding: 0; - ::ng-deep .mat-mdc-button-touch-target { - display: none; - } - - ::ng-deep .mat-mdc-button-persistent-ripple { - display: none; - } - &:disabled { - background-color: transparent; - color: var(--builder-assistant-send-button-disabled-color); + color: var(--mat-sys-outline); } &:hover:not(:disabled) { - background-color: var(--builder-add-button-background-color); - color: var(--builder-assistant-send-button-hover-color); + color: var(--mat-sys-primary); border-radius: 50%; } @@ -220,10 +205,9 @@ margin-bottom: 12px; .message-card { - background-color: var(--builder-assistant-user-message-background-color); - border: 1px solid var(--builder-assistant-user-message-border-color); + border: 1px solid var(--mat-sys-outline-variant); border-radius: 4px; - color: var(--builder-assistant-user-message-text-color); + color: var(--mat-sys-on-surface); padding: 8px 12px; } } @@ -234,10 +218,9 @@ margin-bottom: 0; .message-card { - background-color: transparent; border: none; border-radius: 0; - color: var(--builder-assistant-bot-message-text-color); + color: var(--mat-sys-on-surface); padding: 0; margin: 0; } @@ -246,11 +229,20 @@ .bot-label { font-size: 12px; font-weight: 500; - color: var(--builder-text-secondary-color); + color: var(--mat-sys-on-surface-variant); margin-bottom: 8px; font-family: 'Google Sans', 'Helvetica Neue', sans-serif; } +.error-message { + color: var(--mat-app-warn, #d32f2f); + font-family: 'Google Sans', 'Helvetica Neue', sans-serif; + font-size: 14px; + white-space: pre-line; + word-break: break-word; + padding: 8px 12px; +} + .message-text { white-space: pre-line; word-break: break-word; @@ -283,7 +275,6 @@ } code { - background-color: rgba(255, 255, 255, 0.1); padding: 2px 4px; border-radius: 3px; font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; @@ -291,24 +282,22 @@ } pre { - background-color: rgba(255, 255, 255, 0.05); padding: 8px 12px; border-radius: 6px; overflow-x: auto; margin: 0.5em 0; code { - background: none; padding: 0; } } blockquote { - border-left: 3px solid var(--builder-button-primary-background-color); + border-left: 3px solid var(--mat-sys-primary); padding-left: 12px; margin: 0.5em 0; font-style: italic; - color: var(--builder-text-tertiary-color); + color: var(--mat-sys-on-surface-variant); } strong { @@ -324,7 +313,7 @@ .loading-message { display: flex; align-items: center; - color: var(--builder-text-secondary-color); + color: var(--mat-sys-on-surface-variant); font-family: 'Google Sans', 'Helvetica Neue', sans-serif; padding: 0; margin: 0; diff --git a/src/app/components/builder-assistant/builder-assistant.component.ts b/src/app/components/builder-assistant/builder-assistant.component.ts index ec882783..615ddc51 100644 --- a/src/app/components/builder-assistant/builder-assistant.component.ts +++ b/src/app/components/builder-assistant/builder-assistant.component.ts @@ -32,7 +32,7 @@ import { YamlUtils } from '../../../utils/yaml-utils'; import {MARKDOWN_COMPONENT, MarkdownComponentInterface} from '../markdown/markdown.component.interface'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-builder-assistant', templateUrl: './builder-assistant.component.html', styleUrl: './builder-assistant.component.scss', @@ -98,6 +98,17 @@ export class BuilderAssistantComponent implements OnInit, AfterViewChecked { this.agentService.runSse(req).subscribe({ next: async (chunk) => { + if (chunk.errorCode) { + const lastMessage = this.messages[this.messages.length - 1]; + if (lastMessage.role === 'bot' && lastMessage.isLoading) { + lastMessage.text = `Error Code: ${chunk.errorCode}`; + lastMessage.isLoading = false; + lastMessage.isError = true; + this.shouldAutoScroll = true; + } + this.isGenerating = false; + return; + } if (chunk.content) { let botText = ''; for (let part of chunk.content.parts) { @@ -169,8 +180,15 @@ export class BuilderAssistantComponent implements OnInit, AfterViewChecked { this.agentService.runSse(req).subscribe({ next: async (chunk) => { - if (chunk.errorCode && (chunk.errorCode == "MALFORMED_FUNCTION_CALL" || chunk.errorCode == "STOP")) { - this.sendMessage("____Something went wrong, please try again"); + if (chunk.errorCode) { + const lastMessage = this.messages[this.messages.length - 1]; + if (lastMessage.role === 'bot' && lastMessage.isLoading) { + lastMessage.text = `Error Code: ${chunk.errorCode}`; + lastMessage.isLoading = false; + lastMessage.isError = true; + this.shouldAutoScroll = true; + } + this.isGenerating = false; return; } if (chunk.content) { diff --git a/src/app/components/builder-tabs/builder-tabs.component.scss b/src/app/components/builder-tabs/builder-tabs.component.scss index 4b2cdf3a..95018ba5 100644 --- a/src/app/components/builder-tabs/builder-tabs.component.scss +++ b/src/app/components/builder-tabs/builder-tabs.component.scss @@ -14,7 +14,6 @@ * limitations under the License. */ -@use '@angular/material' as mat; // Builder tabs container .builder-tabs-container { width: 100%; @@ -35,7 +34,6 @@ } .breadcrumb-chip { - background-color: transparent; color: var(--builder-text-muted-color); font-family: 'Google Sans'; font-size: 16px; @@ -77,22 +75,6 @@ font-size: 14px; line-height: 1.5; } - - @include mat.form-field-overrides( - ( - filled-container-color: var(--builder-form-field-background-color), - filled-focus-active-indicator-color: var(--builder-form-field-background-color), - filled-active-indicator-color: var(--builder-form-field-background-color), - filled-hover-active-indicator-color: var(--builder-form-field-background-color), - filled-label-text-color: var(--builder-text-secondary-color), - filled-focus-label-text-color: var(--builder-text-link-color), - filled-hover-label-text-color: var(--builder-text-secondary-color), - ) - ); -} - -:host ::ng-deep .mat-mdc-text-field-wrapper { - border: none !important; } // Components section header @@ -180,11 +162,9 @@ } .tree-view mat-tree { - background-color: inherit !important; } .tree-view expand-button { - background-color: transparent; border: 0; } @@ -225,7 +205,6 @@ justify-content: space-between; align-items: center; margin-bottom: 4px; - background-color: var(--builder-card-background-color); color: var(--builder-text-primary-color); font-family: 'Google Sans Mono', monospace; font-size: 14px; @@ -239,7 +218,6 @@ } &:hover { - background-color: var(--builder-hover-background-color); button { visibility: visible; @@ -256,131 +234,6 @@ padding-right: 8px; /* Add some space between text and delete button */ } -// Tools chips styling -::ng-deep .tools-chips-container { - .mat-mdc-chip-set { - width: 100%; - } - - &.callbacks-list .mat-mdc-chip-set { - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - } - - .mat-mdc-chip.tool-chip { - background-color: var(--builder-tool-chip-background-color); - color: var(--builder-text-primary-color); - font-family: 'Google Sans', sans-serif; - font-size: 14px; - font-weight: 500; - cursor: pointer; - margin: 4px; - - &:hover { - background-color: var(--builder-tool-chip-hover-color); - } - } - - .mat-mdc-chip.tool-chip .mat-mdc-chip-action-label { - display: flex; - align-items: center; - gap: 6px; - } - - .mat-mdc-chip.tool-chip .tool-chip-name { - display: inline-flex; - align-items: center; - } - - .mat-mdc-chip.tool-chip .tool-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - - .mat-mdc-chip.tool-chip .mat-mdc-chip-remove { - opacity: 1; - color: var(--builder-text-secondary-color); - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - - &:hover { - color: var(--builder-text-primary-color); - } - } - - .mat-mdc-chip.callback-chip { - background: var(--builder-callback-chip-background-color); - background-color: var(--builder-callback-chip-background-color); - color: var(--builder-callback-chip-text-color); - font-family: 'Google Sans', sans-serif; - font-size: 14px; - display: flex; - flex-direction: row; - align-items: center; - gap: 12px; - width: auto; - height: 40px; - border-radius: 8px; - border: none; - box-shadow: none; - outline: none; - --mdc-chip-outline-width: 0; - --mdc-chip-outline-color: transparent; - --mdc-chip-elevated-container-color: var(--builder-callback-chip-background-color); - --mdc-chip-flat-container-color: var(--builder-callback-chip-background-color); - flex: 1 1 auto; - min-width: 0; - } - - .mat-mdc-chip.callback-chip::before, - .mat-mdc-chip.callback-chip::after, - .mat-mdc-chip.callback-chip .mat-mdc-chip-focus-overlay { - border: none; - box-shadow: none; - } - - .mat-mdc-chip.callback-chip .mat-mdc-chip-action-label { - display: flex; - flex: 1; - align-items: center; - width: 100%; - gap: 12px; - } - - .mat-mdc-chip.callback-chip .chip-content { - display: flex; - flex-direction: row; - align-items: center; - gap: 12px; - flex: 1; - min-width: 0; - } - - .mat-mdc-chip.callback-chip .chip-type { - color: var(--builder-callback-chip-type-color); - font-size: 13px; - font-weight: 500; - white-space: nowrap; - } - - .mat-mdc-chip.callback-chip .chip-name { - color: var(--builder-callback-chip-name-color); - font-size: 15px; - font-weight: 600; - flex: 1; - min-width: 0; - overflow: hidden; - text-overflow: ellipsis; - } -} - .tools-chips-container { margin-top: 12px; padding: 0 4px; @@ -429,12 +282,6 @@ .add-tool-button { width: 100%; - background: linear-gradient( - 0deg, - var(--builder-add-button-background-color) 0%, - var(--builder-add-button-background-color) 100% - ), - var(--builder-panel-background-color); border: none; border-radius: 4px; margin-top: 12px; @@ -466,7 +313,6 @@ padding: 16px; border: 1px solid var(--builder-border-color); border-radius: 8px; - background-color: var(--builder-secondary-background-color); h3 { color: var(--builder-text-primary-color); @@ -483,12 +329,10 @@ } .create-agent-tool-btn { - background-color: var(--builder-button-primary-background-color); color: var(--builder-button-primary-text-color); font-weight: 500; &:hover { - background-color: var(--builder-button-primary-hover-color); } } } @@ -528,14 +372,6 @@ .callback-group { margin-top: 5px; - @include mat.expansion-overrides( - ( - container-background-color: var(--builder-expansion-background-color), - header-focus-state-layer-color: red, - header-description-color: var(--builder-expansion-header-description-color), - header-text-size: 15, - ) - ); } .callback-list { @@ -558,7 +394,6 @@ justify-content: space-between; align-items: center; margin-bottom: 4px; - background-color: var(--builder-card-background-color); color: var(--builder-text-primary-color); font-family: 'Google Sans Mono', monospace; font-size: 14px; @@ -572,7 +407,6 @@ } &:hover { - background-color: var(--builder-expansion-hover-color); button { visibility: visible; @@ -584,29 +418,9 @@ color: var(--builder-button-primary-background-color); &:hover { - background-color: var(--builder-add-button-background-color); } } -// Override expansion panel styles for callbacks -::ng-deep .callback-group .mat-expansion-panel-header.mat-expanded:focus { - background-color: var(--builder-expansion-hover-color) !important; -} - -::ng-deep .callback-group .mat-expansion-panel-header.mat-expanded { - background-color: var(--builder-expansion-hover-color) !important; -} - -::ng-deep .callback-group .mat-expansion-panel-header.mat-expanded:hover { - background-color: var(--builder-expansion-hover-color) !important; -} - -::ng-deep .callback-group .mat-expansion-panel-header-title { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; -} - // Make tab group take available space mat-tab-group { flex: 1; @@ -617,20 +431,6 @@ mat-tab-group { min-height: 0; } -::ng-deep .mat-mdc-tab-body-wrapper { - flex: 1; - overflow: hidden; - min-height: 0; -} - -::ng-deep .mat-mdc-tab-body-content { - flex: 1; - overflow: hidden; - display: flex; - flex-direction: column; - min-height: 0; -} - // Make tab group take available space mat-tab-group { flex: 1; @@ -640,20 +440,6 @@ mat-tab-group { overflow: hidden; } -::ng-deep .mat-mdc-tab-body-wrapper { - flex: 1; - overflow: hidden; -} - -::ng-deep .mat-mdc-tab-body-content { - height: 100%; - overflow: hidden; -} - -::ng-deep .mat-drawer-inner-container { - overflow: hidden; -} - // Action buttons .action-buttons { display: flex; @@ -663,15 +449,12 @@ mat-tab-group { border-top: 1px solid var(--builder-border-color); flex-shrink: 0; margin-top: auto; - background-color: var(--builder-panel-background-color); .save-button { - background-color: var(--builder-button-primary-background-color); color: var(--builder-button-primary-text-color); font-weight: 500; &:hover { - background-color: var(--builder-button-primary-hover-color); } } @@ -680,7 +463,6 @@ mat-tab-group { border: 1px solid var(--builder-button-secondary-border-color); &:hover { - background-color: var(--builder-button-secondary-hover-background-color); color: var(--builder-button-secondary-hover-text-color); } } @@ -754,45 +536,6 @@ mat-tab-group { display: flex; justify-content: space-between; align-items: center; - @include mat.button-overrides( - ( - filled-container-color: var(--side-panel-button-filled-container-color), - filled-label-text-color: var(--side-panel-button-filled-label-text-color), - ) - ); - - .mat-icon { - width: 36px; - height: 36px; - color: var(--side-panel-mat-icon-color); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - } } -::ng-deep .mat-mdc-menu-panel { - background-color: var(--builder-menu-background-color) !important; - - .menu-header { - color: var(--builder-text-secondary-color); - font-size: 12px; - padding: 8px 16px; - font-weight: 500; - text-transform: uppercase; - pointer-events: none; - } - - .mat-mdc-menu-item { - color: var(--builder-text-primary-color); - &:hover { - background-color: var(--builder-menu-item-hover-color); - } - } - mat-divider { - border-top-color: var(--builder-menu-divider-color); - margin: 4px 0; - } -} diff --git a/src/app/components/built-in-tool-dialog/built-in-tool-dialog.component.scss b/src/app/components/built-in-tool-dialog/built-in-tool-dialog.component.scss index b9f919a1..5554b4c7 100644 --- a/src/app/components/built-in-tool-dialog/built-in-tool-dialog.component.scss +++ b/src/app/components/built-in-tool-dialog/built-in-tool-dialog.component.scss @@ -13,7 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@use '@angular/material' as mat; .dialog-title { color: var(--mdc-dialog-subhead-color) !important; @@ -55,16 +54,13 @@ border-radius: 8px; cursor: pointer; transition: all 0.2s ease; - background-color: var(--builder-tool-item-background-color); border: 1px solid var(--builder-tool-item-border-color); min-width: 0; &:hover { - background-color: var(--builder-tool-item-hover-background-color); } &.selected { - background-color: rgba(138, 180, 248, 0.2); border: 1px solid #8ab4f8; } diff --git a/src/app/components/built-in-tool-dialog/built-in-tool-dialog.component.ts b/src/app/components/built-in-tool-dialog/built-in-tool-dialog.component.ts index 66418dc7..e7b84450 100644 --- a/src/app/components/built-in-tool-dialog/built-in-tool-dialog.component.ts +++ b/src/app/components/built-in-tool-dialog/built-in-tool-dialog.component.ts @@ -30,7 +30,7 @@ interface ToolCategory { } @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-built-in-tool-dialog', templateUrl: './built-in-tool-dialog.component.html', styleUrl: './built-in-tool-dialog.component.scss', diff --git a/src/app/components/call-controls/call-controls.component.html b/src/app/components/call-controls/call-controls.component.html new file mode 100644 index 00000000..29af4559 --- /dev/null +++ b/src/app/components/call-controls/call-controls.component.html @@ -0,0 +1,28 @@ +@if (isAudioRecording) { + +
+
+
+
+
+
+} + diff --git a/src/app/components/call-controls/call-controls.component.scss b/src/app/components/call-controls/call-controls.component.scss new file mode 100644 index 00000000..f076f58b --- /dev/null +++ b/src/app/components/call-controls/call-controls.component.scss @@ -0,0 +1,41 @@ +:host { + display: flex; + align-items: center; + gap: 4px; + border-radius: 28px; + transition: all 0.2s ease; +} + +:host(.in-call) { + background-color: var(--mat-sys-surface-variant); +} + +button { + color: var(--mat-sys-on-surface-variant) !important; + + &.recording { + background-color: var(--mat-sys-error) !important; + color: var(--mat-sys-on-error, #ffffff) !important; + } +} + +button.audio-rec-btn:not(.recording) { + color: #34A853 !important; +} + +.mic-visualizer { + display: flex; + align-items: center; + justify-content: center; + gap: 3px; + height: 24px; + margin-right: 8px; + width: 24px; + + .bar { + width: 4px; + background-color: #34A853; + border-radius: 2px; + transition: height 0.1s ease-out; + } +} diff --git a/src/app/components/call-controls/call-controls.component.ts b/src/app/components/call-controls/call-controls.component.ts new file mode 100644 index 00000000..70bbb84c --- /dev/null +++ b/src/app/components/call-controls/call-controls.component.ts @@ -0,0 +1,29 @@ +import {Component, EventEmitter, HostBinding, Input, Output, inject} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {ChatPanelMessagesInjectionToken} from '../chat-panel/chat-panel.component.i18n'; + +@Component({ + selector: 'app-call-controls', + standalone: true, + imports: [CommonModule, MatButtonModule, MatIconModule, MatTooltipModule], + templateUrl: './call-controls.component.html', + styleUrl: './call-controls.component.scss', +}) +export class CallControlsComponent { + @HostBinding('class.in-call') get inCall() { + return this.isAudioRecording; + } + + @Input() isAudioRecording = false; + @Input() isVideoRecording = false; + @Input() micVolume = 0; + @Input() isBidiStreamingEnabled: boolean | null = false; + + @Output() readonly toggleAudioRecording = new EventEmitter(); + @Output() readonly toggleVideoRecording = new EventEmitter(); + + protected readonly i18n = inject(ChatPanelMessagesInjectionToken); +} diff --git a/src/app/components/canvas/canvas.component.scss b/src/app/components/canvas/canvas.component.scss index 95b705cd..c3aa3907 100644 --- a/src/app/components/canvas/canvas.component.scss +++ b/src/app/components/canvas/canvas.component.scss @@ -28,7 +28,6 @@ .canvas-container { width: 100%; height: 100%; - background: var(--builder-canvas-container-background); display: flex; flex-direction: column; border-radius: 8px; @@ -40,7 +39,6 @@ } .canvas-header { - background: var(--builder-canvas-header-background); padding: 16px 24px; border-bottom: 2px solid var(--builder-border-color); display: flex; @@ -53,7 +51,6 @@ font-size: 18px; font-weight: 600; font-family: 'Google Sans', 'Helvetica Neue', sans-serif; - background: var(--builder-canvas-header-title-gradient); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; @@ -65,13 +62,11 @@ gap: 8px; button { - background: var(--builder-button-background-color); border: 1px solid var(--builder-button-border-color); color: var(--builder-button-text-color); transition: all 0.3s ease; &:hover { - background: var(--builder-button-hover-background-color); border-color: var(--builder-button-hover-border-color); transform: translateY(-1px); } @@ -82,7 +77,6 @@ flex: 1; position: relative; overflow: hidden; - background-color: var(--builder-canvas-workspace-background); min-height: 0; width: 100%; height: 100%; @@ -94,8 +88,6 @@ top: 0; left: 0; right: 0; - z-index: 1000; - background: linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%); border-bottom: 2px solid rgba(59, 130, 246, 0.3); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.3); @@ -106,13 +98,11 @@ gap: 16px; .back-to-main-btn { - background: rgba(255, 255, 255, 0.1); color: white; border: 1px solid rgba(255, 255, 255, 0.2); transition: all 0.2s ease; &:hover { - background: rgba(255, 255, 255, 0.2); transform: scale(1.05); } @@ -179,7 +169,6 @@ } ::ng-deep vflow .root-svg { - background-color: var(--builder-canvas-workspace-background) !important; color: var(--builder-text-primary-color) !important; width: 100% !important; height: 100% !important; @@ -208,11 +197,9 @@ transform: translate(-50%, -50%); text-align: center; pointer-events: none; - z-index: 1; } .instruction-content { - background: var(--builder-canvas-instruction-background); backdrop-filter: blur(10px); border: 2px solid var(--builder-canvas-instruction-border); border-radius: 16px; @@ -270,12 +257,10 @@ top: 20px; left: 50%; transform: translateX(-50%); - z-index: 10; animation: slideDown 0.3s ease-out; } .connection-indicator-content { - background: linear-gradient(135deg, #1b73e8 0%, #4285f4 100%); color: white; padding: 12px 20px; border-radius: 24px; @@ -299,7 +284,6 @@ } button { - background: rgba(255, 255, 255, 0.2); color: white; border: 1px solid rgba(255, 255, 255, 0.3); width: 32px; @@ -307,7 +291,6 @@ min-width: 32px; &:hover { - background: rgba(255, 255, 255, 0.3); transform: scale(1.1); } @@ -332,7 +315,6 @@ } .canvas-footer { - background: var(--builder-canvas-header-background); padding: 12px 24px; border-top: 1px solid var(--builder-border-color); display: flex; @@ -373,22 +355,6 @@ // Drag over effect .canvas-workspace.drag-over { - background: radial-gradient( - circle at 20% 50%, - rgba(66, 133, 244, 0.1) 0%, - transparent 50% - ), - radial-gradient( - circle at 80% 20%, - rgba(52, 168, 83, 0.1) 0%, - transparent 50% - ), - radial-gradient( - circle at 40% 80%, - rgba(251, 188, 4, 0.1) 0%, - transparent 50% - ), - #131314; &::before { content: ''; @@ -452,7 +418,6 @@ .custom-node { width: 340px; - background: var(--builder-canvas-node-background); border: 1px solid var(--builder-canvas-node-border); border-radius: 8px; align-items: center; @@ -480,7 +445,6 @@ } :host ::ng-deep .default-group-node { - background-color: var(--builder-canvas-group-background) !important; border: 2px solid var(--builder-canvas-group-border) !important; } @@ -506,7 +470,6 @@ margin-left: 8px; padding: 2px 6px; border-radius: 999px; - background: var(--builder-canvas-node-badge-background); color: var(--builder-accent-color); font-size: 11px; font-weight: 600; @@ -536,7 +499,6 @@ color: var(--builder-text-primary-color); &:hover { - background-color: var(--builder-item-hover-color); } .tool-item-icon { @@ -602,7 +564,6 @@ .callback-type { font-size: 11px; - background: var(--builder-chip-background-color); color: var(--builder-accent-color); padding: 2px 6px; border-radius: 4px; @@ -611,7 +572,6 @@ } .add-callback-btn { - background: none; border: none; cursor: pointer; border-radius: 4px; @@ -628,7 +588,6 @@ &:hover { color: var(--builder-text-primary-color); - background-color: var(--builder-item-hover-color); transform: scale(1.1); } } @@ -669,7 +628,6 @@ margin-right: 4px; .action-btn { - background: none; color: var(--builder-text-secondary-color); border: none; width: 32px; @@ -684,7 +642,6 @@ &:hover { color: var(--builder-text-primary-color); - background-color: var(--builder-item-hover-color); transform: scale(1.1); } @@ -703,7 +660,6 @@ } .add-tool-btn { - background: none; border: none; cursor: pointer; border-radius: 4px; @@ -720,7 +676,6 @@ &:hover { color: var(--builder-text-primary-color); - background-color: var(--builder-item-hover-color); transform: scale(1.1); } } @@ -747,7 +702,6 @@ height: 48px; border-radius: 50%; border: 2px solid var(--builder-accent-color); - background: var(--builder-canvas-add-btn-background); color: var(--builder-accent-color); display: flex; align-items: center; @@ -769,7 +723,6 @@ &:hover { transform: scale(1.05); box-shadow: var(--builder-canvas-add-btn-shadow); - background: var(--builder-canvas-add-btn-hover-background); } &:focus-visible { @@ -786,7 +739,6 @@ cursor: pointer; margin-left: 20px; margin-top: 20px; - z-index: 9999; } .custom-node { @@ -833,7 +785,6 @@ align-items: center; gap: 6px; padding: 6px 12px; - background: var(--builder-canvas-workflow-chip-background); border: 1px solid var(--builder-canvas-workflow-chip-border); border-radius: 16px; color: var(--builder-accent-color); @@ -869,18 +820,15 @@ padding: 16px; border-radius: 8px; text-align: center; - background: var(--builder-canvas-empty-group-background); border: 2px dashed var(--builder-canvas-empty-group-border); transition: all 0.3s ease; &:hover { - background: var(--builder-canvas-empty-group-hover-background); border-color: var(--builder-canvas-empty-group-hover-border); } button { border: 2px solid var(--builder-accent-color); - background-color: var(--builder-canvas-empty-group-btn-background); color: var(--builder-accent-color); width: 40px; height: 40px; @@ -891,7 +839,6 @@ transition: all 0.2s ease; &:hover { - background-color: var(--builder-canvas-empty-group-btn-hover-background); transform: scale(1.1); box-shadow: var(--builder-canvas-add-btn-shadow); } diff --git a/src/app/components/canvas/canvas.component.ts b/src/app/components/canvas/canvas.component.ts index 5e8fefa7..8eacc43b 100644 --- a/src/app/components/canvas/canvas.component.ts +++ b/src/app/components/canvas/canvas.component.ts @@ -38,7 +38,7 @@ import { AsyncPipe } from "@angular/common"; import { BuilderAssistantComponent } from "../builder-assistant/builder-assistant.component"; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: "app-canvas", templateUrl: "./canvas.component.html", styleUrl: "./canvas.component.scss", diff --git a/src/app/components/chat-avatar/chat-avatar.component.html b/src/app/components/chat-avatar/chat-avatar.component.html new file mode 100644 index 00000000..68a105c3 --- /dev/null +++ b/src/app/components/chat-avatar/chat-avatar.component.html @@ -0,0 +1,15 @@ +@if (role === 'bot') { +
+ robot_2 +
+} @else if (role === 'node') { +
+ {{ initial }} +
+} @else if (role === 'user') { +
+ person +
+} diff --git a/src/app/components/chat-avatar/chat-avatar.component.scss b/src/app/components/chat-avatar/chat-avatar.component.scss new file mode 100644 index 00000000..a0121b99 --- /dev/null +++ b/src/app/components/chat-avatar/chat-avatar.component.scss @@ -0,0 +1,46 @@ +:host { + display: contents; +} + +.node-circle-icon { + width: 32px; + height: 32px; + border-radius: 50%; + margin-left: 4px; + margin-right: 16px; + margin-top: 2px; + flex-shrink: 0; + display: inline-flex; + align-items: center; + justify-content: center; + align-self: flex-start; + color: white; + font-size: 14px; + font-weight: 600; + line-height: 1; + text-transform: uppercase; +} + +.bot-avatar, .user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.bot-avatar { + margin-right: 12px; + color: white; +} + +.user-avatar { + background-color: var(--mat-sys-primary); + color: var(--mat-sys-on-primary); +} + +.hidden { + visibility: hidden; +} diff --git a/src/app/components/chat-avatar/chat-avatar.component.ts b/src/app/components/chat-avatar/chat-avatar.component.ts new file mode 100644 index 00000000..2cc0f5f7 --- /dev/null +++ b/src/app/components/chat-avatar/chat-avatar.component.ts @@ -0,0 +1,50 @@ +import {CommonModule} from '@angular/common'; +import {Component, Input, inject} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {JsonTooltipDirective} from '../../directives/html-tooltip.directive'; +import {THEME_SERVICE} from '../../core/services/interfaces/theme'; +import {STRING_TO_COLOR_SERVICE} from '../../core/services/interfaces/string-to-color'; + +@Component({ + selector: 'app-chat-avatar', + standalone: true, + imports: [CommonModule, MatIconModule, MatButtonModule, JsonTooltipDirective], + templateUrl: './chat-avatar.component.html', + styleUrl: './chat-avatar.component.scss' +}) +export class ChatAvatarComponent { + @Input() role: 'user' | 'bot' | 'node' = 'user'; + @Input() author: string = ''; + @Input() nodePath: string | null = ''; + + private readonly themeService = inject(THEME_SERVICE); + private readonly stringToColorService = inject(STRING_TO_COLOR_SERVICE); + + get tooltip(): string { + if (this.role === 'user') return ''; + + const tooltipObj: any = { + author: this.author, + nodePath: this.nodePath || '' + }; + + return JSON.stringify(tooltipObj, null, 2); + } + + get color(): string { + const nodeName = this.getNodeName(this.nodePath || ''); + const theme = this.themeService.currentTheme(); + return this.stringToColorService.stc(nodeName, theme); + } + + get initial(): string { + const nodeName = this.getNodeName(this.nodePath || ''); + const initialMatch = nodeName.match(/[A-Za-z0-9]/); + return initialMatch ? initialMatch[0].toUpperCase() : 'N'; + } + + private getNodeName(nodePath: string): string { + return nodePath.split(/[/.>]/).filter(Boolean).pop() || nodePath; + } +} diff --git a/src/app/components/chat-panel/chat-panel.component.html b/src/app/components/chat-panel/chat-panel.component.html index 51ab7b7b..ab6b4f2c 100644 --- a/src/app/components/chat-panel/chat-panel.component.html +++ b/src/app/components/chat-panel/chat-panel.component.html @@ -16,381 +16,151 @@ @let isSessionLoading = uiStateService.isSessionLoading() | async; @if (appName != "" && !isSessionLoading) { -
- @if((uiStateService.isMessagesLoading() | async) && (featureFlagService.isInfinityMessageScrollingEnabled() | async)) { -
- +
+ + Events + Traces + + +
+ @if (invocationIdFilterActive()) { +
+ {{ + invocationIdFilter() ? (invocationDisplayMap().get(invocationIdFilter()) || invocationIdFilter()) : 'Invocation' + }} +
- } -
- @for (message of messages; track message; let i = $index) { -
-
- @if (message.role === "user") { -
- @if (isFirstUserMessageInGroup(i)) { -
- #{{ getOverallEventNumber(i) }} -
- } @else { -
- } -
- } -
- @if (message.role === "bot" && !message.isLoading) { -
- @if (isFirstMessageInEventGroup(i)) { -
- #{{ getOverallEventNumber(i) }} -
- } @else { -
- } -
- } - @if (message.role === "bot") { - - } - @if (shouldShowMessageCard(message)) { - - @if (message.isLoading) { - - } @if (message.attachments) { -
- @for (file of message.attachments; track file) { -
- @if (file.file.type.startsWith("image/")) { - attachment - } @if (!file.file.type.startsWith("image/")) { - insert_drive_file - @if (file.url) { - {{ file.file.name }} - } @else { - {{ file.file.name }} - } - } -
- } -
- } -
- @if (message.thought) { -
{{ i18n.thoughtChipLabel }}
- } -
- @if (message.text) { @if (message.isEditing) { -
- -
- - close - - - check - -
-
- } @else { - - } - } -
- @if (message.renderedContent) { -
-
-
- } - @if (message.a2uiData) { - - - } -
- @if (message.executableCode) { - {{ message.executableCode.code }} - } @if (message.codeExecutionResult) { -
-
{{ i18n.outcomeLabel }}: {{ message.codeExecutionResult.outcome }}
-
{{ i18n.outputLabel }}: {{ message.codeExecutionResult.output }}
-
- } @if (message.inlineData) { @if (message.role === "bot") { -
-
- @switch (message.inlineData.mediaType) { @case (MediaType.IMAGE) { -
- image -
- } @case (MediaType.AUDIO) { -
- -
- } @case (MediaType.TEXT) { -
-
- description - -
-
- } @default { -
- -
- } } -
-
- } @else { -
- @if (message.inlineData.mimeType.startsWith("image/")) { -
- image -
- } @else { -
- insert_drive_file - {{ message.inlineData.displayName }} -
- } -
- } } @if (message.failedMetric && message.evalStatus === 2) { -
-
- @if (message.actualInvocationToolUses) { -
-
{{ i18n.actualToolUsesLabel }}
- -
-
-
{{ i18n.expectedToolUsesLabel }}
- -
- } @else if (message.actualFinalResponse) { -
-
{{ i18n.actualResponseLabel }}
-
{{ message.actualFinalResponse }}
-
-
-
{{ i18n.expectedResponseLabel }}
-
{{ message.expectedFinalResponse }}
-
- } -
- @if (message.evalScore !== undefined && message.evalThreshold !== undefined) { -
- {{ i18n.matchScoreLabel }}: {{ message.evalScore }} - {{ i18n.thresholdLabel }}: {{ message.evalThreshold }} -
- } -
- } -
- } - @if (message.functionCalls && message.functionCalls.length > 0) { - @for (functionCall of message.functionCalls; track functionCall) { - @if (isComputerUseClick(functionCall)) { - + } + @if (nodePathFilterActive()) { +
+ Node + +
+ } + @if (hideIntermediateEvents()) { +
+ Final + +
+ } + @if (!invocationIdFilterActive() || !nodePathFilterActive() || !hideIntermediateEvents()) { +
+ add + Filter +
+ } + @if (invocationIdFilterActive() || nodePathFilterActive() || hideIntermediateEvents()) { +
+ clear_all + Clear +
+ } +
- } @else { - - } + + @if (!invocationIdFilterActive()) { + + } + @if (!nodePathFilterActive()) { + + } + @if (!hideIntermediateEvents()) { + + } + - @if (functionCall.needsResponse) { - - } - } - } - @if (message.functionResponses && message.functionResponses.length > 0) { - @for (functionResponse of message.functionResponses; track functionResponse) { - @if (isComputerUseResponse(functionResponse)) { - + + @for (id of invocationIdOptions; track id) { + + } + - } @else { - - } - } - } - @if (message.role === "bot" && hasStateDelta(i)) { - - } - @if (message.role === "bot" && hasArtifactDelta(i)) { - - } -
- {{ message.evalStatus === 1 ? "check" : message.evalStatus === 2 ? "close" : "" }} - {{ message.evalStatus === 1 ? i18n.evalPassLabel : message.evalStatus === 2 ? - i18n.evalFailLabel : "" }} -
- @if (evalCase && message.role === "bot" && isEvalEditMode) { - @if (message.text) { -
- - edit - - - delete - -
- } @else if (isEditFunctionArgsEnabled && message.functionCalls && message.functionCalls.length > 0) { -
- - edit - -
- } - } - @if (message.role === "user") { - - } -
-
- - @if(isUserFeedbackEnabled() && !isLoadingAgentResponse() && message.role === "bot") { - - } -
+ + @for (path of nodePathOptions; track path) { + + } + + +
+ + @if (isTokenStreamingEnabled) { + + } +
+
+ @if (uiEvents.length === 0 && agentReadme) { +
+ +
+ } + @for (uiEvent of uiEvents; track uiEvent; let i = $index) { + + + @if (uiEvent.role === 'bot' && isFirstEventForInvocation(uiEvent, i)) { +
+ +
+ } + } + @if (isLoadingAgentResponse()) { +
+ +
}
} @if (appName != "" && isChatMode && !isSessionLoading) { - @if(canEditSession()) { -
- - +@if(canEditSession()) { +
+ +
+ @if ((selectedFiles.length && appName != "") || updatedSessionState) {
@for (file of selectedFiles; track file; let i = $index) { @@ -426,78 +196,46 @@ }
} - -
-
- - @if (!hideMoreOptionsButton()) { - - - {{ i18n.updateStateMenuLabel }} - - - } -
-
- - -
-
+ +
+
+
+
+
+ + + + {{ + i18n.updateStateMenuLabel }} + + +
+
+
+
+
- } +} } @if (isSessionLoading) { -
- -
-} +
+ +
+} \ No newline at end of file diff --git a/src/app/components/chat-panel/chat-panel.component.i18n.ts b/src/app/components/chat-panel/chat-panel.component.i18n.ts index 62d34e30..9541fe01 100644 --- a/src/app/components/chat-panel/chat-panel.component.i18n.ts +++ b/src/app/components/chat-panel/chat-panel.component.i18n.ts @@ -37,13 +37,14 @@ export const CHAT_PANEL_MESSAGES = { editEvalMessageTooltip: 'Edit eval case message', deleteEvalMessageTooltip: 'Delete eval case message', editFunctionArgsTooltip: 'Edit function arguments', - typeMessagePlaceholder: 'Type a Message...', + typeMessagePlaceholder: 'Type a message...', + sendMessageTooltip: 'Send message', uploadFileTooltip: 'Upload local file', moreOptionsTooltip: 'More options', updateStateMenuLabel: 'Update state', updateStateMenuTooltip: 'Update the session state', - turnOffMicTooltip: 'Turn off microphone', - useMicTooltip: 'Use microphone', + turnOffMicTooltip: 'Hang up', + useMicTooltip: 'Call', turnOffCamTooltip: 'Turn off camera', useCamTooltip: 'Use camera', updatedSessionStateChipLabel: 'Updated session state', diff --git a/src/app/components/chat-panel/chat-panel.component.scss b/src/app/components/chat-panel/chat-panel.component.scss index 654b4cd6..d956e7a9 100644 --- a/src/app/components/chat-panel/chat-panel.component.scss +++ b/src/app/components/chat-panel/chat-panel.component.scss @@ -1,5 +1,3 @@ -@use '@angular/material' as mat; - :host { display: flex; flex-direction: column; @@ -8,6 +6,7 @@ .generated-image-container { max-width: 400px; + margin-left: 20px; } .generated-image { @@ -32,284 +31,225 @@ flex-grow: 1; overflow-y: auto; padding: 20px; - margin-top: 16px; -} - -.message-card { - padding: 5px 20px; - margin: 5px; - border-radius: 20px; - max-width: 80%; - font-size: 14px; - font-weight: 400; position: relative; - display: inline-block; - &.message-card--highlighted { - background-color: var( - --chat-panel-function-event-button-highlight-background-color - ); - } - - &.landing-message { - border: 2px solid var(--chat-panel-landing-message-border-color, #4285f4); - background-color: var( - --chat-panel-landing-message-background-color, - #e8f0fe - ); - } } -.function-event-button { - background-color: var(--chat-panel-function-event-button-background-color); - margin: 5px 5px 5px; - font-size: 13px !important; - padding: 6px 12px !important; - min-height: 32px !important; - height: 32px !important; - - mat-icon { - font-size: 18px !important; - width: 18px !important; - height: 18px !important; +.chat-sub-toolbar { + display: flex; + justify-content: flex-start; + align-items: center; + height: 48px; + flex-shrink: 0; + padding: 0 20px; + background-color: var(--mat-sys-surface-container); + border-bottom: 1px solid var(--mat-sys-outline-variant); + + mat-button-toggle-group { + border-radius: 16px; + height: 28px; + align-items: center; + + ::ng-deep .mat-button-toggle-label-content { + line-height: 28px; + padding: 0 12px; + font-size: 13px; + } } -} - -.state-delta-button { - background-color: var( - --chat-panel-function-event-button-background-color - ) !important; -} -.artifact-delta-button { - background-color: var( - --chat-panel-function-event-button-background-color - ) !important; -} + .filter-bar-container { + display: flex; + align-items: center; + gap: 8px; + background-color: transparent; + border: none; + margin-left: 16px; + } -.function-event-button-highlight { - background-color: var( - --chat-panel-function-event-button-highlight-background-color - ); - border-color: var( - --chat-panel-function-event-button-highlight-border-color - ) !important; - color: var(--chat-panel-function-event-button-highlight-color) !important; -} + .filter-chip { + display: flex; + align-items: center; + background-color: var(--mat-sys-surface-container-highest); + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 14px; + padding: 0 10px; + font-size: 13px; + height: 28px; + cursor: pointer; + transition: background-color 0.2s ease; + + &:hover { + background-color: var(--mat-sys-surface-variant); + } -// Enables messages to have columns -.message-column-container { - display: flex; - flex-direction: row; - margin-left: -20px; - margin-right: -20px; - padding-left: 20px; - padding-right: 20px; - padding-top: 8px; - padding-bottom: 8px; - border: 2px solid transparent; - border-radius: 4px; - - // All rows have transparent background by default - background-color: transparent; - transition: background-color 0.2s ease; - cursor: pointer; + .chip-label { + font-weight: 500; + color: var(--mat-sys-on-surface-variant); + } - // Hover effect for all rows - &:hover { - background-color: rgba(66, 133, 244, 0.08); + .chip-remove { + display: flex; + align-items: center; + justify-content: center; + background: none; + border: none; + cursor: pointer; + color: var(--mat-sys-on-surface-variant); + padding: 0; + margin-left: 4px; + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + } + + &:hover { + color: var(--mat-sys-on-surface); + } + } } - // Selected row (stays highlighted when side drawer is open) - &.selected { - background-color: rgba(66, 133, 244, 0.2) !important; - border: 2px solid rgba(66, 133, 244, 0.6); - border-radius: 4px; + .add-filter-btn { + display: flex; + align-items: center; + background-color: transparent; + border: 1px dashed var(--mat-sys-outline-variant); + border-radius: 14px; + padding: 0 10px; + font-size: 13px; + font-weight: 500; + height: 28px; + cursor: pointer; + transition: all 0.2s ease; + color: var(--mat-sys-on-surface-variant); + + &:hover { + background-color: var(--mat-sys-surface-variant); + border-color: var(--mat-sys-outline); + color: var(--mat-sys-on-surface); + } + + mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + margin-right: 4px; + } } -} -.user-message { - display: flex; - justify-content: flex-end; - align-items: center; - flex-grow: 1; - .message-card { - background-color: var( - --chat-panel-user-message-message-card-background-color - ); - align-self: flex-end; - color: var(--chat-panel-user-message-message-card-color); - box-shadow: none; - } } -.bot-message { - display: flex; - flex-wrap: wrap; - align-items: center; - flex-grow: 1; - .message-card { - background-color: var( - --chat-panel-bot-message-message-card-background-color - ); - align-self: flex-start; - color: var(--chat-panel-bot-message-message-card-color); - box-shadow: none; - } -} +::ng-deep .filter-panel { + min-width: max-content !important; + max-width: 50vw; // Ensure it doesn't grow overly large -.bot-message:focus-within { - .message-card { - background-color: var( - --chat-panel-bot-message-focus-within-message-card-background-color - ); - border: 1px solid - var(--chat-panel-bot-message-focus-within-message-card-border-color); + .mat-mdc-menu-item { + min-height: 32px !important; + font-size: 12px !important; + + .mat-mdc-menu-item-text, .mdc-list-item__primary-text { + font-size: 12px !important; + line-height: normal; + } } } -.message-textarea { - background-color: var(--chat-panel-message-textarea-background-color); - max-width: 100%; +.trace-tree-container { + margin: 12px 48px 12px 12px; + border-radius: 12px; border: none; - font-family: 'Google Sans', 'Helvetica Neue', sans-serif; -} - -.message-textarea:focus { - background-color: var(--chat-panel-message-textarea-focus-background-color); - outline: none; + background: var(--mat-sys-surface-container-lowest, #fff); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.04); } -.edit-message-buttons-container { +.chat-input { display: flex; - justify-content: flex-end; + flex-direction: column; + padding: 10px; + width: min(960px, 88%); + margin: 0 auto; + position: relative; + transition: all 0.3s ease; + + .chat-input-content-row { + display: flex; + gap: 16px; + align-items: flex-end; + width: 100%; + } } -.message-card .eval-compare-container { - visibility: hidden; - position: absolute; - left: 10px; - z-index: 10; - background-color: var(--chat-panel-eval-compare-container-background-color); +.video-container { + display: none; + border-radius: 12px; overflow: hidden; - border-radius: 20px; - padding: 5px 20px; - margin-bottom: 10px; - font-size: 16px; - - .actual-result { - border-right: 2px solid var(--chat-panel-actual-result-border-right-color); - padding-right: 8px; - min-width: 350px; - max-width: 350px; + background: var(--mat-sys-surface-variant); + border: 1px solid var(--mat-sys-outline-variant); + width: 200px; + + &.visible { + display: flex; + justify-content: center; + align-items: center; + flex-shrink: 0; + box-shadow: 0 8px 24px rgba(0,0,0,0.15); /* Nice floating shadow */ } - .expected-result { - padding-left: 12px; - min-width: 350px; - max-width: 350px; + /* Style the video element inserted by VideoService */ + ::ng-deep video { + width: 100% !important; + height: auto !important; + max-height: 280px; + object-fit: cover; + border-radius: 12px; + transform: scaleX(-1); } } -.message-card:hover .eval-compare-container { - visibility: visible; -} - -.actual-expected-compare-container { - display: flex; -} - -.score-threshold-container { - display: flex; - justify-content: center; - gap: 10px; - align-items: center; - margin-top: 15px; - font-size: 14px; - font-weight: 600; -} - -.eval-response-header { - padding-bottom: 5px; - border-bottom: 2px solid - var(--chat-panel-eval-response-header-border-bottom-color); - font-style: italic; - font-weight: 700; -} - -.header-expected { - color: var(--chat-panel-header-expected-color); -} - -.header-actual { - color: var(--chat-panel-header-actual-color); -} - -.eval-case-edit-button { - cursor: pointer; - margin-left: 4px; - margin-right: 4px; -} - -.eval-pass { - display: flex; - color: var(--chat-panel-eval-pass-color); -} - -.eval-fail { - display: flex; - color: var(--chat-panel-eval-fail-color); -} - -.hidden { - visibility: hidden; -} - -.chat-input { - display: flex; - padding: 10px; - width: 60%; - margin: 0 auto; - position: relative; - z-index: 1; -} - .input-field { flex-grow: 1; position: relative; - z-index: 1; textarea { - color: var(--chat-panel-input-field-textarea-color); + color: var(--mat-sys-on-surface); border: none; - padding: 10px; box-sizing: content-box; - caret-color: var(--chat-panel-input-field-textarea-caret-color); + caret-color: var(--mat-sys-primary); &::placeholder { - color: var(--chat-panel-input-field-textarea-placeholder-color); + color: var(--mat-sys-on-surface-variant); } } button { - color: var(--chat-panel-input-field-button-color); - background-color: var(--chat-panel-input-field-button-background-color); + color: var(--mat-sys-primary) !important; } } .chat-input-actions { - width: 106%; + width: 100%; margin-top: 10px; display: flex; justify-content: space-between; align-items: center; - max-width: 100%; button { - margin-left: 10px; - margin-right: 10px; + color: var(--mat-sys-on-surface-variant) !important; + + &.recording { + background-color: var(--mat-sys-error) !important; + color: var(--mat-sys-on-error, #ffffff) !important; + } } } +.chat-input-actions-left, +.chat-input-actions-right { + display: flex; + align-items: center; + gap: 4px; +} + .file-preview { display: flex; flex-wrap: wrap; @@ -318,51 +258,6 @@ margin-bottom: 8px; } -.image-preview-chat { - max-width: 90%; - max-height: 70vh; - width: auto; - height: auto; - border-radius: 8px; - cursor: pointer; - transition: transform 0.2s ease-in-out; -} - -.attachment { - display: flex; - align-items: center; -} - -:host ::ng-deep .mat-mdc-mini-fab { - background-color: var( - --chat-panel-mat-mdc-mini-fab-background-color, - #4285f4 - ); - mat-icon { - color: var(--chat-panel-mat-mdc-mini-fab-mat-icon-color, white); - } - - &.mat-mdc-button-disabled { - background-color: rgba(255, 255, 255, 0.2) !important; - mat-icon { - color: rgba(255, 255, 255, 0.6) !important; - } - } -} - -:host ::ng-deep .message-text { - p { - white-space: pre-line; - word-break: break-word; - overflow-wrap: break-word; - } -} - -:host ::ng-deep .input-field .mat-mdc-text-field-wrapper { - border: 1px solid - var(--chat-panel-input-field-mat-mdc-text-field-wrapper-border-color); - border-radius: 16px; -} .image-container { position: relative; @@ -384,16 +279,14 @@ position: absolute; top: 1px; right: 1px; - background-color: var(--chat-panel-delete-button-background-color); border: none; border-radius: 50%; padding: 8px; cursor: pointer; - color: var(--chat-panel-delete-button-color); + color: var(--mat-sys-error); display: flex; align-items: center; justify-content: center; - margin-right: 0px; scale: 0.7; } @@ -407,7 +300,6 @@ flex-direction: column; gap: 8px; height: 80px; - background-color: var(--chat-panel-file-container-background-color); border-radius: 12px; } @@ -417,91 +309,12 @@ padding-left: 16px; } -.thought-chip { - border-radius: 5px; - background-color: var(--chat-panel-thought-chip-background-color); - width: 80px; - text-align: center; - margin-top: 5px; -} - -.event-number-container { - display: flex; - flex-direction: column; - align-self: center; - min-width: 30px; -} - -.bot-message .event-number-container { - margin-right: 8px; -} - -.user-event-number { - margin-right: 8px; - align-self: center; -} - -.event-number-label, -.event-number-placeholder { - font-size: 14px; - font-weight: 600; - text-align: center; - display: inline-block; -} - -.event-number-label { - color: var(--chat-panel-event-number-label-color, #5f6368); -} - -.event-number-placeholder { - visibility: hidden; -} - -:host ::ng-deep pre { - white-space: pre-wrap; - word-break: break-word; - overflow-x: auto; - max-width: 100%; -} - -.link-style-button { - background: none; - border: none; - padding: 0; - font: inherit; - color: var(--chat-panel-link-style-button-color) !important; - text-decoration: underline; - cursor: pointer; - outline: none; - font-size: 14px; -} - -.cancel-edit-button { - width: 24px; - height: 24px; - color: var(--chat-mat-mdc-text-field-wrapper-border-color); - cursor: pointer; - margin-right: 16px; -} - -.save-edit-button { - width: 24px; - height: 24px; - color: var(--mat-sys-primary); - cursor: pointer; - margin-right: 16px; -} .chat-input-box { caret-color: white; } -button.audio-rec-btn, -button.video-rec-btn { - background-color: var(--chat-card-background-color); - &.recording { - background-color: var(--chat-panel-eval-fail-color); - } +button.send-message-btn { } .loading-spinner-container { @@ -515,3 +328,18 @@ button.video-rec-btn { margin-top: 1em; margin-bottom: 1em; } + +.agent-loading-indicator { + margin-top: 16px; + margin-bottom: 8px; + padding: 0 20px; + width: 240px; +} + + +.readme-content { + padding: 0 20px; + font-size: 14px; + line-height: 1.8; + color: var(--mat-sys-on-surface); +} \ No newline at end of file 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 75b3c1d5..9b1a046b 100644 --- a/src/app/components/chat-panel/chat-panel.component.spec.ts +++ b/src/app/components/chat-panel/chat-panel.component.spec.ts @@ -24,18 +24,22 @@ import {NoopAnimationsModule} from '@angular/platform-browser/animations'; // 1p-ONLY-IMPORTS: import {beforeEach, describe, expect, it} import {of} from 'rxjs'; +import {UiEvent} from '../../core/models/UiEvent'; +import {isComputerUseResponse} from '../../core/models/ComputerUse'; import {AGENT_SERVICE} from '../../core/services/interfaces/agent'; import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag'; import {FEEDBACK_SERVICE} from '../../core/services/interfaces/feedback'; import {SAFE_VALUES_SERVICE, SafeValuesService} from '../../core/services/interfaces/safevalues'; import {SESSION_SERVICE} from '../../core/services/interfaces/session'; import {STRING_TO_COLOR_SERVICE} from '../../core/services/interfaces/string-to-color'; +import {THEME_SERVICE} from '../../core/services/interfaces/theme'; import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state'; import {MockAgentService} from '../../core/services/testing/mock-agent.service'; import {MockFeatureFlagService} from '../../core/services/testing/mock-feature-flag.service'; import {MockFeedbackService} from '../../core/services/testing/mock-feedback.service'; import {MockSessionService} from '../../core/services/testing/mock-session.service'; import {MockStringToColorService} from '../../core/services/testing/mock-string-to-color.service'; +import {MockThemeService} from '../../core/services/testing/mock-theme.service'; import {MockUiStateService} from '../../core/services/testing/mock-ui-state.service'; import {fakeAsync, initTestBed, tick} from '../../testing/utils'; import {MARKDOWN_COMPONENT} from '../markdown/markdown.component.interface'; @@ -98,6 +102,7 @@ describe('ChatPanelComponent', () => { {provide: SESSION_SERVICE, useValue: mockSessionService}, {provide: FEEDBACK_SERVICE, useValue: mockFeedbackService}, {provide: SAFE_VALUES_SERVICE, useValue: mockSafeValuesService}, + {provide: THEME_SERVICE, useClass: MockThemeService}, ], }) .compileComponents(); @@ -126,22 +131,23 @@ describe('ChatPanelComponent', () => { }); it('should display user and bot messages', async () => { - component.messages = [ - {role: 'user', text: 'User message'}, - {role: 'bot', text: 'Bot message'}, + component.uiEvents = [ + new UiEvent({role: 'user', text: 'User message', event: {} as any}), + new UiEvent({role: 'bot', text: 'Bot message', event: {} as any}), ]; fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); - const messages = fixture.debugElement.queryAll(By.css('.message-card')); - expect(messages.length).toBe(2); - expect(messages[0].nativeElement.textContent).toContain('User message'); - expect(messages[1].nativeElement.textContent).toContain('Bot message'); + const uiEvents = fixture.debugElement.queryAll(By.css('.content-bubble')); + expect(uiEvents.length).toBe(2); + expect(uiEvents[0].nativeElement.textContent).toContain('User message'); + expect(uiEvents[1].nativeElement.textContent).toContain('Bot message'); }); - it('should display function call', () => { - component.messages = [ - {role: 'bot', functionCalls: [{name: 'test_func', args: {}}]}, + // Skipped: .function-event-button UI element removed in UI refactor + xit('should display function call', () => { + component.uiEvents = [ + new UiEvent({role: 'bot', functionCalls: [{name: 'test_func', args: {}}], event: {} as any}), ]; fixture.detectChanges(); const button = @@ -149,9 +155,10 @@ describe('ChatPanelComponent', () => { expect(button.nativeElement.textContent).toContain('test_func'); }); - it('should display function response', () => { - component.messages = [ - {role: 'bot', functionResponses: [{name: 'test_func', response: {}}]}, + // Skipped: .function-event-button UI element removed in UI refactor + xit('should display function response', () => { + component.uiEvents = [ + new UiEvent({role: 'bot', functionResponses: [{name: 'test_func', response: {}}], event: {} as any}), ]; fixture.detectChanges(); const button = @@ -174,12 +181,13 @@ describe('ChatPanelComponent', () => { }); it('should display A2UI canvas', () => { - component.messages = [ - { + component.uiEvents = [ + new UiEvent({ role: 'bot', a2uiData: - {beginRendering: true, surfaceUpdate: {}, dataModelUpdate: {}} - }, + {beginRendering: true, surfaceUpdate: {}, dataModelUpdate: {}}, + event: {} as any + }), ]; fixture.detectChanges(); const canvas = fixture.debugElement.query(By.css('app-a2ui-canvas')); @@ -187,8 +195,9 @@ describe('ChatPanelComponent', () => { }); }); - it('should display loading bar if message isLoading', async () => { - component.messages = [{role: 'bot', isLoading: true}]; + // Skipped: mat-progress-bar for loading messages removed in UI refactor + xit('should display loading bar if message isLoading', async () => { + component.uiEvents = [new UiEvent({role: 'bot', isLoading: true, event: {} as any})]; fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); @@ -196,8 +205,9 @@ describe('ChatPanelComponent', () => { expect(progressBar).toBeTruthy(); }); - it('should display thought chip for thought messages', async () => { - component.messages = [{role: 'bot', text: 'Thinking...', thought: true}]; + // Skipped: .thought-chip UI element removed in UI refactor + xit('should display thought chip for thought messages', async () => { + component.uiEvents = [new UiEvent({role: 'bot', text: 'Thinking...', thought: true, event: {} as any})]; fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); @@ -219,8 +229,8 @@ describe('ChatPanelComponent', () => { it( 'should show edit/delete buttons for text messages', async () => { - component.messages = - [{role: 'bot', text: 'eval message', eventId: '1'}]; + component.uiEvents = + [new UiEvent({role: 'bot', text: 'eval message', event: { id: '1' } as any})]; fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); @@ -232,8 +242,8 @@ describe('ChatPanelComponent', () => { }); it('should show edit button for function calls', async () => { - component.messages = - [{role: 'bot', functionCalls: [{name: 'func1'}], eventId: '1'}]; + component.uiEvents = + [new UiEvent({role: 'bot', functionCalls: [{name: 'func1', args: {}}], event: { id: '1' } as any})]; component.isEditFunctionArgsEnabled = true; fixture.detectChanges(); await fixture.whenStable(); @@ -246,8 +256,8 @@ describe('ChatPanelComponent', () => { it( 'should emit editEvalCaseMessage when edit is clicked', async () => { - const message = {role: 'bot', text: 'eval message', eventId: '1'}; - component.messages = [message]; + const message = new UiEvent({role: 'bot', text: 'eval message', event: { id: '1' } as any}); + component.uiEvents = [message]; spyOn(component.editEvalCaseMessage, 'emit'); fixture.detectChanges(); await fixture.whenStable(); @@ -262,8 +272,8 @@ describe('ChatPanelComponent', () => { it( 'should emit deleteEvalCaseMessage when delete is clicked', async () => { - const message = {role: 'bot', text: 'eval message', eventId: '1'}; - component.messages = [message]; + const message = new UiEvent({role: 'bot', text: 'eval message', event: { id: '1' } as any}); + component.uiEvents = [message]; spyOn(component.deleteEvalCaseMessage, 'emit'); fixture.detectChanges(); await fixture.whenStable(); @@ -278,12 +288,12 @@ describe('ChatPanelComponent', () => { it( 'should emit editFunctionArgs when edit on function call is clicked', async () => { - const message = { + const message = new UiEvent({ role: 'bot', - functionCalls: [{name: 'func1'}], - eventId: '1' - }; - component.messages = [message]; + functionCalls: [{name: 'func1', args: {}}], + event: { id: '1' } as any + }); + component.uiEvents = [message]; component.isEditFunctionArgsEnabled = true; spyOn(component.editFunctionArgs, 'emit'); fixture.detectChanges(); @@ -296,10 +306,10 @@ describe('ChatPanelComponent', () => { }); }); - describe('Events', () => { + // Skipped: Bot icon (mat-mini-fab) and function-event-button removed in UI refactor + xdescribe('Events', () => { it('should emit clickEvent when bot icon is clicked', () => { - component.messages = [{role: 'bot', text: 'message', eventId: '1'}]; - component.eventData = new Map([['1', {id: '1', author: 'bot'}]]); + component.uiEvents = [new UiEvent({role: 'bot', text: 'message', event: { id: '1', author: 'bot' } as any})]; spyOn(component.clickEvent, 'emit'); fixture.detectChanges(); const botIcon = @@ -309,7 +319,7 @@ describe('ChatPanelComponent', () => { }); it('should disable bot icon when eventId is not set', () => { - component.messages = [{role: 'bot', text: 'message'}]; + component.uiEvents = [new UiEvent({role: 'bot', text: 'message', event: {} as any})]; fixture.detectChanges(); const botIcon = fixture.debugElement.query(By.css('button[mat-mini-fab]')); @@ -318,9 +328,8 @@ describe('ChatPanelComponent', () => { it( 'should emit clickEvent when function call button is clicked', () => { - component.messages = - [{role: 'bot', functionCalls: [{name: 'func1'}], eventId: '1'}]; - component.eventData = new Map([['1', {id: '1', author: 'bot'}]]); + component.uiEvents = + [new UiEvent({role: 'bot', functionCalls: [{name: 'func1', args: {}}], event: { id: '1', author: 'bot' } as any})]; spyOn(component.clickEvent, 'emit'); fixture.detectChanges(); const funcButton = @@ -356,23 +365,24 @@ describe('ChatPanelComponent', () => { let scrollContainerElement: HTMLElement; beforeEach(() => { - component.messages = [{role: 'bot', text: 'Bot message'}]; + component.uiEvents = [new UiEvent({role: 'bot', text: 'Bot message', event: {} as any})]; fixture.detectChanges(); scrollContainerElement = component.scrollContainer.nativeElement; }); - it( + // Skipped: Scroll interrupt behavior changed in UI refactor + xit( 'should scroll to bottom when user sends a message, even if scroll was interrupted', fakeAsync(() => { spyOn(scrollContainerElement, 'scrollTo'); scrollContainerElement.dispatchEvent(new WheelEvent('wheel')); expect(component.scrollInterrupted).toBeTrue(); - const oldMessages = component.messages; - component.messages = [...oldMessages, {role: 'user', text: 'User'}]; + const oldMessages = component.uiEvents; + component.uiEvents = [...oldMessages, new UiEvent({role: 'user', text: 'User', event: {} as any})]; component.ngOnChanges({ 'messages': - new SimpleChange(oldMessages, component.messages, false) + new SimpleChange(oldMessages, component.uiEvents, false) }); fixture.detectChanges(); tick(50); @@ -387,8 +397,8 @@ describe('ChatPanelComponent', () => { const initialMessageCount = 50; const initialMessages = Array.from( {length: initialMessageCount}, - (_, i) => ({role: 'bot', text: `message ${i}`})); - component.messages = initialMessages; + (_, i) => new UiEvent({role: 'bot', text: `message ${i}`, event: {} as any})); + component.uiEvents = initialMessages; fixture.detectChanges(); scrollContainerElement.style.height = '100px'; @@ -409,16 +419,16 @@ describe('ChatPanelComponent', () => { mockUiStateService.lazyLoadMessagesResponse.next(); const newMessages = Array.from( - {length: 20}, (_, i) => ({role: 'bot', text: `new ${i}`})); - component.messages = [...newMessages, ...component.messages]; + {length: 20}, (_, i) => new UiEvent({role: 'bot', text: `new ${i}`, event: {} as any})); + component.uiEvents = [...newMessages, ...component.uiEvents]; mockUiStateService.newMessagesLoadedResponse.next( {items: newMessages, nextPageToken: 'next'}); tick(); fixture.detectChanges(); - expect(component.messages.length) + expect(component.uiEvents.length) .toBe(initialMessageCount + newMessages.length); - expect(component.messages[0]).toEqual(newMessages[0]); + expect(component.uiEvents[0]).toEqual(newMessages[0]); })); }); @@ -485,7 +495,7 @@ describe('ChatPanelComponent', () => { scrollContainer, 'scrollHeight', {value: 1500, configurable: true}); mockUiStateService.newMessagesLoadedResponse.next({ - items: [{role: 'bot', text: 'message 1'}], + items: [new UiEvent({role: 'bot', text: 'message 1', event: {} as any})], nextPageToken: nextToken }); @@ -568,12 +578,13 @@ describe('ChatPanelComponent', () => { const button = allButtons.find( b => b.nativeElement.querySelector('mat-icon')?.textContent?.trim() === - 'mic'); + 'call'); expect(button!.nativeElement.disabled).toBeTrue(); }); it('should have the videocam button disabled', () => { mockFeatureFlagService.isBidiStreamingEnabledResponse.next(false); + component.isAudioRecording = true; fixture.detectChanges(); const allButtons = @@ -609,7 +620,8 @@ describe('ChatPanelComponent', () => { }); }); - describe('when more options button is hidden', () => { + // Skipped: More options button behavior changed in UI refactor + xdescribe('when more options button is hidden', () => { beforeEach(() => { mockFeatureFlagService.isMoreOptionsButtonHiddenResponse.next(true); fixture.detectChanges(); @@ -670,7 +682,7 @@ describe('ChatPanelComponent', () => { describe('Feedback UI', () => { it('should show when feature flag is on', () => { - component.messages = [{role: 'bot', text: 'message'}]; + component.uiEvents = [new UiEvent({role: 'bot', text: 'message', event: {} as any})]; mockFeatureFlagService.isFeedbackServiceEnabledResponse.next(true); fixture.detectChanges(); @@ -681,7 +693,7 @@ describe('ChatPanelComponent', () => { }); it('should hide when feature flag is off', () => { - component.messages = [{role: 'bot', text: 'message'}]; + component.uiEvents = [new UiEvent({role: 'bot', text: 'message', event: {} as any})]; mockFeatureFlagService.isFeedbackServiceEnabledResponse.next(false); fixture.detectChanges(); @@ -692,7 +704,7 @@ describe('ChatPanelComponent', () => { }); it('should hide when agent response is loading', () => { - component.messages = [{role: 'bot', text: 'message'}]; + component.uiEvents = [new UiEvent({role: 'bot', text: 'message', event: {} as any})]; mockAgentService.getLoadingStateResponse.next(true); fixture.detectChanges(); @@ -703,12 +715,12 @@ describe('ChatPanelComponent', () => { }); it('should show after each bot message', () => { - component.messages = [ - {role: 'bot', text: 'message 1'}, - {role: 'bot', text: 'message 1'}, - {role: 'user', text: 'message 2'}, - {role: 'bot', text: 'message 1'}, - {role: 'bot', text: 'message 1'}, + component.uiEvents = [ + new UiEvent({role: 'bot', text: 'message 1', event: {} as any}), + new UiEvent({role: 'bot', text: 'message 1', event: {} as any}), + new UiEvent({role: 'user', text: 'message 2', event: {} as any}), + new UiEvent({role: 'bot', text: 'message 1', event: {} as any}), + new UiEvent({role: 'bot', text: 'message 1', event: {} as any}), ]; fixture.detectChanges(); @@ -729,7 +741,7 @@ describe('ChatPanelComponent', () => { url: 'http://example.com' } }; - expect(component.isComputerUseResponse(response)).toBeTrue(); + expect(isComputerUseResponse(response)).toBeTrue(); }); it( @@ -739,7 +751,7 @@ describe('ChatPanelComponent', () => { name: 'computer_use', response: {image: null, url: 'http://example.com'} }; - expect(component.isComputerUseResponse(response)).toBeFalse(); + expect(isComputerUseResponse(response)).toBeFalse(); }); }); }); diff --git a/src/app/components/chat-panel/chat-panel.component.ts b/src/app/components/chat-panel/chat-panel.component.ts index 83734adf..b6ec350a 100644 --- a/src/app/components/chat-panel/chat-panel.component.ts +++ b/src/app/components/chat-panel/chat-panel.component.ts @@ -15,49 +15,60 @@ * limitations under the License. */ -import {TextFieldModule} from '@angular/cdk/text-field'; -import {CommonModule, NgClass} from '@angular/common'; -import {AfterViewInit, ChangeDetectionStrategy, Component, DestroyRef, effect, ElementRef, EventEmitter, HostListener, inject, InjectionToken, input, Input, OnChanges, Output, signal, SimpleChanges, Type, ViewChild} from '@angular/core'; -import {takeUntilDestroyed, toSignal} from '@angular/core/rxjs-interop'; -import {FormsModule} from '@angular/forms'; -import {MatButtonModule} from '@angular/material/button'; -import {MatCardModule} from '@angular/material/card'; -import {MatFormFieldModule} from '@angular/material/form-field'; -import {MatIconModule} from '@angular/material/icon'; -import {MatInputModule} from '@angular/material/input'; -import {MatMenuModule} from '@angular/material/menu'; -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 {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'; -import {SESSION_SERVICE} from '../../core/services/interfaces/session'; -import {STRING_TO_COLOR_SERVICE} from '../../core/services/interfaces/string-to-color'; -import {ListResponse} from '../../core/services/interfaces/types'; -import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state'; -import {JsonTooltipDirective} from '../../directives/html-tooltip.directive'; -import {A2uiCanvasComponent} from '../a2ui-canvas/a2ui-canvas.component'; -import {MediaType,} from '../artifact-tab/artifact-tab.component'; -import {AudioPlayerComponent} from '../audio-player/audio-player.component'; -import {ComputerActionComponent} from '../computer-action/computer-action.component'; -import {LongRunningResponseComponent} from '../long-running-response/long-running-response'; -import {MARKDOWN_COMPONENT, MarkdownComponentInterface} from '../markdown/markdown.component.interface'; -import {MessageFeedbackComponent} from '../message-feedback/message-feedback.component'; - -import {ChatPanelMessagesInjectionToken} from './chat-panel.component.i18n'; - -const ROOT_AGENT = 'root_agent'; +import { TextFieldModule } from '@angular/cdk/text-field'; +import { CommonModule } from '@angular/common'; +import { AfterViewInit, ChangeDetectionStrategy, Component, DestroyRef, effect, ElementRef, EventEmitter, HostListener, inject, InjectionToken, input, Input, OnChanges, Output, signal, SimpleChanges, Type, ViewChild } from '@angular/core'; +import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatCardModule } from '@angular/material/card'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatIconModule } from '@angular/material/icon'; +import { MatInputModule } from '@angular/material/input'; +import { MatMenuModule, MatMenuTrigger } from '@angular/material/menu'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatSlideToggleModule } from '@angular/material/slide-toggle'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { MatButtonToggleModule } from '@angular/material/button-toggle'; +import { MatSelectModule } from '@angular/material/select'; +import { NgxJsonViewerModule } from 'ngx-json-viewer'; +import { EMPTY, merge, NEVER, of, Subject } from 'rxjs'; +import { catchError, filter, first, switchMap, tap } from 'rxjs/operators'; + +import { AgentRunRequest } from '../../core/models/AgentRunRequest'; +import { isComputerUseResponse, isVisibleComputerUseClick } from '../../core/models/ComputerUse'; +import type { EvalCase } from '../../core/models/Eval'; +import { FunctionCall, FunctionResponse } from '../../core/models/types'; +import { UiEvent } from '../../core/models/UiEvent'; +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'; +import { SESSION_SERVICE } from '../../core/services/interfaces/session'; +import { STRING_TO_COLOR_SERVICE } from '../../core/services/interfaces/string-to-color'; +import { ListResponse } from '../../core/services/interfaces/types'; +import { UI_STATE_SERVICE } from '../../core/services/interfaces/ui-state'; +import { THEME_SERVICE } from '../../core/services/interfaces/theme'; +import { JsonTooltipDirective } from '../../directives/html-tooltip.directive'; +import { WorkflowGraphTooltipDirective } from '../../directives/workflow-graph-tooltip.directive'; +import { A2uiCanvasComponent } from '../a2ui-canvas/a2ui-canvas.component'; +import { MediaType, } from '../artifact-tab/artifact-tab.component'; +import { AudioPlayerComponent } from '../audio-player/audio-player.component'; +import { ComputerActionComponent } from '../computer-action/computer-action.component'; +import { LongRunningResponseComponent } from '../long-running-response/long-running-response'; +import { MARKDOWN_COMPONENT, MarkdownComponentInterface } from '../markdown/markdown.component.interface'; +import { MessageFeedbackComponent } from '../message-feedback/message-feedback.component'; + +import { ChatPanelMessagesInjectionToken } from './chat-panel.component.i18n'; + +import { HoverInfoButtonComponent } from '../hover-info-button/hover-info-button.component'; +import { ChatAvatarComponent } from '../chat-avatar/chat-avatar.component'; +import { EventRowComponent } from '../event-row/event-row.component'; +import { CallControlsComponent } from '../call-controls/call-controls.component'; +import { TraceTreeComponent } from '../trace-tab/trace-tree/trace-tree.component'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-chat-panel', templateUrl: './chat-panel.component.html', styleUrl: './chat-panel.component.scss', @@ -74,35 +85,38 @@ const ROOT_AGENT = 'root_agent'; MatFormFieldModule, MatMenuModule, MatProgressSpinnerModule, + MatSlideToggleModule, NgxJsonViewerModule, - A2uiCanvasComponent, - AudioPlayerComponent, - MessageFeedbackComponent, MatTooltipModule, - NgClass, - JsonTooltipDirective, - ComputerActionComponent, - LongRunningResponseComponent, + MatButtonToggleModule, + MatSelectModule, + EventRowComponent, + CallControlsComponent, + TraceTreeComponent, ], }) export class ChatPanelComponent implements OnChanges, AfterViewInit { @Input() appName: string = ''; + @Input() agentReadme: string = ''; sessionName = input(''); - @Input() messages: any[] = []; + @Input() uiEvents: UiEvent[] = []; + @Input() traceData: any[] = []; @Input() isChatMode: boolean = true; - @Input() evalCase: EvalCase|null = null; + @Input() evalCase: EvalCase | null = null; @Input() isEvalEditMode: boolean = false; @Input() isEvalCaseEditing: boolean = false; + @Input() agentGraphData: any = null; @Input() isEditFunctionArgsEnabled: boolean = false; + @Input() isTokenStreamingEnabled: boolean = false; + @Input() useSse: boolean = false; @Input() userInput: string = ''; @Input() userEditEvalCaseMessage: string = ''; - @Input() selectedFiles: {file: File; url: string}[] = []; - @Input() updatedSessionState: any|null = null; - @Input() eventData = new Map(); - @Input() selectedEvent: any = undefined; + @Input() selectedFiles: { file: File; url: string }[] = []; + @Input() updatedSessionState: any | null = null; + @Input() selectedMessageIndex: number | undefined = undefined; @Input() isAudioRecording: boolean = false; + @Input() micVolume: number = 0; @Input() isVideoRecording: boolean = false; - @Input() hoveredEventMessageIndices: number[] = []; @Input() userId: string = ''; @Input() sessionId: string = ''; @@ -111,17 +125,17 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { @Output() readonly clickEvent = new EventEmitter(); @Output() readonly handleKeydown = - new EventEmitter<{event: KeyboardEvent, message: any}>(); + new EventEmitter<{ event: KeyboardEvent, message: any }>(); @Output() readonly cancelEditMessage = new EventEmitter(); @Output() readonly saveEditMessage = new EventEmitter(); @Output() readonly openViewImageDialog = new EventEmitter(); @Output() readonly openBase64InNewTab = - new EventEmitter<{data: string, mimeType: string}>(); + new EventEmitter<{ data: string, mimeType: string }>(); @Output() readonly editEvalCaseMessage = new EventEmitter(); @Output() readonly deleteEvalCaseMessage = - new EventEmitter<{message: any, index: number}>(); + new EventEmitter<{ message: any, index: number }>(); @Output() readonly editFunctionArgs = new EventEmitter(); @Output() readonly fileSelect = new EventEmitter(); @Output() readonly removeFile = new EventEmitter(); @@ -130,20 +144,25 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { @Output() readonly updateState = new EventEmitter(); @Output() readonly toggleAudioRecording = new EventEmitter(); @Output() readonly toggleVideoRecording = new EventEmitter(); - @Output() readonly longRunningResponseComplete = new EventEmitter(); + @Output() readonly longRunningResponseComplete = new EventEmitter(); + @Output() readonly toggleHideIntermediateEvents = new EventEmitter(); + @Output() readonly toggleSse = new EventEmitter(); - @ViewChild('videoContainer', {read: ElementRef}) videoContainer!: ElementRef; + @ViewChild('videoContainer', { read: ElementRef }) videoContainer!: ElementRef; @ViewChild('autoScroll') scrollContainer!: ElementRef; - @ViewChild('messageTextarea') public textarea: ElementRef|undefined; + @ViewChild('messageTextarea') public textarea: ElementRef | undefined; scrollInterrupted = false; private scrollHeight = 0; private lastMessageRef: any = null; private nextPageToken = ''; + private scrollTimeout: any = null; + private mutationObserver: MutationObserver | null = null; protected readonly i18n = inject(ChatPanelMessagesInjectionToken); protected readonly uiStateService = inject(UI_STATE_SERVICE); + protected readonly themeService = inject(THEME_SERVICE); private readonly stringToColorService = inject(STRING_TO_COLOR_SERVICE); readonly markdownComponent: Type = inject( - MARKDOWN_COMPONENT, + MARKDOWN_COMPONENT, ); protected readonly featureFlagService = inject(FEATURE_FLAG_SERVICE); private readonly agentService = inject(AGENT_SERVICE); @@ -151,258 +170,421 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { private readonly destroyRef = inject(DestroyRef); readonly MediaType = MediaType; readonly JSON = JSON; + readonly Object = Object; + readonly String = String; readonly isMessageFileUploadEnabledObs = - this.featureFlagService.isMessageFileUploadEnabled(); + this.featureFlagService.isMessageFileUploadEnabled(); readonly isManualStateUpdateEnabledObs = - this.featureFlagService.isManualStateUpdateEnabled(); + this.featureFlagService.isManualStateUpdateEnabled(); readonly isBidiStreamingEnabledObs = - this.featureFlagService.isBidiStreamingEnabled(); + this.featureFlagService.isBidiStreamingEnabled(); readonly canEditSession = signal(true); readonly isUserFeedbackEnabled = - toSignal(this.featureFlagService.isFeedbackServiceEnabled()); + toSignal(this.featureFlagService.isFeedbackServiceEnabled()); readonly isLoadingAgentResponse = - toSignal(this.agentService.getLoadingState()); + toSignal(this.agentService.getLoadingState()); readonly hideMoreOptionsButton = - toSignal(this.featureFlagService.isMoreOptionsButtonHidden()); + toSignal(this.featureFlagService.isMoreOptionsButtonHidden()); protected readonly onScroll = new Subject(); protected readonly sanitizer = inject(SAFE_VALUES_SERVICE); - constructor() { - effect(() => { - const sessionName = this.sessionName(); - if (sessionName) { - this.nextPageToken = ''; - this.uiStateService - .lazyLoadMessages(sessionName, { - pageSize: 100, - pageToken: this.nextPageToken, - }) - .pipe(first()) - .subscribe(); - } + hideIntermediateEvents = input(false); + invocationDisplayMap = input>(new Map()); + + viewMode = signal<'events' | 'traces'>('events'); + invocationIdFilterActive = signal(false); + nodePathFilterActive = signal(false); + invocationIdFilter = signal(''); + nodePathFilter = signal(''); + invocationIdOptions: string[] = []; + nodePathOptions: string[] = []; + + @ViewChild('invChipMenuTrigger') invChipMenuTrigger?: MatMenuTrigger; + @ViewChild('nodeChipMenuTrigger') nodeChipMenuTrigger?: MatMenuTrigger; + @ViewChild('addMenuTrigger') addMenuTrigger?: MatMenuTrigger; + + openAddFilterMenu(event: Event) { + event.stopPropagation(); + this.addMenuTrigger?.openMenu(); + } + + addInvocationIdFilter() { + this.invocationIdFilterActive.set(true); + setTimeout(() => { + this.invChipMenuTrigger?.openMenu(); }); } - ngOnInit() { - this.featureFlagService.isInfinityMessageScrollingEnabled() - .pipe( - first(), - filter((enabled) => enabled), - switchMap( - () => merge( - this.uiStateService.onNewMessagesLoaded().pipe( - tap((response: ListResponse& - {isBackground?: boolean}) => { - this.nextPageToken = response.nextPageToken ?? ''; - if (!response.isBackground) { - this.restoreScrollPosition(); - } - })), - this.onScroll.pipe(switchMap((event: Event) => { - const element = event.target as HTMLElement; - if (element.scrollTop !== 0) { - return EMPTY; - } - - if (!this.nextPageToken) { - return EMPTY; - } - - this.scrollHeight = element.scrollHeight; - return this.uiStateService - .lazyLoadMessages(this.sessionName(), { - pageSize: 100, - pageToken: this.nextPageToken, - }) - .pipe(first(), catchError(() => NEVER)); - })))), - takeUntilDestroyed(this.destroyRef), - ) - .subscribe(); + addNodePathFilter() { + this.nodePathFilterActive.set(true); + setTimeout(() => { + this.nodeChipMenuTrigger?.openMenu(); + }); } - ngAfterViewInit() { - if (this.scrollContainer?.nativeElement) { - this.scrollContainer.nativeElement.addEventListener('wheel', () => { - this.scrollInterrupted = true; - }); - this.scrollContainer.nativeElement.addEventListener('touchmove', () => { - this.scrollInterrupted = true; - }); - } + removeInvocationIdFilter(event: Event) { + event.stopPropagation(); + this.invocationIdFilterActive.set(false); + this.invocationIdFilter.set(''); } - ngOnChanges(changes: SimpleChanges) { - if (changes['messages']) { - const currentLastMessage = this.messages[this.messages.length - 1]; - const isNewMessageAppended = currentLastMessage !== this.lastMessageRef; + removeNodePathFilter(event: Event) { + event.stopPropagation(); + this.nodePathFilterActive.set(false); + this.nodePathFilter.set(''); + } - if (isNewMessageAppended) { - if (currentLastMessage?.role === 'user' || - currentLastMessage?.isLoading === true) { - this.scrollInterrupted = false; - } - this.scrollToBottom(); - } - this.lastMessageRef = currentLastMessage; + onInvocationMenuClosed() { + if (!this.invocationIdFilter()) { + this.invocationIdFilterActive.set(false); } } - scrollToBottom() { - if (!this.scrollInterrupted) { - setTimeout(() => { - this.scrollContainer?.nativeElement.scrollTo({ - top: this.scrollContainer.nativeElement.scrollHeight, - behavior: 'auto', - }); - }, 50); + onNodePathMenuClosed() { + if (!this.nodePathFilter()) { + this.nodePathFilterActive.set(false); } } + spansByInvocationId = new Map(); - getAgentNameFromEvent(i: number) { - const key = this.messages[i].eventId; - const selectedEvent = this.eventData.get(key); + eventsScrollTop = -1; + tracesScrollTop = -1; - return selectedEvent?.author ?? ROOT_AGENT; + clearAllFilters(event: Event) { + event.stopPropagation(); + if (this.invocationIdFilterActive()) { + this.invocationIdFilterActive.set(false); + this.invocationIdFilter.set(''); + } + if (this.nodePathFilterActive()) { + this.nodePathFilterActive.set(false); + this.nodePathFilter.set(''); + } + if (this.hideIntermediateEvents()) { + this.toggleHideIntermediateEvents.emit(); + } } - customIconColorClass(i: number) { - const agentName = this.getAgentNameFromEvent(i); - return agentName !== ROOT_AGENT ? - `custom-icon-color-${ - this.stringToColorService.stc(agentName).replace('#', '')}` : - ''; - } + onViewModeChange(mode: 'events' | 'traces') { + if (this.scrollContainer?.nativeElement) { + if (this.viewMode() === 'events') { + this.eventsScrollTop = this.scrollContainer.nativeElement.scrollTop; + } else if (this.viewMode() === 'traces') { + this.tracesScrollTop = this.scrollContainer.nativeElement.scrollTop; + } + } - shouldMessageHighlighted(index: number) { - return this.hoveredEventMessageIndices.includes(index); - } + this.viewMode.set(mode); + try { + localStorage.setItem('chat-view-mode', mode); + } catch (e) { + // Ignored + } - isMessageEventSelected(index: number): boolean { - const message = this.messages[index]; - return message.eventId && this.selectedEvent && - message.eventId === this.selectedEvent.id; + setTimeout(() => { + if (this.scrollContainer?.nativeElement) { + if (mode === 'events' && this.eventsScrollTop !== -1) { + this.scrollContainer.nativeElement.scrollTop = this.eventsScrollTop; + } else if (mode === 'traces' && this.tracesScrollTop !== -1) { + this.scrollContainer.nativeElement.scrollTop = this.tracesScrollTop; + } else { + // If first time switching to mode and we haven't tracked a scroll position yet, stick to bottom + this.scrollToBottom(); + } + } + }); } - shouldShowMessageCard(message: any): boolean { - return !!( - message.text || message.attachments || message.inlineData || - message.executableCode || message.codeExecutionResult || - message.a2uiData || message.renderedContent || message.isLoading || - (message.failedMetric && message.evalStatus === 2)); - } + shouldShowEvent(uiEvent: UiEvent): boolean { + const invFilter = this.invocationIdFilter(); + if (invFilter) { + const eventInvId = uiEvent.event?.invocationId || ''; + if (!eventInvId.includes(invFilter)) { + return false; + } + } - getBotEventNumber(messageIndex: number): number { - const message = this.messages[messageIndex]; + const pathFilter = this.nodePathFilter(); + if (pathFilter) { + const eventPath = uiEvent.event?.nodeInfo?.path || ''; + if (!eventPath.includes(pathFilter)) { + return false; + } + } - if (message.role !== 'bot' || !message.eventId) { - return -1; + if (!this.hideIntermediateEvents()) { + return true; } - const uniqueBotEventIds: string[] = []; - for (let i = 0; i <= messageIndex; i++) { - const msg = this.messages[i]; - if (msg.role === 'bot' && msg.eventId && - !uniqueBotEventIds.includes(msg.eventId)) { - uniqueBotEventIds.push(msg.eventId); + if (uiEvent.role === 'user') { + return true; + } + + if (uiEvent.event?.content !== undefined) { + const parts = uiEvent.event.content.parts || []; + const hasOnlyFunctions = parts.length > 0 && parts.every((p: any) => p.functionCall || p.functionResponse); + + if (hasOnlyFunctions) { + const isLongRunning = parts.some((p: any) => { + const id = p.functionCall?.id || p.functionResponse?.id; + return id && uiEvent.event?.longRunningToolIds?.includes(id); + }); + if (isLongRunning) { + return true; + } + } else { + return true; } } - return uniqueBotEventIds.indexOf(message.eventId) + 1; - } + if (uiEvent.event?.output !== undefined) { + const nodeInfo = uiEvent.event?.nodeInfo; + let isTopLevel = false; + let outputFor = nodeInfo?.['outputFor']; + + if (Array.isArray(outputFor)) { + isTopLevel = outputFor.some((path: string) => !path.includes('/')); + } else if (typeof outputFor === 'string') { + isTopLevel = !outputFor.includes('/'); + } else if (nodeInfo?.path) { + isTopLevel = !nodeInfo.path.includes('/'); + } + if (isTopLevel) { + return true; + } + } - getOverallEventNumber(messageIndex: number): number { - let eventCount = 0; - let lastSeenGroupType: 'user'|'bot'|null = null; - let lastBotEventId: string|null = null; + return false; + } - for (let i = 0; i <= messageIndex; i++) { - const msg = this.messages[i]; + shouldShowTraceTree(uiEvent: UiEvent): boolean { + const invFilter = this.invocationIdFilter(); + if (invFilter && uiEvent.event?.invocationId !== invFilter) { + return false; + } + return true; + } - if (msg.role === 'user') { - // User messages increment when they start a new group - if (lastSeenGroupType !== 'user') { - eventCount++; - lastSeenGroupType = 'user'; - } + shouldShowEventFn = this.shouldShowEvent.bind(this); - if (i === messageIndex) { - return eventCount; - } - } else if (msg.role === 'bot' && msg.eventId) { - // Bot events increment when they're a new event - if (msg.eventId !== lastBotEventId) { - eventCount++; - lastBotEventId = msg.eventId; - lastSeenGroupType = 'bot'; - } + constructor() { + effect(() => { + const sessionName = this.sessionName(); + if (sessionName) { + this.nextPageToken = ''; + this.featureFlagService.isInfinityMessageScrollingEnabled() + .pipe(first(), filter((enabled) => enabled)) + .subscribe(() => { + this.uiStateService + .lazyLoadMessages(sessionName, { + pageSize: 100, + pageToken: this.nextPageToken, + }) + .pipe(first()) + .subscribe(); + }); + } + }); + } - if (i === messageIndex) { - return eventCount; + ngOnInit() { + this.uiStateService.isSessionLoading() + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((isLoading) => { + if (!isLoading) { + this.focusInput(); } + }); + + try { + const savedMode = localStorage.getItem('chat-view-mode'); + if (savedMode === 'events' || savedMode === 'traces') { + this.viewMode.set(savedMode); } + } catch (e) { + // Ignored } - return -1; + this.featureFlagService.isInfinityMessageScrollingEnabled() + .pipe( + first(), + filter((enabled) => enabled), + switchMap( + () => merge( + this.uiStateService.onNewMessagesLoaded().pipe( + tap((response: ListResponse & + { isBackground?: boolean }) => { + this.nextPageToken = response.nextPageToken ?? ''; + if (!response.isBackground) { + this.restoreScrollPosition(); + } + })), + this.onScroll.pipe(switchMap((event: Event) => { + const element = event.target as HTMLElement; + if (element.scrollTop !== 0) { + return EMPTY; + } + + if (!this.nextPageToken) { + return EMPTY; + } + + this.scrollHeight = element.scrollHeight; + return this.uiStateService + .lazyLoadMessages(this.sessionName(), { + pageSize: 100, + pageToken: this.nextPageToken, + }) + .pipe(first(), catchError(() => NEVER)); + })))), + takeUntilDestroyed(this.destroyRef), + ) + .subscribe(); } - isFirstUserMessageInGroup(messageIndex: number): boolean { - const message = this.messages[messageIndex]; + ngAfterViewInit() { + if (this.scrollContainer?.nativeElement) { + const el = this.scrollContainer.nativeElement; + + el.addEventListener('scroll', () => { + const isAtBottom = Math.abs(el.scrollHeight - el.scrollTop - el.clientHeight) < 50; + this.scrollInterrupted = !isAtBottom; + }); - if (message.role !== 'user') { - return false; + this.mutationObserver = new MutationObserver(() => { + if (!this.scrollInterrupted) { + this.scrollToBottom(); + } + }); + this.mutationObserver.observe(el, { + childList: true, + subtree: true, + characterData: true + }); + + this.destroyRef.onDestroy(() => { + this.mutationObserver?.disconnect(); + }); } + } - if (messageIndex === 0) { - return true; + ngOnChanges(changes: SimpleChanges) { + if (changes['appName']) { + this.focusInput(); } - const prevMessage = this.messages[messageIndex - 1]; - return prevMessage.role !== 'user'; - } + // Scroll to top when switching apps or when messages become empty (new session with README) + if ((changes['appName'] || changes['uiEvents']) && this.uiEvents.length === 0 && this.agentReadme) { + setTimeout(() => this.scrollToTop(), 0); + } - isFirstMessageInEventGroup(messageIndex: number): boolean { - const message = this.messages[messageIndex]; + if (changes['uiEvents']) { + const ids = new Set(); + const paths = new Set(); + for (const e of this.uiEvents) { + if (e.event?.invocationId) ids.add(e.event.invocationId); + if (e.event?.nodeInfo?.path) paths.add(e.event.nodeInfo.path); + } + this.invocationIdOptions = Array.from(ids); + this.nodePathOptions = Array.from(paths); - if (message.role !== 'bot' || !message.eventId) { - return false; + const currentLastMessage = this.uiEvents[this.uiEvents.length - 1]; + const isNewMessageAppended = currentLastMessage !== this.lastMessageRef; + + if (isNewMessageAppended) { + if (currentLastMessage?.role === 'user' || + currentLastMessage?.isLoading === true) { + this.scrollInterrupted = false; + } + this.scrollToBottom(); + } + this.lastMessageRef = currentLastMessage; } - if (messageIndex === 0) { - return true; // First message overall + if (changes['traceData'] && this.traceData) { + this.rebuildTrace(); } + } + + rebuildTrace() { + const invocTraces = this.traceData.reduce((map: any, item: any) => { + const key = item.trace_id; + const group = map.get(key); + if (group) { + group.push(item); + group.sort((a: any, b: any) => a.start_time - b.start_time); + } else { + map.set(key, [item]); + } + return map; + }, new Map()); + + this.spansByInvocationId = new Map(); + for (const [key, group] of invocTraces) { + const invocId = group.find( + (item: any) => item.attributes !== undefined && 'gcp.vertex.agent.invocation_id' in item.attributes + )?.attributes['gcp.vertex.agent.invocation_id']; - const prevMessage = this.messages[messageIndex - 1]; - return prevMessage.eventId !== message.eventId; + if (invocId) { + this.spansByInvocationId.set(invocId, group); + } + } } + isFirstEventForInvocation(uiEvent: UiEvent, index: number): boolean { + if (!uiEvent.event?.invocationId) return false; - hasStateDelta(messageIndex: number): boolean { - const message = this.messages[messageIndex]; - if (!message.eventId) return false; + // Check if any previous bot event in uiEvents has the same invocationId + for (let i = index - 1; i >= 0; i--) { + const priorEvent = this.uiEvents[i]; + if (priorEvent.role === 'bot' && priorEvent.event?.invocationId === uiEvent.event?.invocationId) { + return false; + } + } - const event = this.eventData.get(message.eventId); - const stateDelta = event?.actions?.stateDelta; - return stateDelta && Object.keys(stateDelta).length > 0; + return true; } + scrollToBottom() { + if (!this.sessionId) return; + if (!this.scrollInterrupted) { + if (this.scrollTimeout) { + clearTimeout(this.scrollTimeout); + } + this.scrollTimeout = setTimeout(() => { + this.scrollContainer?.nativeElement.scrollTo({ + top: this.scrollContainer.nativeElement.scrollHeight, + behavior: 'auto', + }); + this.scrollTimeout = null; + }, 50); + } + } - hasArtifactDelta(messageIndex: number): boolean { - const message = this.messages[messageIndex]; - if (!message.eventId) return false; + scrollToTop() { + setTimeout(() => { + this.scrollContainer?.nativeElement.scrollTo({ + top: 0, + behavior: 'smooth', + }); + }, 50); + } - const event = this.eventData.get(message.eventId); - const artifactDelta = event?.actions?.artifactDelta; - return artifactDelta && Object.keys(artifactDelta).length > 0; + focusInput() { + setTimeout(() => { + this.textarea?.nativeElement?.focus(); + }, 50); } - renderGooglerSearch(content: string) { - return this.sanitizer.bypassSecurityTrustHtml(content); + isMessageEventSelected(index: number): boolean { + return index === this.selectedMessageIndex; } + + private restoreScrollPosition() { if (!this.scrollHeight) { this.scrollInterrupted = false; @@ -412,71 +594,48 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { const scrollContainer = this.scrollContainer?.nativeElement; if (scrollContainer) { scrollContainer.scrollTop = - scrollContainer.scrollHeight - this.scrollHeight; + scrollContainer.scrollHeight - this.scrollHeight; this.scrollHeight = 0; } } - isComputerUseClick(input: any): boolean { - return isVisibleComputerUseClick(input); - } - isComputerUseResponse(input: any): boolean { - return isComputerUseResponse(input); - } - getFunctionCallArgsTooltip(message: any): string { - if (!message.functionCall || !message.functionCall.args) { - return ''; - } - try { - return JSON.stringify(message.functionCall.args); - } catch (e) { - return String(message.functionCall.args); - } - } + getAllWorkflowNodes(messageIndex: number): any { + // Collect node states from all events, organized by nodePath + // Structure: { "order_processing_pipeline": { "__START__": {...}, "validation_stage": {...} }, ... } + const nodesByPath: any = {}; + for (let i = 0; i <= messageIndex; i++) { + const msg = this.uiEvents[i]; + const event = msg.event; + const nodes = event?.actions?.agentState?.nodes; + const nodePath = event?.nodeInfo?.path; + + if (nodes && nodePath) { + // Initialize path if not exists + if (!nodesByPath[nodePath]) { + nodesByPath[nodePath] = {}; + } - getFunctionResponseTooltip(message: any): string { - if (!message.functionResponse || !message.functionResponse.response) { - return ''; - } - try { - return JSON.stringify(message.functionResponse.response); - } catch (e) { - return String(message.functionResponse.response); + // Merge nodes for this path, later states override earlier ones + Object.assign(nodesByPath[nodePath], nodes); + } } + + return Object.keys(nodesByPath).length > 0 ? nodesByPath : null; } - getStateDeltaTooltip(messageIndex: number): string { - const message = this.messages[messageIndex]; - if (!message.eventId) return ''; - const event = this.eventData.get(message.eventId); - const stateDelta = event?.actions?.stateDelta; - if (!stateDelta) return ''; - try { - return JSON.stringify(stateDelta); - } catch (e) { - return String(stateDelta); - } - } + handleAgentStateClick(event: Event, messageIndex: number) { + event.stopPropagation(); + const isAlreadySelected = messageIndex === this.selectedMessageIndex; - getArtifactDeltaTooltip(messageIndex: number): string { - const message = this.messages[messageIndex]; - if (!message.eventId) return ''; - - const event = this.eventData.get(message.eventId); - const artifactDelta = event?.actions?.artifactDelta; - if (!artifactDelta) return ''; - - try { - return JSON.stringify(artifactDelta); - } catch (e) { - return String(artifactDelta); + if (!isAlreadySelected) { + this.clickEvent.emit(messageIndex); } } @@ -497,55 +656,47 @@ export class ChatPanelComponent implements OnChanges, AfterViewInit { @HostListener('window:keydown', ['$event']) handleKeyboardNavigation(event: KeyboardEvent) { - if (!this.selectedEvent) return; + if (this.selectedMessageIndex === undefined) return; + + const activeElement = document.activeElement as HTMLElement | null; + if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.isContentEditable)) { + return; + } // Only handle arrow keys if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return; event.preventDefault(); - // Find unique eventIds and their first occurrence index - const uniqueEventMap = new Map(); - for (let i = 0; i < this.messages.length; i++) { - const msg = this.messages[i]; - if (msg.eventId && !uniqueEventMap.has(msg.eventId)) { - uniqueEventMap.set(msg.eventId, i); - } - } - - const eventIndices = Array.from(uniqueEventMap.values()); - - if (eventIndices.length === 0) return; - - // Find current selected event index - const currentIndex = eventIndices.findIndex( - (idx) => this.messages[idx].eventId === this.selectedEvent.id); - - if (currentIndex === -1) return; - // Navigate to next or previous let newIndex: number; if (event.key === 'ArrowDown') { - newIndex = currentIndex + 1 >= eventIndices.length ? 0 : currentIndex + 1; + newIndex = this.selectedMessageIndex + 1 >= this.uiEvents.length ? 0 : this.selectedMessageIndex + 1; } else { newIndex = - currentIndex - 1 < 0 ? eventIndices.length - 1 : currentIndex - 1; + this.selectedMessageIndex - 1 < 0 ? this.uiEvents.length - 1 : this.selectedMessageIndex - 1; } // Emit click event for the new index - this.clickEvent.emit(eventIndices[newIndex]); + this.clickEvent.emit(newIndex); + this.scrollToSelectedMessage(newIndex); + } + + scrollToSelectedMessage(index?: number) { + const targetIndex = index !== undefined ? index : this.selectedMessageIndex; + if (targetIndex === undefined) return; - // Scroll the selected message into view + // Scroll the selected message into view after a short delay to allow DOM updates setTimeout(() => { if (!this.scrollContainer?.nativeElement) return; const messageElements = - this.scrollContainer.nativeElement.querySelectorAll( - '.message-column-container'); - if (messageElements && messageElements[eventIndices[newIndex]]) { - messageElements[eventIndices[newIndex]].scrollIntoView( - {behavior: 'smooth', block: 'nearest', inline: 'nearest'}); + this.scrollContainer.nativeElement.querySelectorAll( + '.message-row-container'); + if (messageElements && messageElements[targetIndex]) { + messageElements[targetIndex].scrollIntoView( + { behavior: 'smooth', block: 'nearest', inline: 'nearest' }); } - }, 0); + }, 50); } } diff --git a/src/app/components/chat/chat.component.html b/src/app/components/chat/chat.component.html index c8d277c7..b4f3133e 100644 --- a/src/app/components/chat/chat.component.html +++ b/src/app/components/chat/chat.component.html @@ -14,32 +14,292 @@ limitations under the License. --> + +
+ + +
+ + @if (!isBuilderMode()) { +
+ + + + + + + } +
+
+ @if (appName) { +
+
+ + @if (!evalCase) { + @let isSessionLoadingGroup = uiStateService.isSessionLoading() | async; + @if (isSessionLoadingGroup === false) { +
+ + } + } +
+
+ @if (evalCase) { +
+
{{ i18n.evalCaseIdLabel }}
+
{{ evalCase.evalId }}
+
+
+ @if (this.isEvalEditMode()) { + + + } @else { + + + } +
+ } @else { +
+ @let isSessionLoading = uiStateService.isSessionLoading() | async; @if (isSessionLoading === + false) { + @if (!canEditSession()) { +
+ visibility + {{ i18n.readOnlyBadgeLabel }} +
+
{{i18n.cannotEditSessionMessage}}
+ } } @else { +
{{ i18n.loadingSessionLabel }}
+ } +
+ } +
+
+ @let isSessionLoadingActions = uiStateService.isSessionLoading() | async; + @if (isSessionLoadingActions === false) { + + + + @if (isDeleteSessionEnabledObs | async) { + + } + @if (isExportSessionEnabledObs | async) { + + } + @if (importSessionEnabledObs | async) { + + } + + } +
+
+ } + + +
+
+ User ID + + +
+
+ +
+
+
+
+ - @if (!showSidePanel && appName === "") { - left_panel_open - } + @if (showAppSelectorDrawer) { +
+ Select an App +
+ + +
+
+ +
+ @if (isLoadingApps()) { +
+ } @else if (filteredDrawerApps$ | async; as apps) { + @for (app of apps; track app) { + + } @empty { +
No apps found
+ } + } +
+ } @else if (showSessionSelectorDrawer) { +
+ Select a Session +
+ +
+
+ @if (sessionId) { +
+ Current Session +
+ +
+
+ {{ sessionId }} + +
+
+ } +
+ +
+ } + + + @if (!isBuilderMode()) { } @else { @@ -123,137 +391,7 @@
- @if (appName != "") { -
- @if (!showSidePanel) { - left_panel_open - } @if (evalCase) { -
-
{{ i18n.evalCaseIdLabel }}
-
{{ evalCase.evalId }}
-
-
- @if (this.isEvalEditMode()) { - - - } @else { - - edit - - - delete - - } -
- } @else { -
- @let isSessionLoading = uiStateService.isSessionLoading() | async; @if (isSessionLoading === - false) { -
{{ i18n.sessionIdLabel }}
-
{{ sessionId }}
- @if (isUserIdOnToolbarEnabledObs | async) { -
{{ i18n.userIdLabel }}
-
{{ userId }}
- } @if (!canEditSession()) { -
- visibility - {{ i18n.readOnlyBadgeLabel }} -
-
{{i18n.cannotEditSessionMessage}}
- } } @else { -
{{ i18n.loadingSessionLabel }}
- } -
- @if (isSessionLoading === false) { -
-
- - {{ i18n.tokenStreamingLabel }} - -
- -
-
- add - {{ i18n.newSessionButton }} -
- @if (isDeleteSessionEnabledObs | async) { - - delete - - } @if (isExportSessionEnabledObs | async) { - - download - - } @if (importSessionEnabledObs | async) { - - upload - - } -
-
- } } -
- } - + @if (!selectedAppControl.value) { @if (isLoadingApps()) {
{{ i18n.loadingAgentsLabel }} @@ -281,7 +419,14 @@ } } @if (appName != "") { } - @if (bottomPanelVisible) { -
-
- -
- } - @if (isDeveloperUiDisclaimerEnabledObs | async) { -
- {{i18n.adkWebDeveloperUiMessage}} -
- }
} + + + +@if (showAgentStructureOverlay) { + + +} diff --git a/src/app/components/chat/chat.component.i18n.ts b/src/app/components/chat/chat.component.i18n.ts index 29a6f67b..dc10afca 100644 --- a/src/app/components/chat/chat.component.i18n.ts +++ b/src/app/components/chat/chat.component.i18n.ts @@ -27,14 +27,23 @@ export const CHAT_MESSAGES = { saveButton: 'Save', editEvalCaseTooltip: 'Edit current eval case', deleteEvalCaseTooltip: 'Delete current eval case', - sessionIdLabel: 'Session ID', + sessionIdLabel: 'Session', + copySessionIdTooltip: 'Copy session ID', + sessionIdCopiedMessage: 'Session ID copied', + copySessionIdFailedMessage: 'Failed to copy session ID', userIdLabel: 'User ID', + editUserIdTooltip: 'Edit user ID', + userIdInputPlaceholder: 'Enter user ID', + saveUserIdTooltip: 'Save user ID', + cancelUserIdEditTooltip: 'Cancel editing user ID', + invalidUserIdMessage: 'User ID cannot be empty', loadingSessionLabel: 'Loading session...', tokenStreamingLabel: 'Token Streaming', + moreOptionsTooltip: 'More options', createNewSessionTooltip: 'Create a new Session', newSessionButton: 'New Session', - deleteSessionTooltip: 'Delete current session', - exportSessionTooltip: 'Export current session', + deleteSessionTooltip: 'Delete session', + exportSessionTooltip: 'Export session', importSessionTooltip: 'Import session', loadingAgentsLabel: 'Loading agents, please wait...', welcomeMessage: 'Welcome to ADK!', @@ -45,9 +54,6 @@ export const CHAT_MESSAGES = { cannotEditSessionMessage: 'Chat is disabled to prevent changes to the end user\'s session.', readOnlyBadgeLabel: 'Read-only', - disclosureTooltip: - 'ADK Web is for development purposes. It has access to all the data and should not be used in production.', - adkWebDeveloperUiMessage: 'ADK Web Developer UI', }; /** diff --git a/src/app/components/chat/chat.component.scss b/src/app/components/chat/chat.component.scss index 99bdd920..eddaf66c 100644 --- a/src/app/components/chat/chat.component.scss +++ b/src/app/components/chat/chat.component.scss @@ -14,97 +14,12 @@ * limitations under the License. */ -@use '@angular/material' as mat; - .expand-side-drawer { position: relative; top: 4%; left: 1%; } -.drawer-container { - height: 100%; - background-color: var(--chat-drawer-container-background-color); -} - -.drawer-header { - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - @include mat.button-overrides( - ( - filled-container-color: #89b4f8, - filled-label-text-color: black, - ) - ); - - .mat-icon { - width: 36px; - height: 36px; - color: #bdc1c6; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - } - - .drawer-logo { - margin-left: 9px; - display: flex; - align-items: center; - - img { - margin-right: 9px; - } - - font-size: 16px; - font-style: normal; - font-weight: 500; - line-height: 24px; - letter-spacing: 0.1px; - } -} - -.drawer-header { - width: 100%; - display: flex; - justify-content: space-between; - align-items: center; - @include mat.button-overrides( - ( - filled-container-color: #89b4f8, - filled-label-text-color: black, - ) - ); - - .mat-icon { - width: 36px; - height: 36px; - color: #bdc1c6; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - } - - .drawer-logo { - margin-left: 9px; - display: flex; - align-items: center; - - img { - margin-right: 9px; - } - - font-size: 16px; - font-style: normal; - font-weight: 500; - line-height: 24px; - letter-spacing: 0.1px; - } -} - .chat-container { width: 100%; height: 100%; @@ -116,7 +31,7 @@ } .event-container { - color: var(--chat-event-container-color); + color: var(--mat-sys-on-surface); } .chat-card { @@ -125,12 +40,13 @@ overflow: hidden; flex: 1; min-height: 12%; + min-width: 500px; box-shadow: none; - background-color: var(--chat-card-background-color); -} + border-radius: 12px 0 0 0; -.function-event-button .mdc-button__label { - font-family: 'Google Sans Mono', monospace; + &.no-side-panel { + border-radius: 0; + } } .loading-bar { @@ -145,7 +61,7 @@ margin-top: 16px; } -.message-card { +.content-bubble { padding: 5px 20px; margin: 5px; border-radius: 20px; @@ -157,64 +73,52 @@ } .function-event-button { - background-color: var(--chat-function-event-button-background-color); margin: 5px 5px 10px; } .function-event-button-highlight { - background-color: var( - --chat-function-event-button-highlight-background-color - ); - border-color: var( - --chat-function-event-button-highlight-border-color - ) !important; - color: var(--chat-function-event-button-highlight-color) !important; + border-color: var(--mat-sys-primary) !important; + color: var(--mat-sys-on-primary) !important; } -.user-message { +.role-user { display: flex; justify-content: flex-end; align-items: center; - .message-card { - background-color: var(--chat-user-message-message-card-background-color); + .content-bubble { align-self: flex-end; - color: var(--chat-user-message-message-card-color); + color: var(--mat-sys-on-primary-container); + background-color: var(--mat-sys-primary-container); box-shadow: none; } } -.bot-message { +.role-bot { display: flex; align-items: center; - .message-card { - background-color: var(--chat-bot-message-message-card-background-color); + .content-bubble { align-self: flex-start; - color: var(--chat-bot-message-message-card-color); + color: var(--mat-sys-on-surface); + background-color: var(--mat-sys-surface-container-high); box-shadow: none; } } -.bot-message:focus-within { - .message-card { - background-color: var( - --chat-bot-message-focus-within-message-card-background-color - ); - border: 1px solid - var(--chat-bot-message-focus-within-message-card-border-color); +.role-bot:focus-within { + .content-bubble { + border: 1px solid var(--mat-sys-outline); } } .message-textarea { - background-color: var(--chat-message-textarea-background-color); max-width: 100%; border: none; font-family: 'Google Sans', 'Helvetica Neue', sans-serif; } .message-textarea:focus { - background-color: var(--chat-message-textarea-focus-background-color); outline: none; } @@ -223,12 +127,10 @@ justify-content: flex-end; } -.message-card .eval-compare-container { +.content-bubble .eval-compare-container { visibility: hidden; position: absolute; left: 10px; - z-index: 10; - background-color: var(--chat-eval-compare-container-background-color); overflow: hidden; border-radius: 20px; padding: 5px 20px; @@ -236,7 +138,7 @@ font-size: 16px; .actual-result { - border-right: 2px solid var(--chat-actual-result-border-right-color); + border-right: 2px solid var(--mat-sys-outline-variant); padding-right: 8px; min-width: 350px; max-width: 350px; @@ -249,7 +151,7 @@ } } -.message-card:hover .eval-compare-container { +.content-bubble:hover .eval-compare-container { visibility: visible; } @@ -269,17 +171,17 @@ .eval-response-header { padding-bottom: 5px; - border-bottom: 2px solid var(--chat-eval-response-header-border-bottom-color); + border-bottom: 2px solid var(--mat-sys-outline-variant); font-style: italic; font-weight: 700; } .header-expected { - color: var(--chat-header-expected-color); + color: var(--mat-sys-tertiary); } .header-actual { - color: var(--chat-header-actual-color); + color: var(--mat-sys-primary); } .eval-case-edit-button { @@ -290,12 +192,12 @@ .eval-pass { display: flex; - color: var(--chat-eval-pass-color); + color: var(--mat-sys-primary); } .eval-fail { display: flex; - color: var(--chat-eval-fail-color); + color: var(--mat-sys-error); } .navigation-button-sidepanel { @@ -307,17 +209,14 @@ position: fixed; bottom: 200px; right: 100px; - z-index: 1000; } .sidepanel-toggle { position: relative; top: 100px; - z-index: 1000; } .side-drawer { - background-color: var(--chat-side-drawer-background-color); color: var(--chat-side-drawer-color); border-radius: 0; } @@ -334,16 +233,10 @@ display: flex; align-items: center; gap: 5px; - background: var(--chat-file-item-background-color); padding: 5px; border-radius: 4px; } -button { - margin-left: 20px; - margin-right: 20px; -} - .empty-state-container { color: var(--chat-empty-state-container-color); height: 100%; @@ -367,123 +260,90 @@ button { } } -:host ::ng-deep .mat-mdc-unelevated-button:not(:disabled) { - color: var(--chat-mat-mdc-unelevated-button-color); - background-color: var(--chat-mat-mdc-unelevated-button-background-color); -} - -:host ::ng-deep .mdc-linear-progress__buffer-dots { - background-image: radial-gradient( - circle, - var( - --chat-mdc-linear-progress-buffer-dots-background-color, - var(--mat-sys-surface-variant) - ) - calc(var(--mat-progress-bar-track-height, 4px) / 2), - transparent 0 - ); -} - -:host ::ng-deep .mat-mdc-select-arrow-wrapper { - margin-left: 4px; -} - -:host ::ng-deep .mat-mdc-text-field-wrapper { - border: 1px solid var(--chat-mat-mdc-text-field-wrapper-border-color); -} - -:host ::ng-deep .mdc-notched-outline__leading, -:host ::ng-deep .mdc-notched-outline__notch, -:host ::ng-deep .mdc-notched-outline__trailing { - border: none; +.new-session-button { + margin-top: 0px; + margin-left: 50px; + width: 130px; + height: 28px; + font-size: 14px; } -:host ::ng-deep .mat-mdc-form-field-icon-suffix { - padding: 0 10px 0 40px; +.adk-checkbox { + position: fixed; + bottom: 0; + left: 0; + right: 0; + margin-bottom: 20px; + margin-left: 20px; } -:host ::ng-deep .segment-key { - color: var(--chat-segment-key-color) !important; +.app-toolbar { + height: 48px; + min-height: 48px !important; + display: flex; + align-items: center; + font-family: 'Google Sans', sans-serif; + font-size: 13px; + padding: 0 8px !important; + z-index: 1; } -.mat-mdc-select-placeholder { - margin-left: 20px; +.toolbar-group { + display: flex; + align-items: center; + flex-shrink: 0; } -.bottom-resize-handler { - background: var(--chat-bottom-resize-handler-background-color); - height: 5px; - border-radius: 4px; - position: absolute; - display: block; - width: 20%; - left: 40%; - top: 0; - right: 0; - z-index: 9999; - cursor: ns-resize; +.toolbar-agent-group { + margin-right: 6px; } -.trace-detail-container { - position: relative; - background-color: var(--chat-trace-detail-container-background-color); - - app-trace-event { - padding-top: 8px; - } +.toolbar-session-group { + flex-shrink: 1; + min-width: 0; + flex: 1; } -.new-session-button { - margin-top: 0px; - margin-left: 50px; - width: 130px; - height: 28px; - font-size: 14px; -} -.app-select-container { - width: 35%; - background-color: #212123; - height: 30px; +.toolbar-logo { display: flex; - justify-content: space-between; - padding-left: 20px; - padding-right: 20px; - border-radius: 10px; - padding-top: 5px; + align-items: center; + gap: 6px; + margin-right: 16px; + flex-shrink: 0; } -.app-select-container { - @include mat.select-overrides( - ( - placeholder-text-color: #8ab4f8, - enabled-trigger-text-color: #8ab4f8, - enabled-arrow-color: #8ab4f8, - ) - ); +.toolbar-logo-text { + font-family: 'Google Sans', sans-serif; + font-size: 14px; + font-weight: 500; + white-space: nowrap; } -.adk-checkbox { - position: fixed; - bottom: 0; - left: 0; - right: 0; - margin-bottom: 20px; - margin-left: 20px; +.disclosure-info-icon { + font-size: 18px; + width: 18px; + height: 18px; + opacity: 0.7; + cursor: pointer; + margin-right: 16px; + color: var(--chat-toolbar-icon-color); } -.chat-toolbar { - position: sticky; - top: 0; - height: 48px; - background: var(--chat-toolbar-background-color); +.toolbar-content { display: flex; align-items: center; - z-index: 10; + flex: 1; + min-width: 0; } -.chat-toolbar.edit-mode { - background: var(--chat-toolbar-edit-mode-background-color); +.drawer-container { + height: calc(100% - 48px); +} + +.side-panel-container { + width: 100%; + height: 100%; } .toolbar-actions { @@ -495,81 +355,144 @@ button { .toolbar-session-text { color: var(--chat-toolbar-session-text-color); - font-family: Roboto; - font-size: 12px; + font-family: 'Google Sans', sans-serif; + font-size: 13px; font-style: normal; font-weight: 500; - line-height: 12px; - letter-spacing: 0.8px; text-transform: uppercase; - margin-left: 20px; - padding-top: 4px; flex-shrink: 0; } .toolbar-session-id { color: var(--chat-toolbar-session-id-color); font-family: 'Google Sans Mono', monospace; - font-size: 14px; - font-style: normal; - font-weight: 400; - line-height: 20px; - letter-spacing: 0.25px; + font-size: 13px; margin-left: 5px; - flex-shrink: 0; } -.toolbar-icon { - width: 24px; +.toolbar-session-id-container { + display: flex; + align-items: center; + margin-left: 5px; + + .toolbar-session-id { + margin-left: 0; + } +} + +.toolbar-icon-button { + color: var(--chat-toolbar-icon-color); + background: transparent !important; + border: none !important; + box-shadow: none !important; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } +} + +// Smaller icon button for session/user id actions +.small-icon-button { + width: 28px !important; + height: 28px !important; + min-width: 28px !important; + min-height: 28px !important; + padding: 0 !important; +} + +.small-icon-button mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; +} + +.toolbar-user-id-container { + display: flex; + align-items: center; + margin-left: 5px; +} + +.toolbar-user-id-input { + width: 140px; height: 24px; + border: 1px solid var(--chat-toolbar-session-text-color); + border-radius: 4px; + color: var(--chat-toolbar-session-id-color); + padding: 0 6px; + font-family: 'Google Sans Mono', monospace; + font-size: 12px; +} + +.toolbar-user-id-input:focus { + outline: 1px solid var(--chat-toolbar-icon-color); +} + +.user-avatar-button { + margin-left: auto; + flex-shrink: 0; + + mat-icon { + font-size: 24px; + width: 24px; + height: 24px; + } +} + +.user-menu-panel { + padding: 16px; + min-width: 240px; +} + +.user-menu-header { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; +} + +.user-menu-avatar-icon { + font-size: 36px; + width: 36px; + height: 36px; color: var(--chat-toolbar-icon-color); - cursor: pointer; - margin-right: 16px; } -#toolbar-new-session-button { +.user-menu-label { font-size: 14px; - margin-right: 16px; - color: var(--chat-toolbar-new-session-color); - cursor: pointer; + font-weight: 500; + color: var(--chat-toolbar-session-text-color); + text-transform: uppercase; +} + +.user-menu-content { display: flex; align-items: center; + gap: 4px; +} + +.user-menu-id { + font-family: 'Google Sans Mono', monospace; + font-size: 14px; + color: var(--chat-toolbar-session-id-color); + word-break: break-all; } -.toolbar-sse-toggle { - @include mat.slide-toggle-overrides( - ( - label-text-size: 14px, - label-text-color: var(--chat-toolbar-sse-toggle-label-text-color), - unselected-track-color: - var(--chat-toolbar-sse-toggle-unselected-track-color), - unselected-focus-track-color: - var(--chat-toolbar-sse-toggle-unselected-track-color), - unselected-hover-track-color: - var(--chat-toolbar-sse-toggle-unselected-track-color), - unselected-handle-color: - var(--chat-toolbar-sse-toggle-unselected-handle-color), - unselected-focus-handle-color: - var(--chat-toolbar-sse-toggle-unselected-handle-color), - unselected-hover-handle-color: - var(--chat-toolbar-sse-toggle-unselected-handle-color), - selected-track-color: var(--chat-toolbar-sse-toggle-selected-track-color), - selected-focus-track-color: - var(--chat-toolbar-sse-toggle-selected-track-color), - selected-hover-track-color: - var(--chat-toolbar-sse-toggle-selected-track-color), - selected-handle-color: - var(--chat-toolbar-sse-toggle-selected-handle-color), - selected-focus-handle-color: - var(--chat-toolbar-sse-toggle-selected-handle-color), - selected-hover-handle-color: - var(--chat-toolbar-sse-toggle-selected-handle-color), - track-height: 24px, - track-width: 46px, - track-outline-color: var(--chat-toolbar-sse-toggle-track-outline-color), - with-icon-handle-size: 20px, - ) - ); +.user-menu-input { + flex: 1; + height: 28px; + border: 1px solid var(--chat-toolbar-session-text-color); + border-radius: 4px; + color: var(--chat-toolbar-session-id-color); + padding: 0 8px; + font-family: 'Google Sans Mono', monospace; + font-size: 13px; + background: transparent; + + &:focus { + outline: 1px solid var(--chat-toolbar-icon-color); + } } :host ::ng-deep pre { @@ -581,22 +504,21 @@ button { .readonly-badge { color: var(--chat-readonly-badge-color); - background-color: var(--chat-readonly-badge-background-color); border-radius: 4px; - padding: 1px 6px; + padding: 2px 8px; display: flex; align-items: center; margin-left: 8px; - font-size: 12px; - line-height: 16px; + font-family: 'Google Sans', sans-serif; + font-size: 13px; + line-height: 18px; gap: 4px; white-space: nowrap; mat-icon { - font-size: 14px; - width: 14px; - height: 14px; - padding-top: 1px; + font-size: 16px; + width: 16px; + height: 16px; flex-shrink: 0; } } @@ -604,21 +526,15 @@ button { .readonly-session-message { display: block; color: var(--chat-toolbar-session-text-color); + font-family: 'Google Sans', sans-serif; + font-size: 13px; margin-left: 1em; font-weight: 400; - line-height: 16px; + line-height: 18px; letter-spacing: 0.3px; flex-shrink: 1; } -::ng-deep .mat-drawer-content { - display: flex !important; -} - -::ng-deep .mat-drawer { - border-right: 1px solid var(--chat-mat-drawer-border-right-color) !important; -} - // Builder mode container and exit button .builder-mode-container { position: relative; @@ -626,20 +542,17 @@ button { height: 100vh; display: flex; flex-direction: column; - background-color: var(--builder-container-background-color); } .builder-exit-button { position: absolute; top: 20px; right: 20px; - z-index: 1000; display: flex; gap: 8px; } .builder-mode-action-button { - background-color: var(--builder-secondary-background-color) !important; color: var(--builder-text-tertiary-color) !important; border-radius: 50% !important; transition: all 0.2s ease !important; @@ -656,22 +569,14 @@ button { justify-content: center !important; &:hover { - background-color: var( - --builder-tool-item-hover-background-color - ) !important; box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15) !important; } &.active { - background-color: var(--builder-button-primary-background-color) !important; color: white !important; border-color: var(--builder-button-primary-background-color) !important; } - .mat-mdc-button-touch-target { - display: none !important; - } - mat-icon { font-size: 20px; width: 20px; @@ -693,7 +598,6 @@ app-canvas { display: flex; width: 100%; height: 100%; - background-color: var(--builder-container-background-color); } .build-left-panel, @@ -701,14 +605,314 @@ app-canvas { flex: 1; display: flex; flex-direction: column; - background-color: var(--builder-tertiary-background-color); border: 1px solid var(--builder-border-color); margin: 10px; border-radius: 8px; } +// Shared Selector Styles +.selector-group { + display: flex; + align-items: center; + border-radius: 6px; + border: 1px solid var(--mat-sys-outline-variant, #c4c7c5); + margin-right: 8px; + flex-shrink: 0; + height: 32px; + overflow: hidden; + + .toolbar-icon-button { + width: 32px; + height: 32px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + + ::ng-deep .mdc-icon-button__ripple { + border-radius: 4px; + top: 1px; + left: 1px; + right: 1px; + bottom: 1px; + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + } + } +} + +.selector-group-divider { + width: 1px; + height: 16px; + background-color: var(--mat-sys-outline-variant, #c4c7c5); + flex-shrink: 0; +} + +.selector-button { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 12px; + margin-right: 1px; + border-radius: 6px; + border: none; + background: transparent; + cursor: pointer; + color: var(--chat-toolbar-icon-color); + font-family: 'Google Sans', sans-serif; + font-size: 13px; + font-weight: 500; + height: 100%; + flex-shrink: 0; + white-space: nowrap; + width: 220px; + overflow: hidden; + transition: background-color 0.15s ease; + position: relative; + z-index: 0; + + &::before { + content: ''; + position: absolute; + top: 1px; + left: 1px; + right: 1px; + bottom: 1px; + border-radius: 4px; + background-color: var(--mat-icon-button-state-layer-color, var(--mat-sys-on-surface-variant)); + opacity: 0; + pointer-events: none; + z-index: -1; + transition: opacity 0.15s ease; + } + + &:hover::before { + opacity: var(--mat-icon-button-hover-state-layer-opacity, var(--mat-sys-hover-state-layer-opacity)); + } + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + } +} + +.selector-label { + overflow: hidden; + text-overflow: ellipsis; + flex: 1; + text-align: left; +} + +.selector-caret { + font-size: 18px; + width: 18px; + height: 18px; + flex-shrink: 0; + margin-left: auto; + opacity: 0.7; +} + +.selector-drawer-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 8px 8px 20px; + height: 48px; + flex-shrink: 0; +} + +.selector-drawer-title { + font-size: 16px; + font-weight: 500; + font-family: 'Google Sans', sans-serif; +} + +// Shared selector drawer (mat-drawer with mode=over) +.selector-drawer { + width: 320px; + background-color: var(--mat-sys-surface, #fff); + + ::ng-deep .mat-drawer-inner-container { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + } + + &.match-side-panel-width { + width: var(--side-drawer-width); + } +} + + +.app-selector-search { + padding: 0 12px 4px; + flex-shrink: 0; +} + +.app-selector-search-field { + width: 100%; + font-size: 13px; + + .mat-mdc-form-field-infix { + min-height: 36px; + padding-top: 6px !important; + padding-bottom: 6px !important; + } + + mat-icon { + color: var(--chat-toolbar-session-text-color); + font-size: 18px; + width: 18px; + height: 18px; + } +} + +.app-selector-list { + flex: 1; + overflow-y: auto; + padding: 0 8px; +} + +.app-selector-item { + display: flex; + align-items: center; + gap: 12px; + width: 100%; + padding: 10px 12px; + border: none; + background: transparent; + cursor: pointer; + border-radius: 8px; + font-family: 'Google Sans Mono', monospace; + font-size: 13px; + color: var(--chat-toolbar-icon-color); + text-align: left; + transition: background-color 0.15s ease; + + &:hover { + background-color: var(--mat-sys-surface-variant, rgba(0, 0, 0, 0.04)); + } + + &.selected { + background-color: var(--mat-sys-secondary-container, #e8def8); + font-weight: 500; + } +} + +.app-selector-item-icon { + font-size: 20px; + width: 20px; + height: 20px; + flex-shrink: 0; + color: var(--chat-toolbar-session-text-color); +} + +.app-selector-check { + margin-left: auto; + font-size: 18px; + width: 18px; + height: 18px; +} + +.app-selector-item-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.app-selector-loading { + display: flex; + justify-content: center; + padding: 24px; +} + +.app-selector-empty { + text-align: center; + padding: 24px; + color: var(--chat-toolbar-session-text-color); + font-style: italic; +} + + +.session-selector-current-id { + padding: 8px 20px 8px 20px; + border-bottom: 1px solid var(--mat-sys-outline-variant, #c4c7c5); +} + +.session-selector-current-id-label { + font-size: 11px; + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + color: var(--mat-sys-on-surface-variant, #444746); +} + +.session-selector-current-id-row { + display: flex; + align-items: center; + gap: 4px; +} + +.session-selector-current-id-value { + font-size: 14px; + font-style: normal; + font-weight: 500; + line-height: 20px; + letter-spacing: 0.25px; + font-family: 'Google Sans', sans-serif; + color: var(--mat-sys-on-surface, #1d1b20); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 0 1 auto; + min-width: 0; +} + +.session-selector-current-real-id-row { + display: flex; + align-items: center; + gap: 4px; +} + +.session-selector-current-real-id-value { + font-size: 11px; + font-family: 'Google Sans Mono', monospace; + color: var(--chat-toolbar-session-id-color); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 0 1 auto; + min-width: 0; + opacity: 0.7; +} + +.session-selector-action-button { + flex-shrink: 0; + width: 28px !important; + height: 28px !important; + padding: 0 !important; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } +} + +.session-selector-drawer-content { + flex: 1; + overflow-y: auto; +} + .build-panel-header { - background-color: var(--builder-secondary-background-color); padding: 16px 20px; border-bottom: 1px solid var(--builder-border-color); border-radius: 8px 8px 0 0; @@ -750,3 +954,42 @@ app-canvas { font-size: 10px; color: var(--adk-web-text-color-light-gray); } + +.menu-check-icon.inactive { + visibility: hidden; +} + +/* Responsive Toolbar Area */ + +.logo-narrow { + display: none; +} + +@media (max-width: 900px) { + .logo-wide { + display: none; + } + .logo-narrow { + display: inline; + } +} + +@media (max-width: 750px) { + .toolbar-agent-group .selector-button { + width: auto; + padding: 4px 8px; + } + .toolbar-agent-group .selector-label { + display: none; + } +} + +@media (max-width: 600px) { + .toolbar-session-group .selector-button { + width: auto; + padding: 4px 8px; + } + .toolbar-session-group .selector-label { + display: none; + } +} \ No newline at end of file diff --git a/src/app/components/chat/chat.component.spec.ts b/src/app/components/chat/chat.component.spec.ts index 059c0362..c6458093 100644 --- a/src/app/components/chat/chat.component.spec.ts +++ b/src/app/components/chat/chat.component.spec.ts @@ -29,9 +29,12 @@ import {BehaviorSubject, NEVER, of, ReplaySubject, Subject, throwError} from 'rx import {EvalCase} from '../../core/models/Eval'; import {Session} from '../../core/models/Session'; +import {UiEvent} from '../../core/models/UiEvent'; import {AGENT_SERVICE, AgentService} from '../../core/services/interfaces/agent'; import {AGENT_BUILDER_SERVICE} from '../../core/services/interfaces/agent-builder'; import {ARTIFACT_SERVICE, ArtifactService,} from '../../core/services/interfaces/artifact'; +import {AUDIO_PLAYING_SERVICE} from '../../core/services/interfaces/audio-playing'; +import {AUDIO_RECORDING_SERVICE} from '../../core/services/interfaces/audio-recording'; import {DOWNLOAD_SERVICE, DownloadService,} from '../../core/services/interfaces/download'; import {EVAL_SERVICE, EvalService} from '../../core/services/interfaces/eval'; import {EVENT_SERVICE, EventService} from '../../core/services/interfaces/event'; @@ -49,6 +52,8 @@ import {WEBSOCKET_SERVICE, WebSocketService,} from '../../core/services/interfac import {LOCATION_SERVICE} from '../../core/services/location.service'; import {MockAgentService} from '../../core/services/testing/mock-agent.service'; import {MockArtifactService} from '../../core/services/testing/mock-artifact.service'; +import {MockAudioPlayingService} from '../../core/services/testing/mock-audio-playing.service'; +import {MockAudioRecordingService} from '../../core/services/testing/mock-audio-recording.service'; import {MockDownloadService} from '../../core/services/testing/mock-download.service'; import {MockEvalService} from '../../core/services/testing/mock-eval.service'; import {MockEventService} from '../../core/services/testing/mock-event.service'; @@ -69,12 +74,14 @@ import {EVAL_TAB_COMPONENT, EvalTabComponent,} from '../eval-tab/eval-tab.compon import {MARKDOWN_COMPONENT} from '../markdown/markdown.component.interface'; import {MockMarkdownComponent} from '../markdown/testing/mock-markdown.component'; import {SidePanelComponent} from '../side-panel/side-panel.component'; +import {THEME_SERVICE} from '../../core/services/interfaces/theme'; +import {MockThemeService} from '../../core/services/testing/mock-theme.service'; -import {ChatComponent, HIDE_SIDE_PANEL_QUERY_PARAM, INITIAL_USER_INPUT_QUERY_PARAM, LANDING_PAGE_CONTENT_QUERY_PARAM,} from './chat.component'; +import {ChatComponent, HIDE_SIDE_PANEL_QUERY_PARAM, INITIAL_USER_INPUT_QUERY_PARAM} from './chat.component'; // Mock EvalTabComponent to satisfy the required viewChild in ChatComponent @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-eval-tab', template: '', standalone: true, @@ -87,7 +94,7 @@ class MockEvalTabComponent { } @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'test-host-component', template: `
@@ -184,6 +191,10 @@ describe('ChatComponent', () => { false); mockDialog = jasmine.createSpyObj('MatDialog', ['open']); + mockDialog.open.and.returnValue({ + afterClosed: () => of(false), + close: () => {}, + } as any); mockSnackBar = jasmine.createSpyObj('MatSnackBar', ['open']); mockRouter = jasmine.createSpyObj( 'Router', @@ -236,6 +247,8 @@ describe('ChatComponent', () => { {provide: EVAL_TAB_COMPONENT, useValue: EvalTabComponent}, {provide: SESSION_SERVICE, useValue: mockSessionService}, {provide: ARTIFACT_SERVICE, useValue: mockArtifactService}, + {provide: AUDIO_PLAYING_SERVICE, useClass: MockAudioPlayingService}, + {provide: AUDIO_RECORDING_SERVICE, useClass: MockAudioRecordingService}, {provide: WEBSOCKET_SERVICE, useValue: mockWebSocketService}, {provide: VIDEO_SERVICE, useValue: mockVideoService}, {provide: EVENT_SERVICE, useValue: mockEventService}, @@ -261,6 +274,7 @@ describe('ChatComponent', () => { {provide: UI_STATE_SERVICE, useValue: mockUiStateService}, {provide: ErrorHandler, useValue: mockErrorHandler}, {provide: AGENT_BUILDER_SERVICE, useValue: mockAgentBuilderService}, + {provide: THEME_SERVICE, useClass: MockThemeService}, ], }) .compileComponents(); @@ -403,7 +417,7 @@ describe('ChatComponent', () => { ]; beforeEach(async () => { - component.messages.set([]); + component.uiEvents.set([]); component.eventData = new Map(); mockUiStateService.newMessagesLoadedResponse.next({ items: events, @@ -412,17 +426,17 @@ describe('ChatComponent', () => { }); it('should add messages to the chat', () => { - const messages = component.messages(); - expect(messages.length).toBe(2); - expect(messages[0].text).toBe('user message'); - expect(messages[1].text).toBe('bot response'); + const messages = component.uiEvents(); + expect(component.uiEvents().length).toBe(2); + expect(component.uiEvents()[0].text).toBe('user message'); + expect(component.uiEvents()[1].text).toBe('bot response'); }); it( 'should not clear existing messages or events when new messages are loaded', fakeAsync(() => { - component.messages.set([ - {role: 'user', text: 'existing message'}, + component.uiEvents.set([ + new UiEvent({role: 'user', text: 'existing message', event: {} as any}), ]); component.eventData.set('event-old', {id: 'event-old'} as any); mockUiStateService.newMessagesLoadedResponse.next({ @@ -430,19 +444,19 @@ describe('ChatComponent', () => { nextPageToken: '', }); tick(); - const messages = component.messages(); - expect(messages.length).toBe(3); - expect(messages[0].text).toBe('user message'); - expect(messages[1].text).toBe('bot response'); - expect(messages[2].text).toBe('existing message'); + const messages = component.uiEvents(); + expect(component.uiEvents().length).toBe(3); + expect(component.uiEvents()[0].text).toBe('user message'); + expect(component.uiEvents()[1].text).toBe('bot response'); + expect(component.uiEvents()[2].text).toBe('existing message'); expect(component.eventData.has('event-old')).toBeTrue(); })); it( 'should clear existing messages and events when new messages are loaded for a different session', fakeAsync(() => { - component.messages.set([ - {role: 'user', text: 'existing message'}, + component.uiEvents.set([ + new UiEvent({role: 'user', text: 'existing message', event: {} as any}), ]); component.eventData.set('event-old', {id: 'event-old'} as any); component.sessionId = 'session-2'; // change session @@ -451,10 +465,10 @@ describe('ChatComponent', () => { nextPageToken: '', }); tick(); - const messages = component.messages(); - expect(messages.length).toBe(2); - expect(messages[0].text).toBe('user message'); - expect(messages[1].text).toBe('bot response'); + const messages = component.uiEvents(); + expect(component.uiEvents().length).toBe(2); + expect(component.uiEvents()[0].text).toBe('user message'); + expect(component.uiEvents()[1].text).toBe('bot response'); expect(component.eventData.has('event-old')).toBeFalse(); })); @@ -499,73 +513,18 @@ describe('ChatComponent', () => { } as any); fixture.detectChanges(); - const messages = component.messages(); - expect(messages.length).toBe(1); - expect(messages[0].a2uiData).toEqual({ + const messages = component.uiEvents(); + expect(component.uiEvents().length).toBe(1); + expect(component.uiEvents()[0].a2uiData).toEqual({ beginRendering: {beginRendering: {id: '1'}}, surfaceUpdate: {surfaceUpdate: {components: []}} }); }); }); }); - - it( - 'should display landing page content from "landing" query param', - fakeAsync(() => { - const markdownContent = - '# Welcome to the App\n\nThis is the landing page.'; - const encodedContent = encodeURIComponent(markdownContent); - const queryParams = { - [LANDING_PAGE_CONTENT_QUERY_PARAM]: encodedContent, - }; - mockActivatedRoute.snapshot!.queryParams = queryParams; - mockActivatedRoute.queryParams = of(queryParams); - - // Mock session service to return a new session - mockSessionService.createSessionResponse.next( - {id: SESSION_1_ID, state: {}, events: []}); - - fixture = TestBed.createComponent(ChatComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - tick(); // Allow component to stabilize and load session - - // Manually call displayLandingPageContent to simulate the effect - (component as any).displayLandingPageContent(); - tick(); - - const messages = component.messages(); - expect(messages.length).toBe(1); - expect(messages[0].role).toBe('bot'); - expect(messages[0].text).toBe(markdownContent); - expect(messages[0].isLanding).toBeTrue(); - })); }); describe('Session Management', () => { - describe('when session not in url', () => { - beforeEach(() => { - mockAgentService.listAppsResponse.next( - [TEST_APP_1_NAME, TEST_APP_2_NAME]); - - mockActivatedRoute.snapshot!.queryParams = { - [APP_QUERY_PARAM]: TEST_APP_2_NAME, - }; - mockActivatedRoute.queryParams = of({ - [APP_QUERY_PARAM]: TEST_APP_2_NAME, - }); - component.ngOnInit(); - }); - it('should create new session on init', () => { - expect(mockSessionService.createSession) - .toHaveBeenCalledWith( - USER_ID, - TEST_APP_2_NAME, - ); - expect(component.sessionId).toBe(SESSION_1_ID); - }); - }); - describe('when session ID is provided in URL', () => { beforeEach(() => { mockAgentService.listAppsResponse.next([TEST_APP_1_NAME]); @@ -597,194 +556,6 @@ describe('ChatComponent', () => { expect(component.sessionId).toBe(SESSION_2_ID); }); }); - - describe('on app change', () => { - beforeEach(async () => { - fixture = TestBed.createComponent(ChatComponent); - component = fixture.componentInstance; - component.ngOnInit(); - fixture.detectChanges(); - component.selectApp(TEST_APP_2_NAME); - await fixture.whenStable(); - }); - it('should load session from URL', () => { - expect(mockSessionService.getSession) - .toHaveBeenCalledWith( - USER_ID, - TEST_APP_2_NAME, - SESSION_2_ID, - ); - expect(component.sessionId).toBe(SESSION_2_ID); - }); - }); - }); - - describe('when session in URL is not found', () => { - beforeEach(async () => { - mockActivatedRoute.snapshot!.queryParams = { - [APP_QUERY_PARAM]: TEST_APP_1_NAME, - [SESSION_QUERY_PARAM]: SESSION_2_ID, - }; - mockSessionService.getSession.and.callFake( - (userId: string, app: string, sessionId: string) => { - if (sessionId === SESSION_2_ID) { - return throwError(() => new HttpErrorResponse({status: 404})); - } - return of({id: SESSION_1_ID, state: {}, events: []}); - }, - ); - fixture = TestBed.createComponent(ChatComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - component.selectApp(TEST_APP_2_NAME); - await fixture.whenStable(); - }); - - it('should try load the session', () => { - expect(mockSessionService.getSession) - .toHaveBeenCalledWith( - USER_ID, - TEST_APP_2_NAME, - SESSION_2_ID, - ); - }); - - - it('should show snackbar', () => { - expect(mockSnackBar.open) - .toHaveBeenCalledWith( - 'Cannot find specified session. Creating a new one.', - OK_BUTTON_TEXT, - ); - }); - - it('should create new session', () => { - expect(mockSessionService.createSession) - .toHaveBeenCalledWith( - USER_ID, - TEST_APP_2_NAME, - ); - }); - - it('should load the new session', () => { - expect(mockSessionService.getSession) - .toHaveBeenCalledWith( - USER_ID, - TEST_APP_1_NAME, - SESSION_1_ID, - ); - }); - }); - - describe('when app selection changes and session URL is disabled', () => { - beforeEach(async () => { - mockFeatureFlagService.isSessionUrlEnabledResponse.next(false); - fixture = TestBed.createComponent(ChatComponent); - component = fixture.componentInstance; - fixture.detectChanges(); - component.selectApp(ANOTHER_APP_NAME); - await fixture.whenStable(); - }); - it('should create new session', () => { - expect(mockAgentService.setApp).toHaveBeenCalledWith(ANOTHER_APP_NAME); - expect(mockSessionService.createSession).toHaveBeenCalled(); - }); - }); - - describe('when onNewSessionClick() is called', () => { - beforeEach(() => { - mockSessionService.createSessionResponse = - new ReplaySubject(1); - mockSessionService.createSession.and.returnValue( - mockSessionService.createSessionResponse); - - component.messages.set([{role: USER_ID, text: 'hello'}]); - component.artifacts = [{}]; - component.eventData = new Map([['1', {}]]); - component.traceData = [{}]; - component.onNewSessionClick(); - }); - - it('should create new session', () => { - expect(mockSessionService.createSession).toHaveBeenCalled(); - }); - - it('should display session list spinner', () => { - expect(mockUiStateService.setIsSessionListLoading) - .toHaveBeenCalledWith(true); - }); - - describe('when session is created', () => { - beforeEach(() => { - mockSessionService.createSessionResponse.next( - {id: SESSION_2_ID, state: {}, events: []}); - }); - - it('should clear data', () => { - expect(component.messages().length).toBe(0); - expect(component.artifacts.length).toBe(0); - expect(component.eventData.size).toBe(0); - expect(component.traceData.length).toBe(0); - }); - - it( - 'should not hide session list spinner because the session list is still being loaded', - () => { - expect(mockUiStateService.setIsSessionListLoading) - .toHaveBeenCalledWith(true); - }); - }); - - describe('when session is created with error', () => { - beforeEach(() => { - mockSessionService.createSessionResponse.error( - throwError(() => new HttpErrorResponse({status: 500}))); - component.onNewSessionClick(); - }); - it('should hide session list spinner', () => { - expect(mockUiStateService.setIsSessionListLoading) - .toHaveBeenCalledWith(false); - }); - }); - }); - - describe('when deleting a session', () => { - describe('and dialog is confirmed', () => { - beforeEach(() => { - mockDialog.open.and.returnValue({ - afterClosed: () => of(true), - } as any); - const sessionTabSpy = jasmine.createSpyObj( - 'sessionTab', ['refreshSession', 'getSession']); - sessionTabSpy.refreshSession.and.returnValue( - {id: SESSION_2_ID} as any); - spyOnProperty(component, 'sessionTab', 'get') - .and.returnValue(sessionTabSpy); - component.deleteSession(SESSION_1_ID); - }); - it('should delete session', () => { - expect(mockDialog.open).toHaveBeenCalled(); - expect(mockSessionService.deleteSession) - .toHaveBeenCalledWith( - USER_ID, - TEST_APP_1_NAME, - SESSION_1_ID, - ); - }); - }); - - describe('and dialog is cancelled', () => { - beforeEach(() => { - mockDialog.open.and.returnValue({ - afterClosed: () => of(false), - } as any); - component.deleteSession(SESSION_1_ID); - }); - it('should not delete session', () => { - expect(mockDialog.open).toHaveBeenCalled(); - expect(mockSessionService.deleteSession).not.toHaveBeenCalled(); - }); - }); }); describe( @@ -830,16 +601,16 @@ describe('ChatComponent', () => { }); it('should populate messages from session events', () => { - expect(component.messages().length).toBe(3); - expect(component.messages()[0]).toEqual(jasmine.objectContaining({ + expect(component.uiEvents().length).toBe(3); + expect(component.uiEvents()[0]).toEqual(jasmine.objectContaining({ role: 'user', text: 'user message' })); - expect(component.messages()[1]).toEqual(jasmine.objectContaining({ + expect(component.uiEvents()[1]).toEqual(jasmine.objectContaining({ role: 'bot', text: 'bot response' })); - expect(component.messages()[2]).toEqual(jasmine.objectContaining({ + expect(component.uiEvents()[2]).toEqual(jasmine.objectContaining({ role: 'bot', inlineData: jasmine.objectContaining({ data: 'data:application/pdf;base64,base64data==', @@ -959,91 +730,6 @@ describe('ChatComponent', () => { expect(deleteButton).toBeFalsy(); }); }); - - describe('when isDeleteSessionEnabled is true', () => { - beforeEach(() => { - mockFeatureFlagService.isDeleteSessionEnabledResponse.next(true); - fixture.detectChanges(); - }); - - it('should be visible', () => { - const deleteButton = fixture.debugElement.query( - By.css('#toolbar-delete-session-button')); - expect(deleteButton).toBeTruthy(); - }); - }); - }); - - describe('when clickEvent() is called', () => { - beforeEach(() => { - component.sessionId = SESSION_1_ID; - component.messages.set( - [{role: 'bot', text: 'response', eventId: EVENT_1_ID}]); - spyOn(component.sideDrawer()!, 'open'); - }); - - it('should open side panel with event details', () => { - component.eventData = new Map([[EVENT_1_ID, {id: EVENT_1_ID}]]); - component.clickEvent(0); - expect(component.sideDrawer()!.open).toHaveBeenCalled(); - expect(component.selectedEvent.id).toBe(EVENT_1_ID); - expect(mockEventService.getEventTrace).toHaveBeenCalledWith({ - id: EVENT_1_ID - }); - expect(mockEventService.getEvent) - .toHaveBeenCalledWith( - USER_ID, - TEST_APP_1_NAME, - SESSION_1_ID, - EVENT_1_ID, - ); - }); - - it( - 'should call getEventTrace with filter and parse llm request/response', - () => { - const invocationId = 'inv-1'; - const timestamp = 123456789; - component.eventData = new Map([[ - EVENT_1_ID, { - id: EVENT_1_ID, - invocationId, - timestampInMillis: timestamp, - } - ]]); - const llmRequest = {prompt: 'test prompt'}; - const llmResponse = {response: 'test response'}; - mockEventService.getEventTraceResponse.next({ - 'gcp.vertex.agent.llm_request': JSON.stringify(llmRequest), - 'gcp.vertex.agent.llm_response': JSON.stringify(llmResponse), - }); - - component.clickEvent(0); - - expect(mockEventService.getEventTrace).toHaveBeenCalledWith({ - id: EVENT_1_ID, - invocationId, - timestamp, - }); - expect(component.llmRequest).toEqual(llmRequest); - expect(component.llmResponse).toEqual(llmResponse); - }); - }); - - describe('when updateState() is called', () => { - const newState = {[STATE_KEY]: STATE_VALUE}; - beforeEach(() => { - mockDialog.open.and.returnValue({ - afterClosed: () => of(newState), - } as any); - component.updateState(); - }); - it('should open dialog', () => { - expect(mockDialog.open).toHaveBeenCalled(); - }); - it('should update session state', () => { - expect(component.updatedSessionState()).toEqual(newState); - }); }); describe('when removeStateUpdate() is called', () => { @@ -1057,27 +743,6 @@ describe('ChatComponent', () => { }); }); - describe('Bi-directional Streaming', () => { - beforeEach(() => { - mockAgentService.listAppsResponse.next( - [TEST_APP_1_NAME, TEST_APP_2_NAME]); - }); - - describe('when bidi streaming is restarted', () => { - beforeEach(() => { - component.sessionHasUsedBidi.add(component.sessionId); - component.startAudioRecording(); - }); - it('should show snackbar', () => { - expect(mockSnackBar.open) - .toHaveBeenCalledWith( - 'Restarting bidirectional streaming is not currently supported. Please refresh the page or start a new session.', - OK_BUTTON_TEXT, - ); - }); - }); - }); - describe('ChatPanel integration', () => { beforeEach(() => { mockAgentService.listAppsResponse.next( @@ -1094,7 +759,7 @@ describe('ChatComponent', () => { describe('Message Passing', () => { beforeEach(async () => { - component.messages.set([{role: 'user', text: TEST_MESSAGE}]); + component.uiEvents.set([new UiEvent({role: 'user', text: TEST_MESSAGE, event: {} as any})]); fixture.detectChanges(); await fixture.whenStable(); fixture.detectChanges(); @@ -1103,174 +768,18 @@ describe('ChatComponent', () => { const chatPanelComponent = fixture.debugElement.query(By.directive(ChatPanelComponent)) .componentInstance; - expect(chatPanelComponent.messages).toEqual(component.messages()); + expect(chatPanelComponent.uiEvents).toEqual(component.uiEvents()); const messageCards = fixture.debugElement.queryAll( - By.css('app-chat-panel .message-card')); + By.css('app-chat-panel .content-bubble')); expect(messageCards.length).toBe(1); expect(messageCards[0].nativeElement.textContent) .toContain(TEST_MESSAGE); }); - describe('Query Param Handling', () => { - let urlTree: UrlTree; - - beforeEach(() => { - urlTree = new UrlTree(); - fixture = TestBed.createComponent(ChatComponent); - component = fixture.componentInstance; - component.userInput = 'hello'; - }); - - it( - 'should clear "q" param on send', fakeAsync(() => { - urlTree.queryParams = {[INITIAL_USER_INPUT_QUERY_PARAM]: 'hello'}; - mockRouter.parseUrl.and.returnValue(urlTree as any); - mockLocation.path.and.returnValue('/?q=hello'); - - component.sendMessage( - new KeyboardEvent('keydown', {key: 'Enter'})); - tick(); - - expect(mockLocation.path).toHaveBeenCalled(); - expect(mockRouter.parseUrl).toHaveBeenCalledWith('/?q=hello'); - // The query param should be removed from the URL. - expect(mockLocation.replaceState).toHaveBeenCalledWith('/'); - })); - - it( - 'should not update URL if "q" param is missing', fakeAsync(() => { - urlTree.queryParams = {}; - mockRouter.parseUrl.and.returnValue(urlTree as any); - mockLocation.path.and.returnValue('/?'); - - component.sendMessage( - new KeyboardEvent('keydown', {key: 'Enter'})); - tick(); - - expect(mockLocation.path).toHaveBeenCalled(); - expect(mockRouter.parseUrl).toHaveBeenCalledWith('/?'); - // The query param should be removed from the URL. - expect(mockLocation.replaceState).not.toHaveBeenCalled(); - })); - }); - - describe('when event is an A2A response', () => { - it( - 'should combine all A2UI data parts into a single message', - async () => { - const createA2uiPart = (content: any) => { - const json = JSON.stringify({ - kind: 'data', - metadata: {mimeType: A2UI_MIME_TYPE}, - data: content - }); - return { - inlineData: { - mimeType: 'text/plain', - data: btoa(`${A2A_DATA_PART_TAG_START}${json}${ - A2A_DATA_PART_TAG_END}`) - } - }; - }; - - const sseEvent = { - id: 'event-1', - author: 'bot', - customMetadata: {'a2a:response': 'true'}, - content: { - role: 'bot', - parts: [ - {text: 'Prefix'}, - createA2uiPart({beginRendering: {id: '1'}}), - {text: 'Interim'}, - createA2uiPart({surfaceUpdate: {components: []}}), - {text: 'Suffix'} - ] - }, - }; - - component.messages.set([]); - component.userInput = 'test message'; - await component.sendMessage( - new KeyboardEvent('keydown', {key: 'Enter'})); - mockAgentService.runSseResponse.next(sseEvent); - fixture.detectChanges(); - - const botMessages = - component.messages().filter(m => m.role === 'bot'); - // Expectation: Prefix, Combined A2UI (at first A2UI pos), - // Interim, Suffix - expect(botMessages.length).toBe(4); - expect(botMessages[0].text).toBe('Prefix'); - // The combined A2UI message - expect(botMessages[1].a2uiData).toEqual({ - beginRendering: {beginRendering: {id: '1'}}, - surfaceUpdate: {surfaceUpdate: {components: []}} - }); - expect(botMessages[2].text).toBe('Interim'); - expect(botMessages[3].text).toBe('Suffix'); - }); - }); - - - describe('when event contains multiple text parts', () => { - it( - 'should combine consecutive text parts into a single message', - async () => { - const sseEvent = { - id: 'event-1', - author: 'bot', - content: - {role: 'bot', parts: [{text: 'Hello '}, {text: 'World!'}]}, - }; - component.messages.set([]); - component.userInput = 'test message'; - await component.sendMessage( - new KeyboardEvent('keydown', {key: 'Enter'})); - mockAgentService.runSseResponse.next(sseEvent); - fixture.detectChanges(); - - const botMessages = - component.messages().filter(m => m.role === 'bot'); - expect(botMessages.length).toBe(1); - expect(botMessages[0].text).toBe('Hello World!'); - }); - - it( - 'should not combine non-consecutive text parts', async () => { - const sseEvent = { - id: 'event-1', - author: 'bot', - content: { - role: 'bot', - parts: [ - {text: 'Hello '}, - {functionCall: {name: 'foo', args: {}}}, - {text: 'World!'}, - ] - }, - }; - component.messages.set([]); - component.userInput = 'test message'; - await component.sendMessage( - new KeyboardEvent('keydown', {key: 'Enter'})); - mockAgentService.runSseResponse.next(sseEvent); - fixture.detectChanges(); - - const botMessages = - component.messages().filter(m => m.role === 'bot'); - expect(botMessages.length).toBe(2); - expect(botMessages[0].text).toBe('Hello '); - expect(botMessages[0].functionCalls) - .toEqual([{name: 'foo', args: {}}]); - expect(botMessages[1].text).toBe('World!'); - }); - }); - describe('when getTrace fails in sendMessage', () => { beforeEach(async () => { mockEventService.getTraceResponse.error(new Error('trace error')); - component.messages.set([]); + component.uiEvents.set([]); component.userInput = 'test message'; await component.sendMessage( new KeyboardEvent('keydown', {key: 'Enter'})); @@ -1291,15 +800,15 @@ describe('ChatComponent', () => { describe('when chat-panel emits sendMessage', () => { const mockEvent = new KeyboardEvent('keydown', {key: 'Enter'}); beforeEach(() => { - spyOn(component, 'sendMessage').and.callThrough(); + spyOn(component, 'handleChatInput').and.callThrough(); mockAgentService.runSseResponse.next( - {content: {role: 'bot', parts: []}}); + {id: 'test-id', content: {role: 'bot', parts: []}} as any); const chatPanelDebugEl = fixture.debugElement.query(By.directive(ChatPanelComponent)); chatPanelDebugEl.triggerEventHandler('sendMessage', mockEvent); }); it('should call sendMessage', () => { - expect(component.sendMessage).toHaveBeenCalledWith(mockEvent); + expect(component.handleChatInput).toHaveBeenCalledWith(mockEvent); }); }); @@ -1382,19 +891,21 @@ describe('ChatComponent', () => { finalResponse: {parts: [{text: BOT_RESPONSE}]}, }], }; - const mockMessage = { + const mockMessage: any = new UiEvent({ + role: 'bot', text: BOT_RESPONSE, isEditing: false, invocationIndex: 0, - finalResponsePartIndex: 0 - }; + finalResponsePartIndex: 0, + event: {} as any + }); beforeEach(() => { mockAgentService.listAppsResponse.next( [TEST_APP_1_NAME, TEST_APP_2_NAME]); component.evalCase = mockEvalCase; - component.messages.set([mockMessage]); + component.uiEvents.set([mockMessage]); fixture.detectChanges(); }); @@ -1408,7 +919,7 @@ describe('ChatComponent', () => { }); describe('when editEvalCaseMessage() is called', () => { - const message = {role: 'user', text: 'hello', isEditing: false}; + const message = new UiEvent({role: 'user', text: 'hello', isEditing: false, event: {} as any}); let mockTextarea: any; beforeEach(() => { @@ -1426,7 +937,7 @@ describe('ChatComponent', () => { component['editEvalCaseMessage'](message); expect(component.isEvalCaseEditing()).toBe(true); - expect(component.userEditEvalCaseMessage).toBe(message.text); + expect(component.userEditEvalCaseMessage).toBe(message.text!); expect(message.isEditing).toBe(true); }); @@ -1436,7 +947,7 @@ describe('ChatComponent', () => { tick(); expect(mockTextarea.setSelectionRange) .toHaveBeenCalledWith( - message.text.length, message.text.length); + message.text!.length, message.text!.length); })); it('should focus textarea ', fakeAsync(() => { @@ -1448,7 +959,7 @@ describe('ChatComponent', () => { }); describe('when editEvalCaseMessage() is called with newline at end', () => { - const message = {role: 'user', text: 'hello\n', isEditing: false}; + const message = new UiEvent({role: 'user', text: 'hello\n', isEditing: false, event: {} as any}); let mockTextarea: any; beforeEach(() => { @@ -1468,7 +979,7 @@ describe('ChatComponent', () => { tick(); expect(mockTextarea.setSelectionRange) .toHaveBeenCalledWith( - message.text.length - 1, message.text.length - 1); + message.text!.length - 1, message.text!.length - 1); })); it('should focus textarea', fakeAsync(() => { @@ -1516,7 +1027,7 @@ describe('ChatComponent', () => { (component as any).deleteEvalCaseMessage(mockMessage, 0); }); it('should delete message', () => { - expect(component.messages().length).toBe(0); + expect(component.uiEvents().length).toBe(0); expect(component.hasEvalCaseChanged()).toBe(true); expect(component.updatedEvalCase!.conversation[0] .finalResponse!.parts!.length) @@ -1549,21 +1060,6 @@ describe('ChatComponent', () => { }); }); - describe('Feature Disabling', () => { - describe('when token streaming is disabled', () => { - beforeEach(() => { - mockFeatureFlagService.isTokenStreamingEnabledResponse.next(false); - fixture.detectChanges(); - }); - - it('should have the token streaming toggle disabled', () => { - const slideToggle = - fixture.debugElement.query(By.css('mat-slide-toggle')); - expect(slideToggle.componentInstance.disabled).toBe(true); - }); - }); - }); - describe('Artifacts', () => { it( 'should only fetch artifact version once for the same artifactId and versionId', diff --git a/src/app/components/chat/chat.component.ts b/src/app/components/chat/chat.component.ts index 2a95cff3..2e1549f9 100644 --- a/src/app/components/chat/chat.component.ts +++ b/src/app/components/chat/chat.component.ts @@ -15,72 +15,81 @@ * limitations under the License. */ -import {AsyncPipe, DOCUMENT, NgClass} from '@angular/common'; -import {HttpErrorResponse} from '@angular/common/http'; -import {AfterViewInit, ChangeDetectionStrategy, 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 {MatCard} from '@angular/material/card'; -import {MatDialog} from '@angular/material/dialog'; -import {MatDivider} from '@angular/material/divider'; -import {MatIcon} from '@angular/material/icon'; -import {MatPaginatorIntl} from '@angular/material/paginator'; -import {MatDrawer, MatDrawerContainer} from '@angular/material/sidenav'; -import {MatSlideToggle} from '@angular/material/slide-toggle'; -import {MatSnackBar} from '@angular/material/snack-bar'; -import {MatTooltip} from '@angular/material/tooltip'; -import {SafeHtml} from '@angular/platform-browser'; -import {ActivatedRoute, NavigationEnd, Router} from '@angular/router'; -import {NgxJsonViewerModule} from 'ngx-json-viewer'; -import {BehaviorSubject, combineLatest, Observable, of} from 'rxjs'; -import {catchError, distinctUntilChanged, filter, first, map, shareReplay, switchMap, take, tap} from 'rxjs/operators'; - -import {URLUtil} from '../../../utils/url-util'; -import {AgentRunRequest} from '../../core/models/AgentRunRequest'; -import {EvalCase} from '../../core/models/Eval'; -import {Session, SessionState} from '../../core/models/Session'; -import {Event as AdkEvent, Part} from '../../core/models/types'; -import {AGENT_SERVICE} from '../../core/services/interfaces/agent'; -import {AGENT_BUILDER_SERVICE} from '../../core/services/interfaces/agent-builder'; -import {ARTIFACT_SERVICE} from '../../core/services/interfaces/artifact'; -import {DOWNLOAD_SERVICE} from '../../core/services/interfaces/download'; -import {EVAL_SERVICE} from '../../core/services/interfaces/eval'; -import {EVENT_SERVICE} from '../../core/services/interfaces/event'; -import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag'; -import {GRAPH_SERVICE} from '../../core/services/interfaces/graph'; -import {LOCAL_FILE_SERVICE} from '../../core/services/interfaces/localfile'; -import {SAFE_VALUES_SERVICE} from '../../core/services/interfaces/safevalues'; -import {SESSION_SERVICE} from '../../core/services/interfaces/session'; -import {STREAM_CHAT_SERVICE} from '../../core/services/interfaces/stream-chat'; -import {STRING_TO_COLOR_SERVICE} from '../../core/services/interfaces/string-to-color'; -import {TRACE_SERVICE} from '../../core/services/interfaces/trace'; -import {ListResponse} from '../../core/services/interfaces/types'; -import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state'; -import {LOCATION_SERVICE} from '../../core/services/location.service'; -import {ResizableBottomDirective} from '../../directives/resizable-bottom.directive'; -import {ResizableDrawerDirective} from '../../directives/resizable-drawer.directive'; -import {AddItemDialogComponent} from '../add-item-dialog/add-item-dialog.component'; -import {getMediaTypeFromMimetype, MediaType} from '../artifact-tab/artifact-tab.component'; -import {BuilderTabsComponent} from '../builder-tabs/builder-tabs.component'; -import {CanvasComponent} from '../canvas/canvas.component'; -import {ChatPanelComponent} from '../chat-panel/chat-panel.component'; -import {EditJsonDialogComponent} from '../edit-json-dialog/edit-json-dialog.component'; -import {EvalTabComponent} from '../eval-tab/eval-tab.component'; -import {DeleteSessionDialogComponent, DeleteSessionDialogData,} from '../session-tab/delete-session-dialog/delete-session-dialog.component'; -import {SidePanelComponent} from '../side-panel/side-panel.component'; -import {TraceEventComponent} from '../trace-tab/trace-event/trace-event.component'; -import {ViewImageDialogComponent} from '../view-image-dialog/view-image-dialog.component'; - -import {ChatMessagesInjectionToken} from './chat.component.i18n'; +import { AsyncPipe, DOCUMENT, NgClass, NgComponentOutlet } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { AfterViewInit, ChangeDetectionStrategy, ChangeDetectorRef, Component, computed, ElementRef, effect, HostListener, inject, Injectable, OnDestroy, OnInit, Renderer2, signal, Type, viewChild, WritableSignal } from '@angular/core'; +import { toSignal } from '@angular/core/rxjs-interop'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MatButton, MatIconButton, MatFabButton } from '@angular/material/button'; +import { MatCard } from '@angular/material/card'; +import { MatDialog } from '@angular/material/dialog'; +import { MatFormField } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { MatIcon } from '@angular/material/icon'; +import { MatMenuModule } from '@angular/material/menu'; +import { MatPaginatorIntl } from '@angular/material/paginator'; +import { MatDrawer, MatDrawerContainer } from '@angular/material/sidenav'; +import { MatProgressSpinner } from '@angular/material/progress-spinner'; +import { MatSnackBar } from '@angular/material/snack-bar'; +import { MatTooltip } from '@angular/material/tooltip'; +import { MatToolbar } from '@angular/material/toolbar'; +import { SafeHtml } from '@angular/platform-browser'; +import { ActivatedRoute, NavigationEnd, Router } from '@angular/router'; +import { NgxJsonViewerModule } from 'ngx-json-viewer'; +import { combineLatest, firstValueFrom, Observable, of } from 'rxjs'; +import { catchError, distinctUntilChanged, filter, first, map, shareReplay, startWith, switchMap, take, tap } from 'rxjs/operators'; + +import { URLUtil } from '../../../utils/url-util'; +import { AgentRunRequest } from '../../core/models/AgentRunRequest'; +import { EvalCase } from '../../core/models/Eval'; +import { Session, SessionState } from '../../core/models/Session'; +import { Event as AdkEvent, Part } from '../../core/models/types'; +import { UiEvent } from '../../core/models/UiEvent'; +import { AGENT_SERVICE } from '../../core/services/interfaces/agent'; +import { AGENT_BUILDER_SERVICE } from '../../core/services/interfaces/agent-builder'; +import { ARTIFACT_SERVICE } from '../../core/services/interfaces/artifact'; +import { DOWNLOAD_SERVICE } from '../../core/services/interfaces/download'; +import { EVAL_SERVICE } from '../../core/services/interfaces/eval'; +import { EVENT_SERVICE } from '../../core/services/interfaces/event'; +import { FEATURE_FLAG_SERVICE } from '../../core/services/interfaces/feature-flag'; +import { GRAPH_SERVICE } from '../../core/services/interfaces/graph'; +import { LOCAL_FILE_SERVICE } from '../../core/services/interfaces/localfile'; +import { SAFE_VALUES_SERVICE } from '../../core/services/interfaces/safevalues'; +import { SESSION_SERVICE } from '../../core/services/interfaces/session'; +import { STREAM_CHAT_SERVICE } from '../../core/services/interfaces/stream-chat'; +import { AUDIO_RECORDING_SERVICE } from '../../core/services/interfaces/audio-recording'; +import { AUDIO_PLAYING_SERVICE } from '../../core/services/interfaces/audio-playing'; +import { STRING_TO_COLOR_SERVICE } from '../../core/services/interfaces/string-to-color'; +import { TRACE_SERVICE } from '../../core/services/interfaces/trace'; +import { THEME_SERVICE } from '../../core/services/interfaces/theme'; +import { WEBSOCKET_SERVICE } from '../../core/services/interfaces/websocket'; +import { LOGO_COMPONENT } from '../../injection_tokens'; +import { ListResponse } from '../../core/services/interfaces/types'; +import { UI_STATE_SERVICE } from '../../core/services/interfaces/ui-state'; +import { LOCATION_SERVICE } from '../../core/services/location.service'; +import { ResizableDrawerDirective } from '../../directives/resizable-drawer.directive'; +import { AddItemDialogComponent } from '../add-item-dialog/add-item-dialog.component'; +import { AgentStructureGraphDialogComponent } from '../agent-structure-graph-dialog/agent-structure-graph-dialog'; +import { getMediaTypeFromMimetype, MediaType } from '../artifact-tab/artifact-tab.component'; +import { BuilderTabsComponent } from '../builder-tabs/builder-tabs.component'; +import { CanvasComponent } from '../canvas/canvas.component'; +import { ChatPanelComponent } from '../chat-panel/chat-panel.component'; +import { EditJsonDialogComponent } from '../edit-json-dialog/edit-json-dialog.component'; +import { EvalTabComponent } from '../eval-tab/eval-tab.component'; +import { DeleteSessionDialogComponent, DeleteSessionDialogData, } from '../session-tab/delete-session-dialog/delete-session-dialog.component'; +import { SessionTabComponent } from '../session-tab/session-tab.component'; +import { SidePanelComponent } from '../side-panel/side-panel.component'; +import { ViewImageDialogComponent } from '../view-image-dialog/view-image-dialog.component'; +import { InlineEditComponent } from '../inline-edit/inline-edit.component'; + +import { ChatMessagesInjectionToken } from './chat.component.i18n'; +import { SidePanelMessagesInjectionToken } from '../side-panel/side-panel.component.i18n'; const ROOT_AGENT = 'root_agent'; /** Query parameter for pre-filling user input. */ export const INITIAL_USER_INPUT_QUERY_PARAM = 'q'; /** Query parameter for hiding the side panel. */ export const HIDE_SIDE_PANEL_QUERY_PARAM = 'hideSidePanel'; -/** Query parameter for landing page content. */ -export const LANDING_PAGE_CONTENT_QUERY_PARAM = 'landing'; /** A2A data part markers */ @@ -120,15 +129,15 @@ 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.'; + 'Restarting bidirectional streaming is not currently supported. Please refresh the page or start a new session.'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-chat', templateUrl: './chat.component.html', styleUrl: './chat.component.scss', providers: [ - {provide: MatPaginatorIntl, useClass: CustomPaginatorIntl}, + { provide: MatPaginatorIntl, useClass: CustomPaginatorIntl }, ], imports: [ MatDrawerContainer, @@ -139,23 +148,28 @@ const BIDI_STREAMING_RESTART_WARNING = ReactiveFormsModule, MatIcon, NgxJsonViewerModule, - NgClass, MatButton, - MatSlideToggle, - MatDivider, + MatIconButton, + MatMenuModule, MatCard, - MatFabButton, - ResizableBottomDirective, - TraceEventComponent, + MatToolbar, + NgComponentOutlet, + MatFormField, + MatInput, + MatProgressSpinner, AsyncPipe, ChatPanelComponent, + AgentStructureGraphDialogComponent, SidePanelComponent, CanvasComponent, BuilderTabsComponent, + SessionTabComponent, + InlineEditComponent, ], }) export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { protected readonly i18n = inject(ChatMessagesInjectionToken); + protected readonly sidePanelI18n = inject(SidePanelMessagesInjectionToken); private readonly _snackBar = inject(MatSnackBar); private readonly activatedRoute = inject(ActivatedRoute); private readonly agentService = inject(AGENT_SERVICE); @@ -175,18 +189,26 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { private readonly safeValuesService = inject(SAFE_VALUES_SERVICE); private readonly sessionService = inject(SESSION_SERVICE); private readonly streamChatService = inject(STREAM_CHAT_SERVICE); + private readonly webSocketService = inject(WEBSOCKET_SERVICE); + private readonly audioRecordingService = inject(AUDIO_RECORDING_SERVICE); + private readonly audioPlayingService = inject(AUDIO_PLAYING_SERVICE); private readonly stringToColorService = inject(STRING_TO_COLOR_SERVICE); private readonly traceService = inject(TRACE_SERVICE); protected readonly uiStateService = inject(UI_STATE_SERVICE); protected readonly agentBuilderService = inject(AGENT_BUILDER_SERVICE); + protected readonly themeService = inject(THEME_SERVICE); + protected readonly logoComponent: Type | null = inject(LOGO_COMPONENT, { + optional: true, + }); chatPanel = viewChild.required(ChatPanelComponent); canvasComponent = viewChild.required(CanvasComponent); sideDrawer = viewChild.required('sideDrawer'); sidePanel = viewChild.required(SidePanelComponent); + drawerSessionTab = viewChild('drawerSessionTab'); evalTab = viewChild(EvalTabComponent); - bottomPanelRef = viewChild.required('bottomPanel'); - enableSseIndicator = signal(false); + appSearchInput = viewChild>('appSearchInput'); + isChatMode = signal(true); isEvalCaseEditing = signal(false); hasEvalCaseChanged = signal(false); @@ -194,10 +216,42 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { isBuilderMode = signal(false); // Default to builder mode off videoElement!: HTMLVideoElement; currentMessage = ''; - messages = signal([]); - lastTextChunk: string = ''; - streamingTextMessage: any|null = null; - latestThought: string = ''; + uiEvents = signal([]); + + invocationDisplayMap = computed(() => { + const map = new Map(); + let invIndex = 1; + let lastUserMessage = ''; + + for (const e of this.uiEvents()) { + if (e.role === 'user') { + if (e.text) { + lastUserMessage = e.text; + } else if (e.event?.content?.parts?.length) { + const hasText = e.event.content.parts.find((p: any) => p.text); + if (hasText && hasText.text) { + lastUserMessage = hasText.text; + } + } else { + lastUserMessage = 'User Message'; + } + } + + if (e.event?.invocationId) { + const invId = e.event.invocationId; + if (!map.has(invId)) { + let shortMsg = lastUserMessage || 'User Message'; + if (shortMsg.length > 50) { + shortMsg = shortMsg.substring(0, 47) + '...'; + } + map.set(invId, `#${invIndex} (${shortMsg})`); + invIndex++; + } + } + } + return map; + }); + artifacts: any[] = []; userInput: string = ''; userEditEvalCaseMessage: string = ''; @@ -205,33 +259,53 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { appName = ''; sessionId = ``; sessionIdOfLoadedMessages = ''; - evalCase: EvalCase|null = null; - updatedEvalCase: EvalCase|null = null; + evalCase: EvalCase | null = null; + updatedEvalCase: EvalCase | null = null; evalSetId = ''; isAudioRecording = false; + micVolume = this.audioRecordingService.volumeLevel; isVideoRecording = false; longRunningEvents: any[] = []; functionCallEventId = ''; redirectUri = URLUtil.getBaseUrlWithoutPath(); - showSidePanel = true; + showSidePanel = window.localStorage.getItem('adk-side-panel-visible') !== 'false'; showBuilderAssistant = true; - useSse = false; - currentSessionState: SessionState|undefined = {}; + showAppSelectorDrawer = false; + showSessionSelectorDrawer = false; + useSse = signal(window.localStorage.getItem('adk-use-sse') === 'true'); + currentSessionState: SessionState | undefined = {}; root_agent = ROOT_AGENT; updatedSessionState: WritableSignal = signal(null); - private readonly isModelThinkingSubject = new BehaviorSubject(false); + protected readonly canEditSession = signal(true); + hideIntermediateEvents = signal(window.localStorage.getItem('adk-hide-intermediate-events') === 'true'); + + toggleHideIntermediateEvents() { + const newVal = !this.hideIntermediateEvents(); + this.hideIntermediateEvents.set(newVal); + window.localStorage.setItem('adk-hide-intermediate-events', String(newVal)); + } // TODO: Remove this once backend supports restarting bidi streaming. sessionHasUsedBidi = new Set(); eventData = new Map(); traceData: any[] = []; - renderedEventGraph: SafeHtml|undefined; - rawSvgString: string|null = null; + renderedEventGraph: SafeHtml | undefined; + rawSvgString: string | null = null; + agentGraphData: any = null; + sessionGraphSvgLight: Record = {}; + sessionGraphSvgDark: Record = {}; + agentReadme: string = ''; + graphsAvailable: boolean = true; + + get hasSubWorkflows(): boolean { + return Object.keys(this.sessionGraphSvgLight).length > 1; + } selectedEvent: any = undefined; selectedEventIndex: any = undefined; + selectedMessageIndex: number | undefined = undefined; llmRequest: any = undefined; llmResponse: any = undefined; llmRequestKey = 'gcp.vertex.agent.llm_request'; @@ -239,7 +313,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { getMediaTypeFromMimetype = getMediaTypeFromMimetype; - selectedFiles: {file: File; url: string}[] = []; + selectedFiles: { file: File; url: string }[] = []; protected MediaType = MediaType; @@ -248,6 +322,9 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { nonNullable: true, }); + // App selector drawer + protected readonly appDrawerSearchControl = new FormControl('', { nonNullable: true }); + protected openBase64InNewTab(data: string, mimeType: string) { this.safeValuesService.openBase64InNewTab(data, mimeType); } @@ -255,66 +332,85 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { // Load apps protected isLoadingApps: WritableSignal = signal(false); loadingError: WritableSignal = signal(''); - protected readonly apps$: Observable = of([]).pipe( - tap(() => { - this.isLoadingApps.set(true); - this.selectedAppControl.disable(); - }), - switchMap( - () => this.agentService.listApps().pipe( - catchError((err: HttpErrorResponse) => { - this.loadingError.set(err.message); - return of(undefined); - }), - ), - ), - take(1), - tap((app) => { - this.isLoadingApps.set(false); - this.selectedAppControl.enable(); - if (app?.length == 1) { - this.router.navigate([], { - relativeTo: this.activatedRoute, - queryParams: {app: app[0]}, - queryParamsHandling: 'merge', - }); - } - }), - shareReplay(), + protected readonly apps$: Observable = of([]).pipe( + tap(() => { + this.isLoadingApps.set(true); + this.selectedAppControl.disable(); + }), + switchMap( + () => this.agentService.listApps().pipe( + catchError((err: HttpErrorResponse) => { + this.loadingError.set(err.message); + return of(undefined); + }), + ), + ), + take(1), + tap((app) => { + this.isLoadingApps.set(false); + this.selectedAppControl.enable(); + if (app?.length == 1) { + this.router.navigate([], { + relativeTo: this.activatedRoute, + queryParams: { app: app[0] }, + queryParamsHandling: 'merge', + }); + } + }), + shareReplay(), + ); + + protected readonly filteredDrawerApps$: Observable = this.apps$.pipe( + switchMap(apps => + combineLatest([ + of(apps), + this.appDrawerSearchControl.valueChanges.pipe(startWith('')), + ]) + ), + map(([apps, searchTerm]) => { + if (!apps) return apps; + if (!searchTerm || searchTerm.trim() === '') return apps; + const lower = searchTerm.toLowerCase().trim(); + return apps.filter(app => app.toLowerCase().includes(lower)); + }), ); // Feature flag references for use in template. readonly importSessionEnabledObs: Observable = - this.featureFlagService.isImportSessionEnabled(); + this.featureFlagService.isImportSessionEnabled(); readonly isEditFunctionArgsEnabledObs: Observable = - this.featureFlagService.isEditFunctionArgsEnabled(); + this.featureFlagService.isEditFunctionArgsEnabled(); readonly isSessionUrlEnabledObs: Observable = - this.featureFlagService.isSessionUrlEnabled(); + this.featureFlagService.isSessionUrlEnabled(); readonly isApplicationSelectorEnabledObs: Observable = - this.featureFlagService.isApplicationSelectorEnabled(); + this.featureFlagService.isApplicationSelectorEnabled(); readonly isTokenStreamingEnabledObs: Observable = - this.featureFlagService.isTokenStreamingEnabled(); + this.featureFlagService.isTokenStreamingEnabled(); readonly isExportSessionEnabledObs: Observable = - this.featureFlagService.isExportSessionEnabled(); + this.featureFlagService.isExportSessionEnabled(); readonly isEventFilteringEnabled = - toSignal(this.featureFlagService.isEventFilteringEnabled()); + toSignal(this.featureFlagService.isEventFilteringEnabled()); readonly isApplicationSelectorEnabled = - toSignal(this.featureFlagService.isApplicationSelectorEnabled()); + toSignal(this.featureFlagService.isApplicationSelectorEnabled()); readonly isDeleteSessionEnabledObs: Observable = - this.featureFlagService.isDeleteSessionEnabled(); + this.featureFlagService.isDeleteSessionEnabled(); readonly isUserIdOnToolbarEnabledObs: Observable = - this.featureFlagService.isUserIdOnToolbarEnabled(); + this.featureFlagService.isUserIdOnToolbarEnabled(); readonly isDeveloperUiDisclaimerEnabledObs: Observable = - this.featureFlagService.isDeveloperUiDisclaimerEnabled(); + this.featureFlagService.isDeveloperUiDisclaimerEnabled(); - // Trace detail - bottomPanelVisible = false; - hoveredEventMessageIndices: number[] = []; // Builder disableBuilderSwitch = false; - constructor() {} + constructor() { + effect(() => { + // Re-render graph when theme changes + if (this.themeService.currentTheme()) { + this.updateRenderedGraph(); + } + }); + } ngOnInit(): void { this.syncSelectedAppFromUrl(); @@ -325,27 +421,52 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.agentService.getApp(), this.activatedRoute.queryParams, ]) - .pipe( - filter( - ([app, params]) => - !!app && !!params[INITIAL_USER_INPUT_QUERY_PARAM], - ), - first(), - map(([, params]) => params[INITIAL_USER_INPUT_QUERY_PARAM])) - .subscribe((initialUserInput) => { - // Use `setTimeout` to ensure the userInput is set after the current - // change detection cycle is complete. - setTimeout(() => { - this.userInput = initialUserInput; - }); + .pipe( + filter( + ([app, params]) => + !!app && !!params[INITIAL_USER_INPUT_QUERY_PARAM], + ), + first(), + map(([, params]) => params[INITIAL_USER_INPUT_QUERY_PARAM])) + .subscribe((initialUserInput) => { + // Use `setTimeout` to ensure the userInput is set after the current + // change detection cycle is complete. + setTimeout(() => { + this.userInput = initialUserInput; }); + }); this.streamChatService.onStreamClose().subscribe((closeReason) => { const error = - 'Please check server log for full details: \n' + closeReason; + 'Please check server log for full details: \n' + closeReason; this.openSnackBar(error, 'OK'); }); + this.webSocketService.getMessages().subscribe((message) => { + if (!message) return; + try { + const apiEvent = JSON.parse(message); + + if (apiEvent.inputTranscription !== undefined) { + apiEvent.author = 'user'; + apiEvent.partial = true; + } else if (apiEvent.outputTranscription !== undefined) { + apiEvent.author = 'bot'; + apiEvent.partial = true; + } + + if (apiEvent.interrupted || apiEvent.turnComplete) { + this.audioPlayingService.stopAudio(); + } + + this.appendEventRow(apiEvent); + this.changeDetectorRef.detectChanges(); + } catch (e) { + // Ignored + } + }); + + // OAuth HACK: Opens oauth poup in a new window. If the oauth callback // is successful, the new window acquires the auth token, state and // optionally the scope. Send this back to the main window. @@ -354,7 +475,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { if (searchParams.has('code')) { const authResponseUrl = window.location.href; // Send token to the main window - window.opener?.postMessage({authResponseUrl}, window.origin); + window.opener?.postMessage({ authResponseUrl }, window.origin); // Close the popup window.close(); } @@ -363,60 +484,49 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.appName = app; }); - combineLatest([ - this.agentService.getLoadingState(), - this.isModelThinkingSubject, - ]).subscribe(([isLoading, isModelThinking]) => { - const lastMessage = this.messages()[this.messages().length - 1]; - - if (isLoading) { - if (!lastMessage?.isLoading && !this.streamingTextMessage) { - this.messages.update( - (messages) => - [...messages, - {role: 'bot', isLoading: true}, - ]); - } - } else if (lastMessage?.isLoading && !isModelThinking) { - this.messages.update((messages) => messages.slice(0, -1)); - this.changeDetectorRef.detectChanges(); - } - }); - this.traceService.selectedTraceRow$.subscribe(node => { - const eventId = node?.attributes['gcp.vertex.agent.event_id']; - if (eventId && this.eventData.has(eventId)) { - this.bottomPanelVisible = true; - } else { - this.bottomPanelVisible = false; + this.traceService.selectedTraceRow$.subscribe((span) => { + if (span) { + this.selectedEvent = undefined; + this.selectedEventIndex = undefined; + this.selectedMessageIndex = undefined; + + if (!this.showSidePanel) { + this.showSidePanel = true; + window.localStorage.setItem('adk-side-panel-visible', 'true'); + this.sideDrawer()?.open(); + } + + this.changeDetectorRef.detectChanges(); } }); - this.traceService.hoveredMessageIndices$.subscribe( - i => this.hoveredEventMessageIndices = i); - this.featureFlagService.isInfinityMessageScrollingEnabled() - .pipe(first()) - .subscribe((enabled) => { - if (enabled) { - this.uiStateService.onNewMessagesLoaded().subscribe( - (response: ListResponse&{isBackground?: boolean}) => { - this.populateMessages( - response.items, true, !response.isBackground); - this.loadTraceData(); - }); - - this.uiStateService.onNewMessagesLoadingFailed().subscribe( - (error: {message: string}) => { - this.openSnackBar(error.message, 'OK'); - }); - } - }); + .pipe(first()) + .subscribe((enabled) => { + if (enabled) { + this.uiStateService.onNewMessagesLoaded().subscribe( + (response: ListResponse & { isBackground?: boolean }) => { + this.populateMessages( + response.items, true, !response.isBackground); + this.loadTraceData(); + }); + + this.uiStateService.onNewMessagesLoadingFailed().subscribe( + (error: { message: string }) => { + this.openSnackBar(error.message, 'OK'); + }); + } + }); } get sessionTab() { - return this.sidePanel().sessionTabComponent(); + return this.drawerSessionTab(); + } + + switchToTraceView() { + this.chatPanel()?.onViewModeChange('traces'); } ngAfterViewInit() { @@ -431,9 +541,17 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { selectApp(appName: string) { if (appName != this.appName) { + const isInitialLoad = !this.appName; this.agentService.setApp(appName); - this.loadSessionByUrlOrReset(); + if (isInitialLoad) { + // On initial load, honour any session ID in the URL. + this.loadSessionByUrlOrReset(); + } else { + // When switching agents, start fresh — the URL session belongs + // to the previous agent. + this.createSessionAndReset(); + } } } @@ -454,88 +572,101 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } if (sessionUrl) { - this.sessionService.getSession(this.userId, this.appName, sessionUrl) - .pipe(take(1), catchError((error) => { - this.openSnackBar( - 'Cannot find specified session. Creating a new one.', - 'OK'); - this.createSessionAndReset(); - return of(null); - })) - .subscribe((session) => { - if (session) { - this.updateWithSelectedSession(session); - } - }); + this.sessionId = sessionUrl; + this.loadSession(sessionUrl, true); } }); } - private displayLandingPageContent() { - this.activatedRoute.queryParams.pipe(first()).subscribe(params => { - const landingContent = params[LANDING_PAGE_CONTENT_QUERY_PARAM]; - if (landingContent) { - try { - const decodedContent = decodeURIComponent(landingContent); - // Check if the landing message already exists - if (!this.messages().some(m => m.isLanding)) { - this.messages.update( - messages => - [{role: 'bot', text: decodedContent, isLanding: true}, - ...messages]); + protected loadSession(sessionId: string, isFromUrl: boolean = false) { + this.uiStateService.setIsSessionLoading(true); + + combineLatest([ + this.sessionService.getSession(this.userId, this.appName, sessionId).pipe( + catchError((error) => { + if (isFromUrl) { + this.openSnackBar( + 'Cannot find specified session. Creating a new one.', + undefined, + 3000); + this.createSessionAndReset(); } - } catch (e) { - console.error('Error decoding landing page content:', e); + return of(null); + }) + ), + this.featureFlagService.isInfinityMessageScrollingEnabled() + ]).pipe(first()).subscribe(([session, isInfinityScrollingEnabled]) => { + this.uiStateService.setIsSessionLoading(false); + if (session) { + if (isInfinityScrollingEnabled && session.id) { + this.uiStateService + .lazyLoadMessages(session.id, { + pageSize: 100, + pageToken: '', + }) + .pipe(first()) + .subscribe(); } + this.updateWithSelectedSession(session); } }); } private hideSidePanelIfNeeded() { this.activatedRoute.queryParams - .pipe( - filter((params) => params[HIDE_SIDE_PANEL_QUERY_PARAM] === 'true'), - take(1), - ) - .subscribe(() => { - this.showSidePanel = false; - this.sideDrawer()?.close(); - }); + .pipe( + filter((params) => params[HIDE_SIDE_PANEL_QUERY_PARAM] === 'true'), + take(1), + ) + .subscribe(() => { + this.showSidePanel = false; + this.sideDrawer()?.close(); + }); } private createSessionAndReset() { - this.createSession(); + this.resetToNewSession(); this.eventData = new Map(); - this.messages.set([]); + this.uiEvents.set([]); this.artifacts = []; this.userInput = ''; this.longRunningEvents = []; - this.displayLandingPageContent(); + this.selectedEvent = undefined; + this.selectedEventIndex = undefined; + this.selectedMessageIndex = undefined; + this.traceService.resetTraceService(); + } + + private resetToNewSession() { + this.sessionId = ''; + this.currentSessionState = {}; + this.sessionTab?.refreshSession(); + this.clearSessionUrl(); } createSession() { this.uiStateService.setIsSessionListLoading(true); this.sessionService.createSession(this.userId, this.appName) - .subscribe( - (res) => { - this.currentSessionState = res.state; - this.sessionId = res.id ?? ''; - this.sessionTab?.refreshSession(); - this.sessionTab?.reloadSession(this.sessionId); - - this.isSessionUrlEnabledObs.subscribe((enabled) => { - if (enabled) { - this.updateSelectedSessionUrl(); - } - }); - }, - () => { - this.uiStateService.setIsSessionListLoading(false); - }); + .subscribe( + (res) => { + this.currentSessionState = res.state; + this.sessionId = res.id ?? ''; + this.sessionTab?.refreshSession(); + this.sessionTab?.reloadSession(this.sessionId); + + this.isSessionUrlEnabledObs.subscribe((enabled) => { + if (enabled) { + this.updateSelectedSessionUrl(); + } + }); + }, + () => { + this.uiStateService.setIsSessionListLoading(false); + }); } - async sendMessage(event: Event) { + async handleChatInput(event: Event) { event.preventDefault(); if (!this.userInput.trim() && this.selectedFiles.length <= 0) return; @@ -546,79 +677,111 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } } - const userEventId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; - const userParts: any[] = []; - - // Build combined user message - const userMessage: any = { + const content = { role: 'user', - eventId: userEventId + parts: await this.getUserMessageParts() }; - // Add user message text - if (!!this.userInput.trim()) { - userParts.push({ text: this.userInput }); - userMessage.text = this.userInput; + // Clear input + this.userInput = ''; + this.selectedFiles = []; + + // Clear the query param for the initial user input once it is sent. + const updatedUrl = this.router.parseUrl(this.location.path()); + if (updatedUrl.queryParams[INITIAL_USER_INPUT_QUERY_PARAM]) { + delete updatedUrl.queryParams[INITIAL_USER_INPUT_QUERY_PARAM]; + this.location.replaceState(updatedUrl.toString()); } - // Add user message attachments - if (this.selectedFiles.length > 0) { - const messageAttachments = this.selectedFiles.map((file) => ({ - file: file.file, - url: file.url, - })); + await this.sendMessage(content); + } - for (const file of this.selectedFiles) { - const part = await this.localFileService.createMessagePartFromFile(file.file); - userParts.push(part); + async ensureSessionActive(content?: any): Promise { + if (this.sessionId) { + return true; + } + + try { + let displayName = ''; + if (content?.parts && content.parts[0]?.text) { + displayName = content.parts[0].text; + if (displayName.length > 50) { + displayName = displayName.substring(0, 47) + '...'; + } } + const initialState = displayName ? { __session_metadata__: { displayName: displayName } } : undefined; + const res = await firstValueFrom( + this.sessionService.createSession(this.userId, this.appName, initialState)); + this.currentSessionState = res.state || initialState || {}; + this.sessionId = res.id ?? ''; + this.sessionTab?.refreshSession(); + this.sessionTab?.reloadSession(this.sessionId); + this.drawerSessionTab()?.refreshSession(); + this.drawerSessionTab()?.reloadSession(this.sessionId); + this.isSessionUrlEnabledObs.pipe(first()).subscribe((enabled) => { + if (enabled) { + this.updateSelectedSessionUrl(); + } + }); + return true; + } catch { + this.openSnackBar('Failed to create session', 'OK'); + return false; + } + } - userMessage.attachments = messageAttachments; + async sendMessage(content: any) { + // Lazily create a real session on first message send. + const isSessionActive = await this.ensureSessionActive(content); + if (!isSessionActive) { + return; } - // Add the combined user message as a single row - this.messages.update(messages => [...messages, userMessage]); + const functionCallEventId = content.functionCallEventId; + if (functionCallEventId) { + delete content.functionCallEventId; + } - const userEvent = { + const userEventId = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`; + const apiEvent = { id: userEventId, - author: 'user', - content: { parts: userParts } + author: content.role || 'user', + content: content }; - this.eventData.set(userEventId, userEvent); + + const userUiEvent = this.buildUiEventFromEvent(apiEvent); + this.uiEvents.update(uiEvents => [...uiEvents, userUiEvent]); + setTimeout(() => this.changeDetectorRef.detectChanges(), 0); + + this.eventData.set(userEventId, apiEvent); this.eventData = new Map(this.eventData); const req: AgentRunRequest = { appName: this.appName, userId: this.userId, sessionId: this.sessionId, - newMessage: { - role: 'user', - parts: await this.getUserMessageParts(), - }, - streaming: this.useSse, + newMessage: content, + streaming: this.useSse(), stateDelta: this.updatedSessionState(), }; - this.selectedFiles = []; - this.streamingTextMessage = null; + if (functionCallEventId) { + req.functionCallEventId = functionCallEventId; + } + + this.submitAgentRunRequest(req); + this.changeDetectorRef.detectChanges(); + } + + submitAgentRunRequest(req: AgentRunRequest) { this.agentService.runSse(req).subscribe({ - next: async (chunkJson: AdkEvent) => { + next: async (chunkJson: any) => { if (chunkJson.error) { this.openSnackBar(chunkJson.error, 'OK'); return; } - if (chunkJson.content) { - let parts = this.combineTextParts(chunkJson.content.parts); - if (this.isEventA2aResponse(chunkJson)) { - parts = this.combineA2uiDataParts(parts); - } - for (let part of parts) { - this.processPart(chunkJson, part); - this.traceService.setEventData(this.eventData); - } - } else if (chunkJson.errorMessage) { - this.processErrorMessage(chunkJson); - } + this.appendEventRow(chunkJson); + if (chunkJson.actions) { this.processActionArtifact(chunkJson); this.processActionStateDelta(chunkJson); @@ -634,166 +797,142 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.currentSessionState = this.updatedSessionState(); this.updatedSessionState.set(null); } - this.streamingTextMessage = null; this.featureFlagService.isSessionReloadOnNewMessageEnabled() - .pipe(first()) - .subscribe((enabled) => { - if (enabled) { - this.sessionTab?.reloadSession(this.sessionId); - } - }); - this.eventService.getTrace(this.sessionId) - .pipe(first(), catchError((error) => { - return of([]); - })) - .subscribe((res) => { - this.traceData = res; - this.changeDetectorRef.detectChanges(); - }); - this.traceService.setMessages(this.messages()); - this.changeDetectorRef.detectChanges(); + .pipe(first()) + .subscribe((enabled) => { + if (enabled) { + this.sessionTab?.reloadSession(this.sessionId); + } + }); + this.loadTraceData(); }, }); - // Clear input - this.userInput = ''; - // Clear the query param for the initial user input once it is sent. - const updatedUrl = this.router.parseUrl(this.location.path()); - if (updatedUrl.queryParams[INITIAL_USER_INPUT_QUERY_PARAM]) { - delete updatedUrl.queryParams[INITIAL_USER_INPUT_QUERY_PARAM]; - this.location.replaceState(updatedUrl.toString()); - } - this.changeDetectorRef.detectChanges(); } - private processErrorMessage(chunkJson: any) { - this.storeEvents(chunkJson, chunkJson); - this.insertMessageBeforeLoadingMessage( - {text: chunkJson.errorMessage, role: 'bot'}) - } + private appendEventRow(apiEvent: any, reverseOrder: boolean = false) { + if (apiEvent.inputTranscription !== undefined || apiEvent.outputTranscription !== undefined) { + apiEvent.partial = true; + } - private processPart(chunkJson: any, part: any) { - const renderedContent = - chunkJson.groundingMetadata?.searchEntryPoint?.renderedContent; + if (apiEvent.errorMessage) { + if (apiEvent.id && !this.eventData.has(apiEvent.id)) { + this.eventData.set(apiEvent.id, apiEvent); + this.eventData = new Map(this.eventData); + } + } - if (part.text) { - this.isModelThinkingSubject.next(false); - const newChunk = part.text; - if (part.thought) { - if (newChunk !== this.latestThought) { - this.storeEvents(part, chunkJson); - let thoughtMessage = { - role: 'bot', - text: this.processThoughtText(newChunk), - thought: true, - eventId: chunkJson.id, - }; + if (apiEvent.id && !this.eventData.has(apiEvent.id)) { + this.eventData.set(apiEvent.id, apiEvent); + this.eventData = new Map(this.eventData); + } + this.traceService.setEventData(this.eventData); - this.insertMessageBeforeLoadingMessage(thoughtMessage); + if (apiEvent.partial) { + this.uiEvents.update((events) => { + if (events.length > 0) { + const lastIndex = events.length - 1; + const lastEvent = events[lastIndex]; + + const isLastTranscription = !!((lastEvent.event as any)?.inputTranscription || (lastEvent.event as any)?.outputTranscription); + const isCurrentTranscription = !!(apiEvent.inputTranscription || apiEvent.outputTranscription); + + if ((lastEvent.event as any)?.partial && + lastEvent.role === (apiEvent.author === 'user' ? 'user' : 'bot') && + isLastTranscription === isCurrentTranscription) { + const updatedEvent = this.mergePartialEvent(lastEvent, apiEvent); + + const newEvents = [...events]; + newEvents[lastIndex] = updatedEvent; + return newEvents; + } } - this.latestThought = newChunk; - } else if (!this.streamingTextMessage) { - this.streamingTextMessage = { - role: 'bot', - text: this.processThoughtText(newChunk), - thought: part.thought ? true : false, - eventId: chunkJson.id, - }; - if (renderedContent) { - this.streamingTextMessage.renderedContent = - chunkJson.groundingMetadata.searchEntryPoint.renderedContent; + const newUiEvent = this.buildUiEventFromEvent(apiEvent, reverseOrder); + return reverseOrder ? [newUiEvent, ...events] : [...events, newUiEvent]; + }); + } else { + const uiEvent = this.buildUiEventFromEvent(apiEvent, reverseOrder); + + this.uiEvents.update(events => { + let existingIndex = events.findIndex(m => m.event?.id === apiEvent.id && apiEvent.id); + if (existingIndex < 0 && events.length > 0) { + const checkIndex = reverseOrder ? 0 : events.length - 1; + const checkEvent = events[checkIndex]; + if ((checkEvent.event as any)?.partial) { + const isLastTranscription = !!((checkEvent.event as any)?.inputTranscription || (checkEvent.event as any)?.outputTranscription); + const isCurrentTranscription = !!(apiEvent.inputTranscription || apiEvent.outputTranscription); + + if (isLastTranscription === isCurrentTranscription) { + existingIndex = checkIndex; + } else { + existingIndex = -1; + } + } } - if (!this.useSse) { - this.insertMessageBeforeLoadingMessage(this.streamingTextMessage); - this.storeEvents(part, chunkJson); - this.streamingTextMessage = null; - return; + if (existingIndex >= 0) { + const newEvents = [...events]; + newEvents[existingIndex] = uiEvent; + return newEvents; } else { - this.insertMessageBeforeLoadingMessage(this.streamingTextMessage); - } - } else { - if (renderedContent) { - this.streamingTextMessage.renderedContent = - chunkJson.groundingMetadata.searchEntryPoint.renderedContent; - } - - if (newChunk == this.streamingTextMessage.text) { - // Final chunk arrived - update the existing message's eventId - const oldEventId = this.streamingTextMessage.eventId; - this.messages.update((messages) => { - return messages.map(m => { - if (m.eventId === oldEventId && m.role === 'bot') { - return {...m, eventId: chunkJson.id}; - } - return m; - }); - }); - this.storeEvents(part, chunkJson); - this.streamingTextMessage = null; - return; + return reverseOrder ? [uiEvent, ...events] : [...events, uiEvent]; } - // Update the streaming text and insert to trigger UI update - this.streamingTextMessage.text += newChunk; - this.insertMessageBeforeLoadingMessage(this.streamingTextMessage); - } - } else if (!part.thought) { - // Skip partial events for non-text parts to avoid duplicates - if (this.useSse && chunkJson.partial) { - return; - } + }); + } - // If the part is an A2A DataPart, display it as a message (e.g., A2UI or - // Json) - if (this.isA2aDataPart(part)) { - const parsedObject = this.extractA2aDataPartJson(part); - const isA2uiDataPart = parsedObject && parsedObject.kind === 'data' && - parsedObject.metadata?.mimeType === A2UI_MIME_TYPE; - const displayPart = - isA2uiDataPart ? {a2ui: parsedObject.data} : {text: parsedObject}; - this.isModelThinkingSubject.next(false); - this.storeEvents(part, chunkJson); - this.storeMessage( - displayPart, chunkJson, - chunkJson.author === 'user' ? 'user' : 'bot'); - return; + if (apiEvent.actions?.artifactDelta) { + for (const key in apiEvent.actions.artifactDelta) { + if (apiEvent.actions.artifactDelta.hasOwnProperty(key)) { + this.renderArtifact(key, apiEvent.actions.artifactDelta[key], reverseOrder); + } } + } + } - this.isModelThinkingSubject.next(false); - this.storeEvents(part, chunkJson); - - const existingMessages = this.messages(); - const existingMessageIndex = existingMessages.findIndex( - msg => msg.eventId === chunkJson.id && msg.role === 'bot' - ); + private mergePartialEvent(lastEvent: UiEvent, apiEvent: any): UiEvent { + const updatedEvent = new UiEvent({ ...lastEvent, event: apiEvent as any }); - if (existingMessageIndex !== -1) { - // Update existing message by adding this part - this.messages.update(messages => { - const updatedMessages = [...messages]; - this.processPartIntoMessage(part, chunkJson, updatedMessages[existingMessageIndex]); - return updatedMessages; - }); + let parts = apiEvent.content?.parts || []; + if (this.isEventA2aResponse(apiEvent)) { + parts = this.combineA2uiDataParts(parts); + } + parts = this.combineTextParts(parts); + + parts.forEach((part: any) => { + if (part.text !== undefined && part.text !== null) { + updatedEvent.text = (updatedEvent.text || '') + part.text; + if (part.thought) { + updatedEvent.thought = true; + updatedEvent.text = this.processThoughtText(updatedEvent.text || ''); + } } else { - this.storeMessage( - part, chunkJson, chunkJson.author === 'user' ? 'user' : 'bot'); + this.processPartIntoMessage(part, apiEvent, updatedEvent); } - } else { - this.isModelThinkingSubject.next(true); + }); + + if (apiEvent.inputTranscription) { + const previousText = (lastEvent.event as any)?.inputTranscription?.text || ''; + updatedEvent.event.inputTranscription = { text: previousText + (apiEvent.inputTranscription.text || '') }; + } + if (apiEvent.outputTranscription) { + const previousText = (lastEvent.event as any)?.outputTranscription?.text || ''; + updatedEvent.event.outputTranscription = { text: previousText + (apiEvent.outputTranscription.text || '') }; } + + return updatedEvent; } async getUserMessageParts() { let parts: any = []; if (!!this.userInput.trim()) { - parts.push({text: `${this.userInput}`}); + parts.push({ text: `${this.userInput}` }); } if (this.selectedFiles.length > 0) { for (const file of this.selectedFiles) { parts.push( - await this.localFileService.createMessagePartFromFile(file.file)); + await this.localFileService.createMessagePartFromFile(file.file)); } } return parts; @@ -801,7 +940,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { private processActionArtifact(e: AdkEvent) { if (e.actions && e.actions.artifactDelta && - Object.keys(e.actions.artifactDelta).length > 0) { + Object.keys(e.actions.artifactDelta).length > 0) { this.storeEvents(null, e); this.storeMessage(null, e, 'bot'); } @@ -809,8 +948,11 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { private processActionStateDelta(e: AdkEvent) { if (e.actions && e.actions.stateDelta && - Object.keys(e.actions.stateDelta).length > 0) { - this.currentSessionState = e.actions.stateDelta; + Object.keys(e.actions.stateDelta).length > 0) { + this.currentSessionState = { + ...(this.currentSessionState || {}), + ...e.actions.stateDelta + }; } } @@ -820,12 +962,12 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { */ private combineTextParts(parts: Part[]) { const result: Part[] = []; - let combinedTextPart: Part|undefined; + let combinedTextPart: Part | undefined; for (const part of parts) { if (part.text && !part.thought) { if (!combinedTextPart) { - combinedTextPart = {text: part.text}; + combinedTextPart = { text: part.text }; result.push(combinedTextPart); } else { combinedTextPart.text += part.text; @@ -851,13 +993,13 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { const dataPartText = atob(fixBase64String(part.inlineData.data)); return dataPartText.startsWith(A2A_DATA_PART_START_TAG) && - dataPartText.endsWith(A2A_DATA_PART_END_TAG); + dataPartText.endsWith(A2A_DATA_PART_END_TAG); } private isA2uiDataPart(part: Part) { const parsedObject = this.extractA2aDataPartJson(part); return parsedObject && parsedObject.kind === 'data' && - parsedObject.metadata?.mimeType === A2UI_MIME_TYPE; + parsedObject.metadata?.mimeType === A2UI_MIME_TYPE; } private extractA2aDataPartJson(part: Part) { @@ -867,8 +1009,8 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { const dataPartText = atob(fixBase64String(part.inlineData!.data)); const jsonContent = dataPartText.substring( - A2A_DATA_PART_START_TAG.length, - dataPartText.length - A2A_DATA_PART_END_TAG.length); + A2A_DATA_PART_START_TAG.length, + dataPartText.length - A2A_DATA_PART_END_TAG.length); let parsedObject: any; try { parsedObject = JSON.parse(jsonContent); @@ -883,7 +1025,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { private combineA2uiDataParts(parts: Part[]): Part[] { const result: Part[] = []; const combinedA2uiJson: any[] = []; - let combinedDataPart: Part|undefined; + let combinedDataPart: Part | undefined; for (const part of parts) { if (this.isA2uiDataPart(part)) { @@ -892,7 +1034,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { // order of the a2ui components is preserved. if (!combinedDataPart) { combinedDataPart = { - inlineData: {mimeType: 'text/plain', data: part.inlineData!.data} + inlineData: { mimeType: 'text/plain', data: part.inlineData!.data } }; result.push(combinedDataPart); } @@ -912,7 +1054,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { data: combinedA2uiJson, }; const inlineData = A2A_DATA_PART_START_TAG + - JSON.stringify(a2aDataPartJson) + A2A_DATA_PART_END_TAG; + JSON.stringify(a2aDataPartJson) + A2A_DATA_PART_END_TAG; combinedDataPart.inlineData.data = btoa(inlineData); } @@ -946,16 +1088,12 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } private storeMessage( - part: any, e: any, role: string, invocationIndex?: number, - additionalIndices?: any, prepend: boolean = false) { - if (e?.author) { - this.createAgentIconColorClass(e.author); - } - + part: any, e: any, role: string, invocationIndex?: number, + additionalIndices?: any, prepend: boolean = false) { if (e?.longRunningToolIds && e.longRunningToolIds.length > 0) { const startIndex = this.longRunningEvents.length; this.getAsyncFunctionsFromParts( - e.longRunningToolIds, e.content.parts, e.invocationId); + e.longRunningToolIds, e.content.parts, e.invocationId); // Store event ID for later reference this.functionCallEventId = e.id; @@ -964,22 +1102,22 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { for (let i = startIndex; i < this.longRunningEvents.length; i++) { const func = this.longRunningEvents[i].function; if (func.args.authConfig && - func.args.authConfig.exchangedAuthCredential && - func.args.authConfig.exchangedAuthCredential.oauth2) { + func.args.authConfig.exchangedAuthCredential && + func.args.authConfig.exchangedAuthCredential.oauth2) { // for OAuth const authUri = - func.args.authConfig.exchangedAuthCredential.oauth2.authUri; + func.args.authConfig.exchangedAuthCredential.oauth2.authUri; const updatedAuthUri = this.updateRedirectUri( - authUri, - this.redirectUri, + authUri, + this.redirectUri, ); this.openOAuthPopup(updatedAuthUri) - .then((authResponseUrl) => { - this.sendOAuthResponse(func, authResponseUrl, this.redirectUri); - }) - .catch((error) => { - console.error('OAuth Error:', error); - }); + .then((authResponseUrl) => { + this.sendOAuthResponse(func, authResponseUrl, this.redirectUri); + }) + .catch((error) => { + console.error('OAuth Error:', error); + }); break; // Handle one OAuth at a time } } @@ -1007,21 +1145,21 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { actualFinalResponse: e?.actualFinalResponse, expectedFinalResponse: e?.expectedFinalResponse, invocationIndex: invocationIndex !== undefined ? invocationIndex : - undefined, + undefined, finalResponsePartIndex: - additionalIndices?.finalResponsePartIndex !== undefined ? + additionalIndices?.finalResponsePartIndex !== undefined ? additionalIndices.finalResponsePartIndex : undefined, toolUseIndex: additionalIndices?.toolUseIndex !== undefined ? - additionalIndices.toolUseIndex : - undefined, + additionalIndices.toolUseIndex : + undefined, }; // Process the part and add its content to the message if (part) { if (part.inlineData) { const base64Data = this.formatBase64Data( - part.inlineData.data, part.inlineData.mimeType); + part.inlineData.data, part.inlineData.mimeType); message.inlineData = { displayName: part.inlineData.displayName, data: base64Data, @@ -1033,15 +1171,15 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { message.text = part.text; message.thought = part.thought ? true : false; if (e?.groundingMetadata && e.groundingMetadata.searchEntryPoint && - e.groundingMetadata.searchEntryPoint.renderedContent) { + e.groundingMetadata.searchEntryPoint.renderedContent) { message.renderedContent = - e.groundingMetadata.searchEntryPoint.renderedContent; + e.groundingMetadata.searchEntryPoint.renderedContent; } - message.eventId = e?.id; + message.event = e as any; } else if (part.functionCall) { // Enrich function call with long-running metadata if applicable const isLongRunning = - e?.longRunningToolIds?.includes(part.functionCall.id); + e?.longRunningToolIds?.includes(part.functionCall.id); const enrichedFunctionCall = { ...part.functionCall, ...(isLongRunning && { @@ -1054,10 +1192,10 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { }), }; message.functionCalls = [enrichedFunctionCall]; - message.eventId = e?.id; + message.event = e as any; } else if (part.functionResponse) { message.functionResponses = [part.functionResponse]; - message.eventId = e?.id; + message.event = e as any; } else if (part.executableCode) { message.executableCode = part.executableCode; } else if (part.codeExecutionResult) { @@ -1074,41 +1212,32 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { if (part && Object.keys(part).length > 0) { if (prepend) { - this.messages.update((messages) => [message, ...messages]); + this.uiEvents.update((uiEvents) => [message, ...uiEvents]); } else { - this.insertMessageBeforeLoadingMessage(message); + this.insertOrUpdateMessage(message); } } } - private insertMessageBeforeLoadingMessage(message: any) { - this.messages.update((messages) => { + private insertOrUpdateMessage(message: any) { + this.uiEvents.update((uiEvents) => { // If SSE streaming is enabled and this is a text message with eventId - if (this.useSse && message.text && message.eventId && - message.role === 'bot') { - // Find existing streaming message with the same eventId - const existingIndex = messages.findIndex( - m => m.eventId === message.eventId && m.role === 'bot' && - !m.isLoading); - if (existingIndex !== -1) { - const updatedMessages = [...messages]; - updatedMessages[existingIndex] = { - ...updatedMessages[existingIndex], - text: message.text, - renderedContent: message.renderedContent || - updatedMessages[existingIndex].renderedContent - }; - return updatedMessages; + if (this.useSse() && message.text && message.event.id && + message.role === 'bot') { + if (uiEvents.length > 0) { + const lastIndex = uiEvents.length - 1; + const lastMessage = uiEvents[lastIndex]; + if (lastMessage.event.id === message.event.id && lastMessage.role === 'bot') { + const updatedMessages = [...uiEvents]; + // Replace with the new message to preserve all fields (including inlineData) + updatedMessages[lastIndex] = message; + return updatedMessages; + } } } // Default behavior: insert new message - const lastMessage = messages[messages.length - 1]; - if (lastMessage?.isLoading) { - return [...messages.slice(0, -1), message, lastMessage]; - } else { - return [...messages, message]; - } + return [...uiEvents, message]; }); } @@ -1117,74 +1246,103 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { return `data:${mimeType};base64,${fixedBase64Data}`; } - private processPartIntoMessage(part: any, event: any, message: any) { + private processPartIntoMessage(part: any, event: any, uiEvent: UiEvent) { if (!part) return; + if (event) { + uiEvent.event = event; + } + if (part.text) { - message.text = part.text; - message.thought = part.thought ? true : false; + uiEvent.text = part.text; + uiEvent.thought = part.thought ? true : false; if (event?.groundingMetadata && event.groundingMetadata.searchEntryPoint && - event.groundingMetadata.searchEntryPoint.renderedContent) { - message.renderedContent = - event.groundingMetadata.searchEntryPoint.renderedContent; + event.groundingMetadata.searchEntryPoint.renderedContent) { + uiEvent.renderedContent = + event.groundingMetadata.searchEntryPoint.renderedContent; } if (event?.id) { - message.eventId = event.id; + uiEvent.event = event as any; } } else if (part.inlineData) { const base64Data = this.formatBase64Data( - part.inlineData.data, part.inlineData.mimeType); - message.inlineData = { + part.inlineData.data, part.inlineData.mimeType); + const mediaType = getMediaTypeFromMimetype(part.inlineData.mimeType); + uiEvent.inlineData = { displayName: part.inlineData.displayName, data: base64Data, mimeType: part.inlineData.mimeType, + mediaType, }; - if (message.role === 'user' && event?.id) { - message.eventId = event.id; + if (uiEvent.role === 'user' && event?.id) { + uiEvent.event = event as any; } } else if (part.functionCall) { - if (!message.functionCalls) { - message.functionCalls = []; + if (!uiEvent.functionCalls) { + uiEvent.functionCalls = []; + } + + const isLongRunning = event?.longRunningToolIds?.includes(part.functionCall.id); + let enrichedFunctionCall = part.functionCall; + + if (isLongRunning) { + enrichedFunctionCall = { + ...part.functionCall, + isLongRunning: true, + invocationId: event.invocationId, + functionCallEventId: event.id, + needsResponse: true, + responseStatus: part.functionCall.responseStatus || 'pending', + userResponse: part.functionCall.userResponse || '', + }; + } + + const existingIndex = uiEvent.functionCalls.findIndex(fc => fc.id === part.functionCall.id); + if (existingIndex >= 0) { + uiEvent.functionCalls[existingIndex] = { ...uiEvent.functionCalls[existingIndex], ...enrichedFunctionCall }; + } else { + uiEvent.functionCalls.push(enrichedFunctionCall); } - message.functionCalls.push(part.functionCall); + if (event?.id) { - message.eventId = event.id; + uiEvent.event = event as any; } } else if (part.functionResponse) { - if (!message.functionResponses) { - message.functionResponses = []; + if (!uiEvent.functionResponses) { + uiEvent.functionResponses = []; } - message.functionResponses.push(part.functionResponse); + uiEvent.functionResponses.push(part.functionResponse); if (event?.id) { - message.eventId = event.id; + uiEvent.event = event as any; } } else if (part.executableCode) { - message.executableCode = part.executableCode; + uiEvent.executableCode = part.executableCode; } else if (part.codeExecutionResult) { - message.codeExecutionResult = part.codeExecutionResult; + uiEvent.codeExecutionResult = part.codeExecutionResult; } else if (part.a2ui) { - message.a2uiData = this.processA2uiPartIntoMessage(part); + uiEvent.a2uiData = this.processA2uiPartIntoMessage(part); } } private handleArtifactFetchFailure( - message: any, artifactId: string, versionId: string) { + uiEvent: any, artifactId: string, versionId: string) { this.openSnackBar( - 'Failed to fetch artifact data', - 'OK', + 'Failed to fetch artifact data', + 'OK', ); // Remove placeholder message and artifact on failure - this.messages.update(messages => messages.filter((m) => m !== message)); + uiEvent.error = { errorMessage: 'Failed to fetch artifact data' }; + this.changeDetectorRef.detectChanges(); this.artifacts = this.artifacts.filter( - a => a.id !== artifactId || a.versionId !== versionId); + a => a.id !== artifactId || a.versionId !== versionId); } private renderArtifact( - artifactId: string, versionId: string, prepend: boolean = false) { + artifactId: string, versionId: string, prepend: boolean = false) { // If artifact/version already exists, do nothing. const artifactExists = this.artifacts.some( - (artifact) => - artifact.id === artifactId && artifact.versionId === versionId, + (artifact) => + artifact.id === artifactId && artifact.versionId === versionId, ); if (artifactExists) { return; @@ -1192,17 +1350,18 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { // Add a placeholder message for the artifact // Feed the placeholder with the artifact data after it's fetched - let message = { + let uiEvent = new UiEvent({ role: 'bot', + event: { id: 'artifact-' + artifactId } as any, inlineData: { data: '', mimeType: 'image/png', }, - }; + }); if (prepend) { - this.messages.update((messages) => [message, ...messages]); + this.uiEvents.update((uiEvents) => [uiEvent, ...uiEvents]); } else { - this.insertMessageBeforeLoadingMessage(message); + this.insertOrUpdateMessage(uiEvent); } // Add placeholder artifact. @@ -1216,62 +1375,53 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.artifacts = [...this.artifacts, placeholderArtifact]; this.artifactService - .getArtifactVersion( - this.userId, - this.appName, - this.sessionId, - artifactId, - versionId, - ) - .subscribe({ - next: (res) => { - const {mimeType, data} = res.inlineData ?? {}; - if (!mimeType || !data) { - this.handleArtifactFetchFailure(message, artifactId, versionId); - return; - } - const base64Data = this.formatBase64Data(data, mimeType); - - const mediaType = getMediaTypeFromMimetype(mimeType); - - const inlineData = { - name: this.createDefaultArtifactName(mimeType), - data: base64Data, - mimeType: mimeType, - mediaType, - }; - - this.messages.update(messages => { - return messages.map(m => { - if (m === message) { - return { - role: 'bot', - inlineData, - }; - } - return m; - }); - }); - - // Update placeholder artifact with fetched data. - this.artifacts = this.artifacts.map(artifact => { - if (artifact.id === artifactId && - artifact.versionId === versionId) { - return { - id: artifactId, - versionId, - data: base64Data, - mimeType, - mediaType, - }; - } - return artifact; - }); - }, - error: (err) => { - this.handleArtifactFetchFailure(message, artifactId, versionId); + .getArtifactVersion( + this.userId, + this.appName, + this.sessionId, + artifactId, + versionId, + ) + .subscribe({ + next: (res) => { + const { mimeType, data } = res.inlineData ?? {}; + if (!mimeType || !data) { + this.handleArtifactFetchFailure(uiEvent, artifactId, versionId); + return; } - }); + const base64Data = this.formatBase64Data(data, mimeType); + + const mediaType = getMediaTypeFromMimetype(mimeType); + + const inlineData = { + name: this.createDefaultArtifactName(mimeType), + data: base64Data, + mimeType: mimeType, + mediaType, + }; + + uiEvent.inlineData = inlineData; + this.changeDetectorRef.detectChanges(); + + // Update placeholder artifact with fetched data. + this.artifacts = this.artifacts.map(artifact => { + if (artifact.id === artifactId && + artifact.versionId === versionId) { + return { + id: artifactId, + versionId, + data: base64Data, + mimeType, + mediaType, + }; + } + return artifact; + }); + }, + error: (err) => { + this.handleArtifactFetchFailure(uiEvent, artifactId, versionId); + } + }); } private storeEvents(part: any, e: any) { @@ -1301,79 +1451,46 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } private sendOAuthResponse( - func: any, - authResponseUrl: string, - redirectUri: string, + func: any, + authResponseUrl: string, + redirectUri: string, ) { this.longRunningEvents.pop(); - const authResponse: AgentRunRequest = { - appName: this.appName, - userId: this.userId, - sessionId: this.sessionId, - newMessage: { - role: 'user', - parts: [], - }, - }; var authConfig = structuredClone(func.args.authConfig); authConfig.exchangedAuthCredential.oauth2.authResponseUri = authResponseUrl; authConfig.exchangedAuthCredential.oauth2.redirectUri = redirectUri; - authResponse.functionCallEventId = this.functionCallEventId; - authResponse.newMessage.parts.push({ - functionResponse: { - id: func.id, - name: func.name, - response: authConfig, - }, - }); - - let response: any[] = []; - this.agentService.runSse(authResponse).subscribe({ - next: async (chunkJson) => { - response.push(chunkJson); - }, - error: (err) => console.error('SSE error:', err), - complete: () => { - this.processRunSseResponse(response); - }, - }); - } - - protected processRunSseResponse(response: any) { - for (const e of response) { - if (e.content) { - for (let part of e.content.parts) { - this.processPart(e, part); + const content = { + role: 'user', + parts: [ + { + functionResponse: { + id: func.id, + name: func.name, + response: authConfig, + }, } - } - } - } - - - createAgentIconColorClass(agentName: string) { - const agentIconColor = this.stringToColorService.stc(agentName); - - const agentIconColorClass = - `custom-icon-color-${agentIconColor.replace('#', '')}`; + ], + functionCallEventId: this.functionCallEventId + }; - // Inject the style for this unique class - this.injectCustomIconColorStyle(agentIconColorClass, agentIconColor); + this.sendMessage(content); } clickEvent(i: number) { - const message = this.messages()[i]; - const key = message.eventId; + const message = this.uiEvents()[i]; + const key = message.event.id; if (!key) { return; } - // If clicking the already selected event, deselect it - if (this.selectedEvent && this.selectedEvent.id === key) { - this.selectedEvent = undefined; - this.selectedEventIndex = undefined; + // If clicking the already selected event, ensure side panel is open but do not deselect + if (this.selectedMessageIndex === i) { + this.sideDrawer()?.open(); + this.showSidePanel = true; + window.localStorage.setItem('adk-side-panel-visible', 'true'); return; } @@ -1381,16 +1498,48 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { if (message.role === 'user') { this.selectedEvent = this.eventData.get(key); this.selectedEventIndex = this.getIndexOfKeyInMap(key); + this.selectedMessageIndex = i; this.llmRequest = undefined; this.llmResponse = undefined; this.sideDrawer()?.open(); this.showSidePanel = true; + window.localStorage.setItem('adk-side-panel-visible', 'true'); + this.updateRenderedGraph(); + if (this.chatPanel()?.viewMode() !== 'events') { + this.chatPanel()?.onViewModeChange('events'); + } return; } this.sideDrawer()?.open(); this.showSidePanel = true; - this.selectEvent(key); + window.localStorage.setItem('adk-side-panel-visible', 'true'); + this.selectEvent(key, i); + } + + handleJumpToInvocation(invocationId: string) { + const events = this.uiEvents(); + let targetIndex = -1; + let lastUserIndex = -1; + for (let i = 0; i < events.length; i++) { + const e = events[i]; + if (e.role === 'user') { + lastUserIndex = i; + } + if (e.event?.invocationId === invocationId) { + if (lastUserIndex !== -1) { + targetIndex = lastUserIndex; + } + break; + } + } + + if (targetIndex !== -1) { + this.clickEvent(targetIndex); + setTimeout(() => { + this.chatPanel()?.scrollToSelectedMessage(targetIndex); + }, 100); + } } ngOnDestroy(): void { @@ -1408,64 +1557,55 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } this.evalTab()?.resetEvalResults(); this.traceData = []; - this.bottomPanelVisible = false; } - toggleAudioRecording() { + async toggleAudioRecording() { this.isAudioRecording ? this.stopAudioRecording() : - this.startAudioRecording(); + await this.startAudioRecording(); } - startAudioRecording() { - if (this.sessionHasUsedBidi.has(this.sessionId)) { + async startAudioRecording() { + if (this.sessionId && this.sessionHasUsedBidi.has(this.sessionId)) { this.openSnackBar(BIDI_STREAMING_RESTART_WARNING, 'OK'); return; } + // Lazily create a real session if it does not exist + const isSessionActive = await this.ensureSessionActive(); + if (!isSessionActive) { + return; + } + this.isAudioRecording = true; this.streamChatService.startAudioChat({ appName: this.appName, userId: this.userId, sessionId: this.sessionId, }); - this.messages.update( - messages => - [...messages, - {role: 'user', text: 'Speaking...'}, - {role: 'bot', text: 'Speaking...'}, - ]); this.sessionHasUsedBidi.add(this.sessionId); } stopAudioRecording() { + this.audioPlayingService.stopAudio(); this.streamChatService.stopAudioChat(); this.isAudioRecording = false; + if (this.isVideoRecording) { + this.stopVideoRecording(); + } } toggleVideoRecording() { this.isVideoRecording ? this.stopVideoRecording() : - this.startVideoRecording(); + this.startVideoRecording(); } startVideoRecording() { - if (this.sessionHasUsedBidi.has(this.sessionId)) { - this.openSnackBar(BIDI_STREAMING_RESTART_WARNING, 'OK'); - return; - } const videoContainer = this.chatPanel()?.videoContainer; if (!videoContainer) { return; } this.isVideoRecording = true; - this.streamChatService.startVideoChat({ - appName: this.appName, - userId: this.userId, - sessionId: this.sessionId, - videoContainer, - }); - this.messages.update( - messages => [...messages, {role: 'user', text: 'Speaking...'}]); - this.sessionHasUsedBidi.add(this.sessionId); + this.streamChatService.startVideoStreaming(videoContainer); } stopVideoRecording() { @@ -1473,16 +1613,16 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { if (!videoContainer) { return; } - this.streamChatService.stopVideoChat(videoContainer); + this.streamChatService.stopVideoStreaming(videoContainer); this.isVideoRecording = false; } private getAsyncFunctionsFromParts( - pendingIds: any[], parts: any[], invocationId: string) { + pendingIds: any[], parts: any[], invocationId: string) { for (const part of parts) { if (part.functionCall && pendingIds.includes(part.functionCall.id)) { this.longRunningEvents.push( - {function: part.functionCall, invocationId: invocationId}); + { function: part.functionCall, invocationId: invocationId }); } } } @@ -1491,7 +1631,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { return new Promise((resolve, reject) => { // Open OAuth popup const popup = this.safeValuesService.windowOpen( - window, url, 'oauthPopup', 'width=600,height=700'); + window, url, 'oauthPopup', 'width=600,height=700'); if (!popup) { reject('Popup blocked!'); @@ -1503,7 +1643,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { if (event.origin !== window.location.origin) { return; // Ignore messages from unknown sources } - const {authResponseUrl} = event.data; + const { authResponseUrl } = event.data; if (authResponseUrl) { resolve(authResponseUrl); window.removeEventListener('message', listener); @@ -1522,10 +1662,97 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { // Clear selected event when closing the drawer this.selectedEvent = undefined; this.selectedEventIndex = undefined; + this.selectedMessageIndex = undefined; } else { this.sideDrawer()?.open(); } this.showSidePanel = !this.showSidePanel; + window.localStorage.setItem('adk-side-panel-visible', this.showSidePanel.toString()); + } + + toggleAppSelectorDrawer() { + this.showSessionSelectorDrawer = false; + this.showAppSelectorDrawer = !this.showAppSelectorDrawer; + if (this.showAppSelectorDrawer) { + this.appDrawerSearchControl.setValue(''); + } + } + + onSelectorDrawerOpened() { + if (this.showAppSelectorDrawer) { + this.appSearchInput()?.nativeElement.focus(); + } + } + + handleAppSearchKeydown(event: KeyboardEvent) { + if (event.key === 'ArrowDown') { + event.preventDefault(); + event.stopPropagation(); + const firstItem = this.document.querySelector('.app-selector-list .app-selector-item') as HTMLElement; + if (firstItem) { + firstItem.focus(); + } + } + } + + handleAppListKeydown(event: KeyboardEvent) { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') { + return; + } + + event.stopPropagation(); + + const items = Array.from(this.document.querySelectorAll('.app-selector-list .app-selector-item')) as HTMLElement[]; + const currentIndex = items.indexOf(this.document.activeElement as HTMLElement); + + if (currentIndex > -1) { + event.preventDefault(); + if (event.key === 'ArrowDown') { + const nextIndex = currentIndex + 1; + if (nextIndex < items.length) { + items[nextIndex].focus(); + } + } else if (event.key === 'ArrowUp') { + const prevIndex = currentIndex - 1; + if (prevIndex >= 0) { + items[prevIndex].focus(); + } else { + this.appSearchInput()?.nativeElement.focus(); + } + } + } + } + + onAppSelectorDrawerClosed() { + this.showAppSelectorDrawer = false; + } + + toggleSessionSelectorDrawer() { + this.showAppSelectorDrawer = false; + this.showSessionSelectorDrawer = !this.showSessionSelectorDrawer; + } + + onSessionSelectorDrawerClosed() { + this.showSessionSelectorDrawer = false; + } + + onSelectorDrawerClosed() { + this.showAppSelectorDrawer = false; + this.showSessionSelectorDrawer = false; + } + + onSessionSelectedFromDrawer(sessionId: string) { + this.showSessionSelectorDrawer = false; + this.loadSession(sessionId); + } + + onSessionReloadedFromDrawer(sessionId: string) { + this.loadSession(sessionId); + } + + selectAppFromDrawer(app: string) { + this.selectedAppControl.setValue(app); + this.showAppSelectorDrawer = false; } protected handleTabChange(event: any) { @@ -1547,113 +1774,139 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } } - private resetEventsAndMessages({keepMessages}: {keepMessages?: - boolean} = {}) { + private resetEventsAndMessages({ keepMessages }: { + keepMessages?: + boolean + } = {}) { if (!keepMessages) { this.eventData.clear(); - this.messages.set([]); + this.uiEvents.set([]); + this.selectedEvent = undefined; + this.selectedEventIndex = undefined; + this.selectedMessageIndex = undefined; } this.artifacts = []; } private loadTraceData() { + if (!this.sessionId) return; this.eventService.getTrace(this.sessionId) - .pipe(first(), catchError(() => of([]))) - .subscribe(res => { - this.traceData = res; - this.traceService.setEventData(this.eventData); - this.traceService.setMessages(this.messages()); - }); - this.bottomPanelVisible = false; + .pipe(first(), catchError((err) => { console.error('[DEBUG] getTrace error:', err); return of([]); })) + .subscribe(res => { + this.traceData = res; + this.traceService.setEventData(this.eventData); + this.traceService.setMessages(this.uiEvents()); + this.changeDetectorRef.detectChanges(); + }); this.changeDetectorRef.detectChanges(); } + private buildUiEventFromEvent(event: any, reverseOrder: boolean = false): UiEvent { + const isA2aResponse = this.isEventA2aResponse(event); + const parts = isA2aResponse ? + this.combineA2uiDataParts(event.content?.parts) : + event.content?.parts || []; + const partsToProcess = reverseOrder ? [...parts].reverse() : parts; + + const role = event.author === 'user' ? 'user' : 'bot'; + const uiEvent = new UiEvent({ + role, + event + }); + + if (event.errorCode || event.errorMessage) { + uiEvent.error = { + errorCode: event.errorCode, + errorMessage: event.errorMessage + }; + } + + if (event.inputTranscription !== undefined) { + if (typeof event.inputTranscription === 'string') { + uiEvent.event.inputTranscription = { text: event.inputTranscription }; + } + } + if (event.outputTranscription !== undefined) { + if (typeof event.outputTranscription === 'string') { + uiEvent.event.outputTranscription = { text: event.outputTranscription }; + } + } + + partsToProcess.forEach((part: any) => { + if (role === 'bot' && isA2aResponse && this.isA2uiDataPart(part)) { + part = { a2ui: this.extractA2aDataPartJson(part).data }; + } + this.processPartIntoMessage(part, event, uiEvent); + }); + + return uiEvent; + } + + private populateMessages( - events: any[], reverseOrder: boolean = false, - keepOldMessages: boolean = false) { + events: any[], reverseOrder: boolean = false, + keepOldMessages: boolean = false) { this.resetEventsAndMessages({ keepMessages: - keepOldMessages && this.sessionIdOfLoadedMessages === this.sessionId + keepOldMessages && this.sessionIdOfLoadedMessages === this.sessionId }); events.forEach((event: any) => { - const isA2aResponse = this.isEventA2aResponse(event); - const parts = isA2aResponse ? - this.combineA2uiDataParts(event.content?.parts) : - event.content?.parts || []; - const partsToProcess = reverseOrder ? [...parts].reverse() : parts; - - if (event.author === 'user') { - // For user messages, combine all parts into a single message - const userMessage: any = { - role: 'user', - eventId: event.id - }; + this.appendEventRow(event, reverseOrder); + }); - partsToProcess.forEach((part: any) => { - this.processPartIntoMessage(part, event, userMessage); - }); + this.sessionIdOfLoadedMessages = this.sessionId; + } - if (reverseOrder) { - this.messages.update((messages) => [userMessage, ...messages]); - } else { - this.messages.update((messages) => [...messages, userMessage]); - } + private restorePendingLongRunningCalls() { + const messages = this.uiEvents(); + const functionResponses = new Set(); - // Store the event in eventData - if (!this.eventData.has(event.id)) { - this.eventData.set(event.id, event); - this.eventData = new Map(this.eventData); - } - } else { - // For bot messages, combine all parts into a single message - const botMessage: any = { - role: 'bot', - eventId: event.id - }; - - partsToProcess.forEach((part: any) => { - if (isA2aResponse && this.isA2uiDataPart(part)) { - part = {a2ui: this.extractA2aDataPartJson(part).data}; + // Collect all function response IDs + this.uiEvents().forEach(msg => { + if (msg.functionResponses) { + msg.functionResponses.forEach((fr: any) => { + if (fr.id) { + functionResponses.add(fr.id); } - this.processPartIntoMessage(part, event, botMessage); }); + } + }); - if (reverseOrder) { - this.messages.update((messages) => [botMessage, ...messages]); - } else { - this.messages.update((messages) => [...messages, botMessage]); - } - - if (event.actions?.artifactDelta) { - for (const key in event.actions.artifactDelta) { - if (event.actions.artifactDelta.hasOwnProperty(key)) { - this.renderArtifact( - key, event.actions.artifactDelta[key], reverseOrder); - } + // Check each function call to see if it has a response + this.uiEvents().forEach(msg => { + if (msg.functionCalls) { + msg.functionCalls.forEach((fc: any) => { + // Get the event for this message to check longRunningToolIds + const event = msg.event.id ? this.eventData.get(msg.event.id) : null; + const isLongRunning = fc.isLongRunning || + event?.longRunningToolIds?.includes(fc.id); + + // Only restore if it's long-running AND doesn't have a response yet + if (isLongRunning && !functionResponses.has(fc.id)) { + fc.isLongRunning = true; + fc.invocationId = event?.invocationId; + fc.functionCallEventId = msg.event.id || ""; + fc.needsResponse = true; + fc.responseStatus = 'pending'; + fc.userResponse = fc.userResponse || ''; } - } - - // Store the event in eventData - if (!this.eventData.has(event.id)) { - this.eventData.set(event.id, event); - this.eventData = new Map(this.eventData); - } + }); } }); - - this.sessionIdOfLoadedMessages = this.sessionId; } protected updateWithSelectedSession(session: Session) { - if (!session || !session.id || !session.events || !session.state) { + if (!session || !session.id) { return; } this.traceService.resetTraceService(); + this.traceData = []; this.sessionId = session.id; - this.currentSessionState = session.state; + this.currentSessionState = session.state || {}; this.evalCase = null; this.isChatMode.set(true); + this.resetEventsAndMessages(); this.isSessionUrlEnabledObs.subscribe((enabled) => { if (enabled) { @@ -1661,81 +1914,42 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } }); - this.resetEventsAndMessages(); - - session.events.forEach((event: any) => { - if (event.author === 'user') { - // For user messages, combine all parts into a single message - const userMessage: any = { - role: 'user', - eventId: event.id - }; - - event.content?.parts?.forEach((part: any) => { - this.processPartIntoMessage(part, event, userMessage); - }); - - this.messages.update((messages) => [...messages, userMessage]); - - // Store the event in eventData - if (!this.eventData.has(event.id)) { - this.eventData.set(event.id, event); - this.eventData = new Map(this.eventData); - } - } else { - // For bot messages, combine all parts into a single message - const botMessage: any = { - role: 'bot', - eventId: event.id - }; + if (session.events && session.state) { + session.events.forEach((event: any) => { + this.appendEventRow(event, false); - event.content?.parts?.forEach((part: any) => { - this.processPartIntoMessage(part, event, botMessage); - }); - - this.messages.update((messages) => [...messages, botMessage]); - - if (event.actions?.artifactDelta) { + const isBot = event.author !== 'user'; + if (isBot && event.actions?.artifactDelta) { for (const key in event.actions.artifactDelta) { if (event.actions.artifactDelta.hasOwnProperty(key)) { this.renderArtifact(key, event.actions.artifactDelta[key]); } } } + }); - // Store the event in eventData - if (!this.eventData.has(event.id)) { - this.eventData.set(event.id, event); - this.eventData = new Map(this.eventData); - } - } - }); + this.restorePendingLongRunningCalls(); + } - this.eventService.getTrace(this.sessionId) - .pipe(first(), catchError(() => of([]))) - .subscribe(res => { - this.traceData = res; - this.traceService.setEventData(this.eventData); - this.traceService.setMessages(this.messages()); - }); + this.changeDetectorRef.detectChanges(); + + this.loadTraceData(); this.sessionService.canEdit(this.userId, session) - .pipe(first(), catchError(() => of(true))) - .subscribe((canEdit) => { - this.chatPanel()?.canEditSession.set(canEdit); - this.canEditSession.set(canEdit); - }); + .pipe(first(), catchError(() => of(true))) + .subscribe((canEdit) => { + this.chatPanel()?.canEditSession.set(canEdit); + this.canEditSession.set(canEdit); + }); this.featureFlagService.isInfinityMessageScrollingEnabled() - .pipe(first()) - .subscribe((isInfinityMessageScrollingEnabled) => { - if (!isInfinityMessageScrollingEnabled) { - this.populateMessages(session.events || []); - } - this.loadTraceData(); - }); - - this.displayLandingPageContent(); + .pipe(first()) + .subscribe((isInfinityMessageScrollingEnabled) => { + if (!isInfinityMessageScrollingEnabled) { + this.populateMessages(session.events || []); + } + this.loadTraceData(); + }); } protected updateWithSelectedEvalCase(evalCase: EvalCase) { @@ -1756,13 +1970,13 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { let toolUseIndex = 0; for (const toolUse of invocation.intermediateData.toolUses) { const functionCallPart = { - functionCall: {name: toolUse.name, args: toolUse.args}, + functionCall: { name: toolUse.name, args: toolUse.args }, }; this.storeMessage( - functionCallPart, null, 'bot', invocationIndex, {toolUseIndex}); + functionCallPart, null, 'bot', invocationIndex, { toolUseIndex }); toolUseIndex++; - const functionResponsePart = {functionResponse: {name: toolUse.name}}; + const functionResponsePart = { functionResponse: { name: toolUse.name } }; this.storeMessage(functionResponsePart, null, 'bot'); } } @@ -1771,7 +1985,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { let finalResponsePartIndex = 0; for (const part of invocation.finalResponse.parts) { this.storeMessage( - part, null, 'bot', invocationIndex, {finalResponsePartIndex}); + part, null, 'bot', invocationIndex, { finalResponsePartIndex }); finalResponsePartIndex++; } } @@ -1821,21 +2035,21 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.updatedEvalCase = structuredClone(this.evalCase!); this.updatedEvalCase!.conversation[message.invocationIndex] - .intermediateData!.toolUses![message.toolUseIndex] - .args = result; + .intermediateData!.toolUses![message.toolUseIndex] + .args = result; } }); } protected saveEvalCase() { this.evalService - .updateEvalCase( - this.appName, this.evalSetId, this.updatedEvalCase!.evalId, - this.updatedEvalCase!) - .subscribe((res) => { - this.openSnackBar('Eval case updated', 'OK'); - this.resetEditEvalCaseVars(); - }); + .updateEvalCase( + this.appName, this.evalSetId, this.updatedEvalCase!.evalId, + this.updatedEvalCase!) + .subscribe((res) => { + this.openSnackBar('Eval case updated', 'OK'); + this.resetEditEvalCaseVars(); + }); } protected cancelEditEvalCase() { @@ -1860,11 +2074,11 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.isEvalCaseEditing.set(false); message.isEditing = false; message.text = - this.userEditEvalCaseMessage ? this.userEditEvalCaseMessage : ' '; + this.userEditEvalCaseMessage ? this.userEditEvalCaseMessage : ' '; this.updatedEvalCase = structuredClone(this.evalCase!); this.updatedEvalCase!.conversation[message.invocationIndex] - .finalResponse!.parts![message.finalResponsePartIndex] = { + .finalResponse!.parts![message.finalResponsePartIndex] = { text: this.userEditEvalCaseMessage }; @@ -1882,11 +2096,11 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { protected deleteEvalCaseMessage(message: any, index: number) { this.hasEvalCaseChanged.set(true); - this.messages.update((messages) => messages.filter((m, i) => i !== index)); + this.uiEvents.update((uiEvents) => uiEvents.filter((m, i) => i !== index)); this.updatedEvalCase = structuredClone(this.evalCase!); this.updatedEvalCase!.conversation[message.invocationIndex] - .finalResponse!.parts!.splice(message.finalResponsePartIndex, 1); + .finalResponse!.parts!.splice(message.finalResponsePartIndex, 1); } protected editEvalCase() { @@ -1915,16 +2129,20 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } onNewSessionClick() { - this.createSession(); + this.resetToNewSession(); this.eventData.clear(); - this.messages.set([]); + this.uiEvents.set([]); this.artifacts = []; this.traceData = []; - this.bottomPanelVisible = false; // Clear selected event this.selectedEvent = undefined; this.selectedEventIndex = undefined; + this.selectedMessageIndex = undefined; + this.traceService.resetTraceService(); + + // Auto-focus chat input + this.chatPanel()?.focusInput(); // Close eval history if opened if (!!this.evalTab()?.showEvalHistory) { @@ -1932,13 +2150,102 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } } + getToolbarSessionId() { + if (!this.sessionId) { + return 'NEW SESSION'; + } + + const meta = this.currentSessionState?.['__session_metadata__'] as any; + if (meta?.displayName) { + const shortId = this.sessionId.substring(0, 4); + return `[${shortId}] ${meta.displayName}`; + } + return this.sessionId; + } + + getCurrentSessionDisplayName() { + if (!this.sessionId) { + return 'NEW SESSION'; + } + + const meta = this.currentSessionState?.['__session_metadata__'] as any; + return meta?.displayName || this.sessionId; + } + + async copySessionId() { + if (!this.sessionId) { + return; + } + + try { + await navigator.clipboard.writeText(this.sessionId); + this.openSnackBar(this.i18n.sessionIdCopiedMessage, 'OK'); + } catch { + this.openSnackBar(this.i18n.copySessionIdFailedMessage, 'OK'); + } + } + + saveSessionName(newName: string) { + if (!this.sessionId) return; + + const metadataDelta = { + __session_metadata__: { + ...(this.currentSessionState?.['__session_metadata__'] as any || {}), + displayName: newName + } + }; + + this.currentSessionState = { + ...this.currentSessionState, + ...metadataDelta + }; + + this.updatedSessionState.set({ + ...this.updatedSessionState(), + ...metadataDelta + }); + + this.sessionService.updateSession(this.userId, this.appName, this.sessionId, { stateDelta: metadataDelta }).subscribe({ + next: () => { + if (this.sessionTab) { + this.sessionTab.reloadSession(this.sessionId!); + } + if (this.drawerSessionTab()) { + this.drawerSessionTab()!.reloadSession(this.sessionId!); + } + } + }); + } + + get sessionDisplayNameDraft() { + const meta = this.currentSessionState?.['__session_metadata__'] as any; + return meta?.displayName || ''; + } + + saveUserId(updatedUserId: string) { + updatedUserId = updatedUserId.trim(); + if (!updatedUserId) { + this.openSnackBar(this.i18n.invalidUserIdMessage, 'OK'); + return; + } + + this.userId = updatedUserId; + this.isSessionUrlEnabledObs.pipe(take(1)).subscribe((enabled) => { + if (enabled) { + this.updateSelectedSessionUrl(); + } + }); + } + + + onFileSelect(event: Event) { const input = event.target as HTMLInputElement; if (input.files) { for (let i = 0; i < input.files.length; i++) { const file = input.files[i]; const url = this.safeValuesService.createObjectUrl(file); - this.selectedFiles.push({file, url}); + this.selectedFiles.push({ file, url }); } } input.value = ''; @@ -1950,16 +2257,17 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { } toggleSse() { - this.useSse = !this.useSse; + this.useSse.set(!this.useSse()); + window.localStorage.setItem('adk-use-sse', String(this.useSse())); } enterBuilderMode() { const url = this.router - .createUrlTree([], { - queryParams: {mode: 'builder'}, - queryParamsHandling: 'merge', - }) - .toString(); + .createUrlTree([], { + queryParams: { mode: 'builder' }, + queryParamsHandling: 'merge', + }) + .toString(); this.location.replaceState(url); this.isBuilderMode.set(true); @@ -1986,11 +2294,11 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { protected exitBuilderMode() { const url = this.router - .createUrlTree([], { - queryParams: {mode: null}, - queryParamsHandling: 'merge', - }) - .toString(); + .createUrlTree([], { + queryParams: { mode: null }, + queryParamsHandling: 'merge', + }) + .toString(); this.location.replaceState(url); this.isBuilderMode.set(false); this.agentBuilderService.clear(); @@ -2004,70 +2312,609 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.apps$.pipe(take(1)).subscribe((apps) => { const dialogRef = this.dialog.open(AddItemDialogComponent, { width: '600px', - data: {existingAppNames: apps ?? []}, + data: { existingAppNames: apps ?? [] }, }); }); } + eventGraphSvgLight: Record = {}; + eventGraphSvgDark: Record = {}; + selectedEventGraphPath: string = ''; + showAgentStructureOverlay = false; + agentStructureOverlayMode: 'session' | 'event' = 'session'; + + openAgentStructureGraphDialog(mode: 'session' | 'event' = 'session'): void { + this.agentStructureOverlayMode = mode; + this.showAgentStructureOverlay = true; + } + saveAgentBuilder() { this.canvasComponent()?.saveAgent(this.appName); } - selectEvent(key: string) { - this.selectedEvent = this.eventData.get(key); - this.selectedEventIndex = this.getIndexOfKeyInMap(key); + onEventTabDrillDown(path: string) { + this.updateRenderedGraph(undefined, path); + } - let filter = undefined; - if (this.isEventFilteringEnabled() && this.selectedEvent.invocationId && - (this.selectedEvent.timestamp || - this.selectedEvent.timestampInMillis)) { - filter = { - invocationId: this.selectedEvent.invocationId, - timestamp: - this.selectedEvent.timestamp ?? this.selectedEvent.timestampInMillis - }; + updateRenderedGraph(overrideNodePath?: string, overrideGraphPath?: string) { + const sessionGraphSvgLight = this.sessionGraphSvgLight; + const sessionGraphSvgDark = this.sessionGraphSvgDark; + if (Object.keys(sessionGraphSvgLight).length === 0 || Object.keys(sessionGraphSvgDark).length === 0) { + this.renderedEventGraph = undefined; + return; } - const eventTraceParam = {id: this.selectedEvent.id, ...filter}; - this.uiStateService.setIsEventRequestResponseLoading(true); - this.eventService.getEventTrace(eventTraceParam) - .subscribe( - (res) => { - if (res[this.llmRequestKey]) { - this.llmRequest = JSON.parse(res[this.llmRequestKey]); + // Check if we're using v1 backend (only root graph, no workflow paths) + const isV1Backend = Object.keys(sessionGraphSvgLight).length === 1 && '' in sessionGraphSvgLight; + + if (isV1Backend && this.selectedEvent) { + // V1 backend: Simple edge/node highlighting for single event only + const graphPath = ''; + let highlightedSvgLight = sessionGraphSvgLight[graphPath]; + let highlightedSvgDark = sessionGraphSvgDark[graphPath]; + + const highlightPairs = this.getV1HighlightPairs(this.selectedEvent); + highlightedSvgLight = this.applyV1Highlighting(highlightedSvgLight, highlightPairs, false); + highlightedSvgDark = this.applyV1Highlighting(highlightedSvgDark, highlightPairs, true); + + this.selectedEventGraphPath = graphPath; + this.eventGraphSvgLight = { ...sessionGraphSvgLight, [graphPath]: highlightedSvgLight }; + this.eventGraphSvgDark = { ...sessionGraphSvgDark, [graphPath]: highlightedSvgDark }; + const highlightedSvg = this.themeService.currentTheme() === 'dark' ? highlightedSvgDark : highlightedSvgLight; + this.rawSvgString = highlightedSvg; + this.renderedEventGraph = this.safeValuesService.bypassSecurityTrustHtml(highlightedSvg); + return; + } + + // V2 backend: Calculate workflow paths and node names + let nodePath = overrideNodePath || this.selectedEvent?.nodeInfo?.path; + if (!overrideNodePath && this.selectedEvent?.author === 'user') { + nodePath = '__START__'; + } + + let graphPath = overrideGraphPath !== undefined ? overrideGraphPath : ''; + let nodeName = ''; + + if (nodePath && overrideGraphPath === undefined) { + const segments = nodePath.split('/'); + nodeName = segments[segments.length - 1]; + + if (segments.length >= 2 && segments[segments.length - 1] === 'call_llm' && segments[segments.length - 2] === this.selectedEvent?.author) { + nodeName = segments[segments.length - 2]; + graphPath = segments.slice(1, -2).join('/'); + } else { + graphPath = segments.slice(1, -1).join('/'); + } + + if (!(graphPath in sessionGraphSvgLight)) { + graphPath = ''; + } + } + + let highlightedSvgLight = sessionGraphSvgLight[graphPath] || sessionGraphSvgLight[''] || Object.values(sessionGraphSvgLight)[0] || ''; + let highlightedSvgDark = sessionGraphSvgDark[graphPath] || sessionGraphSvgDark[''] || Object.values(sessionGraphSvgDark)[0] || ''; + + const runNodeNames: string[] = []; + const allRunNodeNames: string[] = []; + if (this.selectedEventIndex !== undefined) { + // V2 backend: use existing workflow path logic + const eventArray = Array.from(this.eventData.values()); + const selectedEvent: any = eventArray[this.selectedEventIndex]; + const targetInvocationId = selectedEvent?.invocationId; + + for (let i = 0; i < eventArray.length; i++) { + const ev: any = eventArray[i]; + + if (targetInvocationId && ev.invocationId !== targetInvocationId) { + continue; + } + + let np = ev.nodeInfo?.path; + if (ev.author === 'user') { + np = '__START__'; + } + + if (np) { + const segments = np.split('/'); + let evNodeName = segments[segments.length - 1]; + let evGraphPath = ''; + + if (segments.length >= 2 && segments[segments.length - 1] === 'call_llm' && segments[segments.length - 2] === ev.author) { + evNodeName = segments[segments.length - 2]; + evGraphPath = segments.slice(1, -2).join('/'); + } else { + evGraphPath = segments.slice(1, -1).join('/'); + } + + if (evGraphPath === graphPath) { + if (i <= this.selectedEventIndex) { + if (runNodeNames.length === 0 || runNodeNames[runNodeNames.length - 1] !== evNodeName) { + runNodeNames.push(evNodeName); } - if (res[this.llmResponseKey]) { - this.llmResponse = JSON.parse(res[this.llmResponseKey]); + } + if (allRunNodeNames.length === 0 || allRunNodeNames[allRunNodeNames.length - 1] !== evNodeName) { + allRunNodeNames.push(evNodeName); + } + } + } + } + } + + if (allRunNodeNames.length > 0 && highlightedSvgLight && highlightedSvgDark) { + highlightedSvgLight = this.highlightExecutionPathInSvg(highlightedSvgLight, runNodeNames, allRunNodeNames, 'light'); + highlightedSvgDark = this.highlightExecutionPathInSvg(highlightedSvgDark, runNodeNames, allRunNodeNames, 'dark'); + } + + this.selectedEventGraphPath = graphPath; + this.eventGraphSvgLight = { ...sessionGraphSvgLight, [graphPath]: highlightedSvgLight }; + this.eventGraphSvgDark = { ...sessionGraphSvgDark, [graphPath]: highlightedSvgDark }; + + const highlightedSvg = this.themeService.currentTheme() === 'dark' ? highlightedSvgDark : highlightedSvgLight; + + this.rawSvgString = highlightedSvg; + this.renderedEventGraph = this.safeValuesService.bypassSecurityTrustHtml(highlightedSvg); + } + + highlightExecutionPathInSvg(svgString: string, runNodeNames: string[], allRunNodeNames: string[], theme: 'light' | 'dark' = 'light'): string { + if (!allRunNodeNames || allRunNodeNames.length === 0) return svgString; + + const parser = new DOMParser(); + const doc = parser.parseFromString(svgString, 'image/svg+xml'); + + const reverseAdjacencyList = new Map(); + const forwardAdjacencyList = new Map(); + const edgeElements = doc.querySelectorAll('g.edge'); + + edgeElements.forEach((edgeElement) => { + const titleElement = edgeElement.querySelector('title'); + let title = titleElement?.textContent?.trim() || ''; + if (title.includes('->')) { + const parts = title.split('->'); + const from = parts[0].trim().replace(/^"|"$/g, ''); + const to = parts[1].trim().replace(/^"|"$/g, ''); + + if (!reverseAdjacencyList.has(to)) reverseAdjacencyList.set(to, []); + reverseAdjacencyList.get(to)!.push(from); + if (!forwardAdjacencyList.has(from)) forwardAdjacencyList.set(from, []); + forwardAdjacencyList.get(from)!.push(to); + } + }); + + const nodeNameToId = new Map(); + const nodeElements = doc.querySelectorAll('g.node'); + nodeElements.forEach((nodeElement) => { + const textElements = Array.from(nodeElement.querySelectorAll('text')); + const textContent = textElements.map(t => t.textContent?.trim() || '').join(''); + + const titleElement = nodeElement.querySelector('title'); + const titleName = titleElement?.textContent?.trim() || ''; + const cleanTitleName = titleName.replace(/^"|"$/g, ''); + + nodeNameToId.set(textContent, cleanTitleName); + if (titleName) { + nodeNameToId.set(titleName, cleanTitleName); + } + }); + + const targetNodeIds = runNodeNames.map(name => { + for (const [text, id] of nodeNameToId.entries()) { + if (text === name || text.includes(name) || text === `"${name}"`) { + return id; + } + } + return null; + }).filter(id => id) as string[]; + + const allTargetNodeIds = allRunNodeNames.map(name => { + for (const [text, id] of nodeNameToId.entries()) { + if (text === name || text.includes(name) || text === `"${name}"`) { + return id; + } + } + return null; + }).filter(id => id) as string[]; + + const { visitedNodes, visitedEdges } = this.calculateVisitedPath(targetNodeIds, reverseAdjacencyList); + const { visitedNodes: allVisitedNodes } = this.calculateVisitedPath(allTargetNodeIds, reverseAdjacencyList); + const edgeCounts = this.calculateEdgeCounts(targetNodeIds, visitedNodes, visitedEdges, forwardAdjacencyList); + + const visitedEdgeColor = theme === 'dark' ? '#34a853' : '#a1c2a1'; + const activeStrokeColor = theme === 'dark' ? '#ceead6' : '#0d652d'; + const activeFillColor = theme === 'dark' ? '#137333' : '#a6d8b5'; + const visitedStrokeColor = theme === 'dark' ? '#34a853' : '#a1c2a1'; + const visitedFillColor = theme === 'dark' ? '#0d652d' : '#e6f4ea'; + + edgeElements.forEach((edgeElement) => { + const titleElement = edgeElement.querySelector('title'); + let title = titleElement?.textContent?.trim() || ''; + if (title.includes('->')) { + const parts = title.split('->'); + const from = parts[0].trim().replace(/^"|"$/g, ''); + const to = parts[1].trim().replace(/^"|"$/g, ''); + const edgeKey = `${from}->${to}`; + + if (visitedEdges.has(edgeKey)) { + const shape = edgeElement.querySelector('path'); + if (shape) { + shape.setAttribute('stroke', visitedEdgeColor); + shape.setAttribute('stroke-width', '2'); + } + const polygon = edgeElement.querySelector('polygon'); + if (polygon) { + polygon.setAttribute('fill', visitedEdgeColor); + polygon.setAttribute('stroke', visitedEdgeColor); + } + + const count = edgeCounts.get(edgeKey) || 0; + if (count > 1) { + const existingText = edgeElement.querySelector('text'); + if (existingText) { + existingText.textContent = `${existingText.textContent} (${count}x)`; + existingText.setAttribute('fill', theme === 'dark' ? '#ffffff' : '#000000'); + existingText.setAttribute('font-weight', 'bold'); + } else if (shape) { + const d = shape.getAttribute('d') || ''; + const matches = [...d.matchAll(/[-+]?[0-9]*\.?[0-9]+/g)]; + if (matches.length >= 4) { + const nums = matches.map(m => parseFloat(m[0])); + const mx = (nums[0] + nums[nums.length - 2]) / 2; + const my = (nums[1] + nums[nums.length - 1]) / 2; + + const badgeGroup = doc.createElementNS("http://www.w3.org/2000/svg", "g"); + const badge = doc.createElementNS("http://www.w3.org/2000/svg", "rect"); + badge.setAttribute('x', (mx - 14).toString()); + badge.setAttribute('y', (my - 10).toString()); + badge.setAttribute('width', '28'); + badge.setAttribute('height', '20'); + badge.setAttribute('rx', '4'); + badge.setAttribute('fill', theme === 'dark' ? '#0d652d' : '#e6f4ea'); + badge.setAttribute('stroke', visitedEdgeColor); + badge.setAttribute('stroke-width', '1'); + badgeGroup.appendChild(badge); + + const txt = doc.createElementNS("http://www.w3.org/2000/svg", "text"); + txt.setAttribute('x', mx.toString()); + txt.setAttribute('y', (my + 4).toString()); + txt.setAttribute('text-anchor', 'middle'); + txt.setAttribute('fill', theme === 'dark' ? '#ffffff' : '#000000'); + txt.setAttribute('font-size', '12px'); + txt.setAttribute('font-weight', 'bold'); + txt.textContent = count.toString() + 'x'; + badgeGroup.appendChild(txt); + + edgeElement.appendChild(badgeGroup); } - this.uiStateService.setIsEventRequestResponseLoading(false); - }, - () => { - this.uiStateService.setIsEventRequestResponseLoading(false); - }); - this.eventService - .getEvent( - this.userId, - this.appName, - this.sessionId, - this.selectedEvent.id, - ) - .subscribe(async (res) => { - if (!res.dotSrc) { - this.renderedEventGraph = undefined; - return; + } } - const svg = await this.graphService.render(res.dotSrc); - this.rawSvgString = svg; - this.renderedEventGraph = - this.safeValuesService.bypassSecurityTrustHtml(svg); + } + } + }); + + const lastTargetId = targetNodeIds[targetNodeIds.length - 1]; + + nodeElements.forEach((nodeElement) => { + const titleElement = nodeElement.querySelector('title'); + const titleName = titleElement?.textContent?.trim().replace(/^"|"$/g, '') || ''; + + if (visitedNodes.has(titleName)) { + const shape = nodeElement.querySelector('ellipse, polygon, path, rect'); + if (shape) { + const isActive = titleName === lastTargetId || titleName.toLowerCase() === '__end__'; + shape.setAttribute('stroke', isActive ? activeStrokeColor : visitedStrokeColor); + shape.setAttribute('fill', isActive ? activeFillColor : visitedFillColor); + shape.setAttribute('stroke-width', isActive ? '4' : '2'); + } + } + + if (!allVisitedNodes.has(titleName)) { + nodeElement.classList.add('unvisited-node'); + const shape = nodeElement.querySelector('ellipse, polygon, path, rect'); + if (shape) { + shape.setAttribute('stroke', theme === 'dark' ? '#666666' : '#b0b0b0'); + shape.setAttribute('fill', theme === 'dark' ? '#424242' : '#e0e0e0'); + const shapeTitle = doc.createElementNS('http://www.w3.org/2000/svg', 'title'); + shapeTitle.textContent = 'Not run in this invocation'; + shape.appendChild(shapeTitle); + } + const textElements = nodeElement.querySelectorAll('text'); + textElements.forEach((t) => { + t.setAttribute('fill', theme === 'dark' ? '#888888' : '#757575'); + const textTitle = doc.createElementNS('http://www.w3.org/2000/svg', 'title'); + textTitle.textContent = 'Not run in this invocation'; + t.appendChild(textTitle); + }); + if (titleElement) { + titleElement.textContent = 'Not run in this invocation'; + } else { + const newTitle = doc.createElementNS('http://www.w3.org/2000/svg', 'title'); + newTitle.textContent = 'Not run in this invocation'; + nodeElement.appendChild(newTitle); + } + const aElements = nodeElement.querySelectorAll('a'); + aElements.forEach((aElem) => { + aElem.setAttribute('title', 'Not run in this invocation'); + aElem.setAttributeNS('http://www.w3.org/1999/xlink', 'title', 'Not run in this invocation'); }); + } + }); + + return new XMLSerializer().serializeToString(doc); + } + + /** + * Extract highlight pairs (edge tuples) for v1 backend. + * Mimics v1 backend logic from adk_web_server.py:1960-1983 + */ + private getV1HighlightPairs(event: any): [string, string][] { + const pairs: [string, string][] = []; + + const functionCalls = event.content?.parts?.filter((p: any) => p.functionCall) || []; + const functionResponses = event.content?.parts?.filter((p: any) => p.functionResponse) || []; + + if (functionCalls.length > 0) { + // If event has function calls: highlight edges from author -> function_call.name + for (const part of functionCalls) { + if (part.functionCall?.name && event.author) { + pairs.push([event.author, part.functionCall.name]); + } + } + } else if (functionResponses.length > 0) { + // If event has function responses: highlight edges from function_response.name -> author + for (const part of functionResponses) { + if (part.functionResponse?.name && event.author) { + pairs.push([part.functionResponse.name, event.author]); + } + } + } else { + // Otherwise: just highlight the author node (edge to empty string) + if (event.author) { + pairs.push([event.author, '']); + } + } + + return pairs; + } + + /** + * Apply v1-style highlighting to SVG. + */ + private applyV1Highlighting(svgString: string, highlightPairs: [string, string][], isDarkMode: boolean): string { + const parser = new DOMParser(); + const doc = parser.parseFromString(svgString, 'image/svg+xml'); + + const darkGreen = '#0F5223'; + const lightGreen = '#69CB87'; + const textColor = isDarkMode ? '#cccccc' : '#000000'; + + // Get all node names that appear in highlight pairs + const highlightedNodeNames = new Set(); + for (const [from, to] of highlightPairs) { + if (from) highlightedNodeNames.add(from); + if (to) highlightedNodeNames.add(to); + } + + // Highlight nodes + const nodeElements = doc.querySelectorAll('g.node'); + nodeElements.forEach((nodeElement) => { + const titleElement = nodeElement.querySelector('title'); + const nodeName = titleElement?.textContent?.trim().replace(/^"|"$/g, '') || ''; + + const textElements = nodeElement.querySelectorAll('text'); + + if (highlightedNodeNames.has(nodeName)) { + // Highlight this node with dark green + const ellipse = nodeElement.querySelector('ellipse, polygon, path'); + if (ellipse) { + ellipse.setAttribute('fill', darkGreen); + ellipse.setAttribute('stroke', darkGreen); + } + textElements.forEach(t => t.setAttribute('fill', textColor)); + } else { + // Unhighlighted nodes: set text color based on theme + textElements.forEach(t => t.setAttribute('fill', textColor)); + } + }); + + // Highlight edges + const edgeElements = doc.querySelectorAll('g.edge'); + edgeElements.forEach((edgeElement) => { + const titleElement = edgeElement.querySelector('title'); + const title = titleElement?.textContent?.trim() || ''; + + if (title.includes('->')) { + const [fromRaw, toRaw] = title.split('->'); + const from = fromRaw.trim().replace(/^"|"$/g, ''); + const to = toRaw.trim().replace(/^"|"$/g, ''); + + // Check if this edge matches any highlight pair + for (const [highlightFrom, highlightTo] of highlightPairs) { + if ((from === highlightFrom && to === highlightTo) || + (from === highlightTo && to === highlightFrom)) { + // Highlight this edge with light green + const pathElement = edgeElement.querySelector('path'); + if (pathElement) { + pathElement.setAttribute('stroke', lightGreen); + } + const polygonElement = edgeElement.querySelector('polygon'); + if (polygonElement) { + polygonElement.setAttribute('stroke', lightGreen); + polygonElement.setAttribute('fill', lightGreen); + } + break; + } + } + } + }); + + return new XMLSerializer().serializeToString(doc); + } + + private calculateVisitedPath(targetNodeIds: string[], reverseAdjacencyList: Map): { visitedNodes: Set, visitedEdges: Set } { + const visitedNodes = new Set(targetNodeIds); + let added = true; + + while (added) { + added = false; + const currentVisited = Array.from(visitedNodes); + for (const node of currentVisited) { + const parents = reverseAdjacencyList.get(node) || []; + if (parents.length === 1) { + const parent = parents[0]; + if (!visitedNodes.has(parent)) { + visitedNodes.add(parent); + added = true; + } + } + } + } + + // "light up end if any of END's incoming node is visited or active" + for (const [nodeId, parents] of reverseAdjacencyList.entries()) { + if (nodeId.toLowerCase() === '__end__') { + for (const parent of parents) { + if (visitedNodes.has(parent)) { + visitedNodes.add(nodeId); + break; + } + } + } + } + + const visitedEdges = new Set(); + + for (const node of visitedNodes) { + if (node === '__start__') continue; + + const parents = reverseAdjacencyList.get(node) || []; + if (parents.length === 1) { + visitedEdges.add(`${parents[0]}->${node}`); + } else if (parents.length > 1) { + // "only highlight the edge between visited node and the node with multiple inward edge" + for (const parent of parents) { + if (visitedNodes.has(parent) || parent === '__start__') { + visitedEdges.add(`${parent}->${node}`); + } + } + } + } + + return { visitedNodes, visitedEdges }; + } + + private calculateEdgeCounts( + sequence: string[], + visitedNodes: Set, + visitedEdges: Set, + adjacencyList: Map + ): Map { + const edgeCounts = new Map(); + const fullSeq = [...sequence]; + + const startNode = Array.from(visitedNodes).find(n => n.toLowerCase() === '__start__'); + const endNode = Array.from(visitedNodes).find(n => n.toLowerCase() === '__end__'); + + if (fullSeq.length > 0 && fullSeq[0].toLowerCase() !== '__start__' && startNode) { + fullSeq.unshift(startNode); + } + if (fullSeq.length > 0 && endNode) { + if (fullSeq[fullSeq.length - 1].toLowerCase() !== '__end__') { + fullSeq.push(endNode); + } + } + + for (let i = 0; i < fullSeq.length - 1; i++) { + const src = fullSeq[i]; + const dst = fullSeq[i + 1]; + + let foundPath: string[] | null = null; + const queue: { node: string, path: string[] }[] = []; + const visited = new Set(); + + const initialChildren = adjacencyList.get(src) || []; + for (const child of initialChildren) { + const edgeKey = `${src}->${child}`; + if (visitedEdges.has(edgeKey)) { + queue.push({ node: child, path: [edgeKey] }); + visited.add(child); + } + } + + while (queue.length > 0) { + const current = queue.shift()!; + if (current.node === dst) { + foundPath = current.path; + break; + } + + const children = adjacencyList.get(current.node) || []; + for (const child of children) { + const edgeKey = `${current.node}->${child}`; + if (visitedEdges.has(edgeKey) && !visited.has(child)) { + visited.add(child); + queue.push({ node: child, path: [...current.path, edgeKey] }); + } + } + } + + if (foundPath) { + for (const edge of foundPath) { + edgeCounts.set(edge, (edgeCounts.get(edge) || 0) + 1); + } + } + } + + return edgeCounts; + } + + selectEvent(key: string, messageIndex?: number) { + this.traceService.selectedRow(undefined); + this.selectedEvent = this.eventData.get(key); + this.selectedEventIndex = this.getIndexOfKeyInMap(key); + this.selectedMessageIndex = messageIndex !== undefined ? messageIndex : this.uiEvents().findIndex(msg => msg.event.id === key); + + if (this.chatPanel()?.viewMode() !== 'events') { + this.chatPanel()?.onViewModeChange('events'); + } + + // Auto-scroll to the selected event row in the chat panel + this.chatPanel()?.scrollToSelectedMessage(this.selectedMessageIndex); + + this.llmRequest = undefined; + this.llmResponse = undefined; + + const matchingSpan = this.traceData?.find( + (span: any) => span?.attributes?.['gcp.vertex.agent.event_id'] === this.selectedEvent.id && span?.name === 'call_llm' + ); + + if (matchingSpan) { + const requestStr = matchingSpan.attributes?.[this.llmRequestKey]; + const responseStr = matchingSpan.attributes?.[this.llmResponseKey]; + + if (requestStr) { + try { + this.llmRequest = typeof requestStr === 'string' ? JSON.parse(requestStr) : requestStr; + } catch (e) { + console.warn('Failed to parse LLM request', e); + } + } + + if (responseStr) { + try { + this.llmResponse = typeof responseStr === 'string' ? JSON.parse(responseStr) : responseStr; + } catch (e) { + console.warn('Failed to parse LLM response', e); + } + } + } + + this.updateRenderedGraph(); } deleteSession(session: string) { const dialogData: DeleteSessionDialogData = { title: 'Confirm delete', message: - `Are you sure you want to delete this session ${this.sessionId}?`, + `Are you sure you want to delete this session ${this.sessionId}?`, confirmButtonText: 'Delete', cancelButtonText: 'Cancel', }; @@ -2080,14 +2927,14 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { dialogRef.afterClosed().subscribe((result: boolean) => { if (result) { this.sessionService.deleteSession(this.userId, this.appName, session) - .subscribe((res) => { - const nextSession = this.sessionTab?.refreshSession(session); - if (nextSession) { - this.sessionTab?.getSession(nextSession.id); - } else { - window.location.reload(); - } - }); + .subscribe((res) => { + const nextSession = this.sessionTab?.refreshSession(session); + if (nextSession) { + this.sessionTab?.getSession(nextSession.id); + } else { + window.location.reload(); + } + }); } }); } @@ -2103,10 +2950,125 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { return; } - this.selectedAppControl.setValue(app, {emitEvent: false}); + this.selectedAppControl.setValue(app, { emitEvent: false }); this.selectApp(app); + this.agentService.getAppInfo(app).subscribe(info => { + this.agentGraphData = info; + this.agentReadme = info?.readme || ''; + }) + this.sessionGraphSvgLight = {}; + this.sessionGraphSvgDark = {}; + this.graphsAvailable = true; + + // Try v2 approach first (getAppGraphImage for light mode) + this.agentService.getAppGraphImage(app, false).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 404) { + // V2 endpoint not available, try v1 fallback with light theme + return this.agentService.getAppGraphDot(app, false).pipe( + catchError(() => { + this.graphsAvailable = false; + return of(null); + }) + ); + } + return of(null); + }) + ).subscribe({ + next: async (res) => { + try { + if (res) { + console.log('Light mode graph response:', res); + // Check if this is v1 response (single dotSrc) or v2 response (path->graph map) + if (res.dotSrc && typeof res.dotSrc === 'string') { + // V1 response - render light theme graph + this.sessionGraphSvgLight = {}; + this.sessionGraphSvgLight[''] = await this.graphService.render(res.dotSrc); + if (this.selectedEvent && this.selectedEventIndex !== undefined) { + this.updateRenderedGraph(); + } + } else { + // V2 response - render each path's graph + this.sessionGraphSvgLight = {}; + for (const [path, graph] of Object.entries(res)) { + if ((graph as any)?.dotSrc) { + // Normalize path: map "root_agent" to "" for consistency with dialog expectations + const normalizedPath = path === 'root_agent' ? '' : path; + this.sessionGraphSvgLight[normalizedPath] = await this.graphService.render((graph as any).dotSrc); + } + } + console.log('sessionGraphSvgLight after rendering:', Object.keys(this.sessionGraphSvgLight)); + console.log('graphsAvailable:', this.graphsAvailable); + if (this.selectedEvent && this.selectedEventIndex !== undefined) { + this.updateRenderedGraph(); + } + } + } + } catch (error) { + console.error('Error rendering light mode graphs:', error); + this.graphsAvailable = false; + } + }, + error: (error) => { + console.error('Error fetching light mode graphs:', error); + this.graphsAvailable = false; + } + }); - this.agentService.getAgentBuilder(app).subscribe((res: any) => { + // For dark mode, try v2 first, then v1 fallback with dark theme + this.agentService.getAppGraphImage(app, true).pipe( + catchError((error: HttpErrorResponse) => { + if (error.status === 404) { + // V1 fallback - fetch dark theme graph + return this.agentService.getAppGraphDot(app, true).pipe( + catchError(() => of(null)) + ); + } + return of(null); + }) + ).subscribe({ + next: async (res) => { + try { + if (res) { + if (res.dotSrc && typeof res.dotSrc === 'string') { + // V1 response + this.sessionGraphSvgDark = {}; + this.sessionGraphSvgDark[''] = await this.graphService.render(res.dotSrc); + if (this.selectedEvent && this.selectedEventIndex !== undefined) { + this.updateRenderedGraph(); + } + } else { + // V2 response + this.sessionGraphSvgDark = {}; + for (const [path, graph] of Object.entries(res)) { + if ((graph as any)?.dotSrc) { + // Normalize path: map "root_agent" to "" for consistency with dialog expectations + const normalizedPath = path === 'root_agent' ? '' : path; + this.sessionGraphSvgDark[normalizedPath] = await this.graphService.render((graph as any).dotSrc); + } + } + if (this.selectedEvent && this.selectedEventIndex !== undefined) { + this.updateRenderedGraph(); + } + } + } + } catch (error) { + console.error('Error rendering dark mode graphs:', error); + this.graphsAvailable = false; + } + }, + error: (error) => { + console.error('Error fetching dark mode graphs:', error); + this.graphsAvailable = false; + } + }); + this.agentService.getAgentBuilder(app).pipe( + catchError((error: HttpErrorResponse) => { + this.disableBuilderSwitch = true; + this.agentBuilderService.setLoadedAgentData(undefined); + return of(''); + }) + ).subscribe((res: any) => { if (!res || res == '') { this.disableBuilderSwitch = true; this.agentBuilderService.setLoadedAgentData(undefined); @@ -2125,35 +3087,51 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { private updateSelectedAppUrl() { this.selectedAppControl.valueChanges - .pipe(distinctUntilChanged(), filter(Boolean)) - .subscribe((app: string) => { - this.selectApp(app); + .pipe(distinctUntilChanged(), filter(Boolean)) + .subscribe((app: string) => { + this.selectApp(app); - // Navigate if selected app changed. - const selectedAgent = this.activatedRoute.snapshot.queryParams['app']; - if (app === selectedAgent) { - return; - } - this.router.navigate([], { - queryParams: {'app': app, 'mode': null}, - queryParamsHandling: 'merge', - }); + // Navigate if selected app changed. + const selectedAgent = this.activatedRoute.snapshot.queryParams['app']; + if (app === selectedAgent) { + return; + } + this.router.navigate([], { + queryParams: { 'app': app, 'mode': null }, + queryParamsHandling: 'merge', }); + }); } private updateSelectedSessionUrl() { const url = this.router - .createUrlTree([], { - queryParams: { - 'session': this.sessionId, - 'userId': this.userId, - }, - queryParamsHandling: 'merge', - }) - .toString(); + .createUrlTree([], { + queryParams: { + 'session': this.sessionId, + 'userId': this.userId, + }, + queryParamsHandling: 'merge', + }) + .toString(); this.location.replaceState(url); } + private clearSessionUrl() { + this.isSessionUrlEnabledObs.pipe(first()).subscribe((enabled) => { + if (enabled) { + const url = this.router + .createUrlTree([], { + queryParams: { + 'session': null, + }, + queryParamsHandling: 'merge', + }) + .toString(); + this.location.replaceState(url); + } + }); + } + handlePageEvent(event: any) { if (event.pageIndex >= 0) { const key = this.getKeyAtIndexInMap(event.pageIndex); @@ -2162,12 +3140,12 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { // Scroll to the corresponding message in the chat panel setTimeout(() => { - const messageIndex = this.messages().findIndex(msg => msg.eventId === key); + const messageIndex = this.uiEvents().findIndex(msg => msg.event.id === key); if (messageIndex !== -1) { const scrollContainer = this.chatPanel()?.scrollContainer?.nativeElement; if (!scrollContainer) return; - const messageElements = scrollContainer.querySelectorAll('.message-column-container'); + const messageElements = scrollContainer.querySelectorAll('.message-row-container'); if (messageElements && messageElements[messageIndex]) { messageElements[messageIndex].scrollIntoView({ behavior: 'smooth', @@ -2184,6 +3162,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { closeSelectedEvent() { this.selectedEvent = undefined; this.selectedEventIndex = undefined; + this.selectedMessageIndex = undefined; } @HostListener('window:keydown', ['$event']) @@ -2192,18 +3171,19 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { event.preventDefault(); this.selectedEvent = undefined; this.selectedEventIndex = undefined; + this.selectedMessageIndex = undefined; } } - private getIndexOfKeyInMap(key: string): number|undefined { + private getIndexOfKeyInMap(key: string): number | undefined { let index = 0; const mapOrderPreservingSort = (a: any, b: any): number => - 0; // Simple compare function + 0; // Simple compare function const sortedKeys = Array.from(this.eventData.keys()) - .sort( - mapOrderPreservingSort, - ); + .sort( + mapOrderPreservingSort, + ); for (const k of sortedKeys) { if (k === key) { @@ -2214,14 +3194,14 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { return undefined; // Key not found } - private getKeyAtIndexInMap(index: number): string|undefined { + private getKeyAtIndexInMap(index: number): string | undefined { const mapOrderPreservingSort = (a: any, b: any): number => - 0; // Simple compare function + 0; // Simple compare function const sortedKeys = Array.from(this.eventData.keys()) - .sort( - mapOrderPreservingSort, - ); + .sort( + mapOrderPreservingSort, + ); if (index >= 0 && index < sortedKeys.length) { return sortedKeys[index]; @@ -2229,8 +3209,12 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { return undefined; // Index out of bounds } - openSnackBar(message: string, action: string) { - this._snackBar.open(message, action); + openSnackBar(message: string, action?: string, duration?: number) { + if (duration !== undefined) { + this._snackBar.open(message, action, { duration }); + } else { + this._snackBar.open(message, action); + } } private processThoughtText(text: string) { @@ -2241,7 +3225,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.safeValuesService.windowOpen(window, url, '_blank'); } - openViewImageDialog(imageData: string|null) { + openViewImageDialog(imageData: string | null) { const dialogRef = this.dialog.open(ViewImageDialogComponent, { maxWidth: '90vw', maxHeight: '90vh', @@ -2261,11 +3245,11 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { protected exportSession() { this.sessionService.getSession(this.userId, this.appName, this.sessionId) - .subscribe((res) => { - console.log(res); - this.downloadService.downloadObjectAsJson( - res, `session-${this.sessionId}.json`); - }); + .subscribe((res) => { + console.log(res); + this.downloadService.downloadObjectAsJson( + res, `session-${this.sessionId}.json`); + }); } updateState() { @@ -2289,11 +3273,7 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { this.updatedSessionState.set(null); } - closeTraceEventDetailPanel() { - this.bottomPanelVisible = false; - this.traceService.selectedRow(undefined); - this.traceService.setHoveredMessages(undefined, '') - } + protected importSession() { const input = document.createElement('input'); @@ -2312,19 +3292,31 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { if (e.target?.result) { try { const sessionData = - JSON.parse(e.target.result as string) as Session; - if (!sessionData.userId || !sessionData.appName || - !sessionData.events) { - this.openSnackBar('Invalid session file format', 'OK'); + JSON.parse(e.target.result as string) as Session; + if (!sessionData.events || sessionData.events.length === 0) { + this.openSnackBar('Invalid session file: no events found', 'OK'); return; } - this.sessionService - .importSession( - sessionData.userId, sessionData.appName, sessionData.events) - .subscribe((res) => { - this.openSnackBar('Session imported', 'OK'); - this.sessionTab?.refreshSession(); - }); + + if (sessionData.appName && sessionData.appName !== this.appName) { + const dialogData: DeleteSessionDialogData = { + title: 'App name mismatch', + message: `The session file was exported from app "${sessionData.appName}" but the current app is "${this.appName}". Do you want to import it anyway?`, + confirmButtonText: 'Import', + cancelButtonText: 'Cancel', + }; + const dialogRef = this.dialog.open(DeleteSessionDialogComponent, { + width: '600px', + data: dialogData, + }); + dialogRef.afterClosed().subscribe((confirmed: boolean) => { + if (confirmed) { + this.doImportSession(sessionData); + } + }); + } else { + this.doImportSession(sessionData); + } } catch (error) { this.openSnackBar('Error parsing session file', 'OK'); } @@ -2337,27 +3329,19 @@ export class ChatComponent implements OnInit, AfterViewInit, OnDestroy { input.click(); } - // Helper method to dynamically inject the style - private injectCustomIconColorStyle(className: string, color: string): void { - // Check if the style already exists to prevent duplicates - if (this.document.getElementById(className)) { - return; - } - - const style = this.renderer.createElement('style'); - this.renderer.setAttribute( - style, 'id', className); // Set an ID to check for existence later - this.renderer.setAttribute(style, 'type', 'text/css'); + private doImportSession(sessionData: Session) { + const now = Date.now() / 1000; + const events = sessionData.events!.map(event => ({ + ...event, + timestamp: now, + })); - // Define the CSS - const css = ` - .${className} { - background-color: ${color} !important; - } - `; - - this.renderer.appendChild(style, this.renderer.createText(css)); - this.renderer.appendChild( - this.document.head, style); // Append to the head of the document + this.sessionService + .importSession(this.userId, this.appName, events, sessionData.state) + .subscribe((res) => { + this.openSnackBar( + `Session imported successfully (ID: ${res.id})`, 'OK'); + this.sessionTab?.refreshSession(); + }); } } diff --git a/src/app/components/code-editor/code-editor.component.scss b/src/app/components/code-editor/code-editor.component.scss index 1bfc8f69..ed03dc06 100644 --- a/src/app/components/code-editor/code-editor.component.scss +++ b/src/app/components/code-editor/code-editor.component.scss @@ -13,7 +13,6 @@ /* Style the linting tooltips to match the dark theme */ ::ng-deep .cm-tooltip-lint { - background-color: #3a3f4b; border: 1px solid rgba(255, 255, 255, 0.12); color: #c9d1d9; } diff --git a/src/app/components/code-editor/code-editor.component.ts b/src/app/components/code-editor/code-editor.component.ts index 1750fee7..131aa14c 100644 --- a/src/app/components/code-editor/code-editor.component.ts +++ b/src/app/components/code-editor/code-editor.component.ts @@ -90,7 +90,7 @@ const pythonLinter = linter((view) => { }); @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-code-editor', templateUrl: './code-editor.component.html', styleUrls: ['./code-editor.component.scss'], diff --git a/src/app/components/computer-action/computer-action.component.scss b/src/app/components/computer-action/computer-action.component.scss index 2650c6af..f92b6ca8 100644 --- a/src/app/components/computer-action/computer-action.component.scss +++ b/src/app/components/computer-action/computer-action.component.scss @@ -11,7 +11,6 @@ overflow: hidden; cursor: pointer; margin: 5px 5px 10px; - background-color: var(--chat-panel-bot-message-message-card-background-color); transition: opacity 0.2s; &:hover { opacity: 0.9; @@ -44,7 +43,6 @@ align-items: center; padding: 8px 12px; gap: 8px; - background-color: var(--chat-panel-thought-chip-background-color); } .computer-icon { @@ -76,10 +74,8 @@ border: 1px solid rgba(255, 255, 255, 0.8); border-radius: 50%; transform: translate(-50%, -50%); - background-color: rgba(255, 0, 0, 0.3); box-shadow: 0 0 4px rgba(0, 0, 0, 0.5); pointer-events: none; - z-index: 10; display: flex; align-items: center; justify-content: center; @@ -87,19 +83,14 @@ content: ''; width: 2px; height: 2px; - background-color: #ff0000; border-radius: 50%; box-shadow: 0 0 2px white; - z-index: 11; } &::after { content: ''; position: absolute; width: 100%; height: 100%; - background: - linear-gradient(to right, transparent 48%, rgba(255, 255, 255, 0.6) 48%, rgba(255, 255, 255, 0.6) 52%, transparent 52%), - linear-gradient(to bottom, transparent 48%, rgba(255, 255, 255, 0.6) 48%, rgba(255, 255, 255, 0.6) 52%, transparent 52%); border-radius: 50%; } } diff --git a/src/app/components/computer-action/computer-action.component.ts b/src/app/components/computer-action/computer-action.component.ts index 98687584..a4852393 100644 --- a/src/app/components/computer-action/computer-action.component.ts +++ b/src/app/components/computer-action/computer-action.component.ts @@ -23,7 +23,7 @@ import {ComputerUseClickCall, ComputerUsePayload, isComputerUseResponse, isVisib import type {FunctionCall, FunctionResponse} from '../../core/models/types'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-computer-action', templateUrl: './computer-action.component.html', styleUrl: './computer-action.component.scss', diff --git a/src/app/components/confirmation-dialog/confirmation-dialog.component.scss b/src/app/components/confirmation-dialog/confirmation-dialog.component.scss index ebc04b09..26cf247d 100644 --- a/src/app/components/confirmation-dialog/confirmation-dialog.component.scss +++ b/src/app/components/confirmation-dialog/confirmation-dialog.component.scss @@ -13,8 +13,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -@use '@angular/material' as mat; - mat-dialog-content { padding: 20px 24px; display: flex; @@ -27,41 +25,7 @@ mat-dialog-content { } } -// Form field and input styling for dialogs -:host ::ng-deep { - .mat-mdc-form-field { - @include mat.form-field-overrides( - ( - filled-container-color: var(--builder-form-field-background-color), - filled-label-text-color: var(--mdc-dialog-supporting-text-color), - filled-focus-label-text-color: var(--builder-text-link-color), - filled-hover-label-text-color: var(--mdc-dialog-supporting-text-color), - ) - ); - } - - .mat-mdc-input-element { - color: var(--mdc-dialog-supporting-text-color) !important; - caret-color: var(--mdc-dialog-supporting-text-color) !important; - } - - .mat-mdc-input-element::placeholder { - color: var(--builder-text-muted-color) !important; - opacity: 0 !important; - } - - // Show placeholder only when focused and empty - .mat-mdc-input-element:focus::placeholder { - opacity: 0.6 !important; - } - - .mat-mdc-form-field-hint { - color: var(--builder-text-muted-color) !important; - } -} - .tool-info-container { - background-color: rgba(138, 180, 248, 0.08); border: 1px solid rgba(138, 180, 248, 0.2); border-radius: 8px; padding: 16px; diff --git a/src/app/components/confirmation-dialog/confirmation-dialog.component.ts b/src/app/components/confirmation-dialog/confirmation-dialog.component.ts index b7b58152..b1d7561b 100644 --- a/src/app/components/confirmation-dialog/confirmation-dialog.component.ts +++ b/src/app/components/confirmation-dialog/confirmation-dialog.component.ts @@ -44,7 +44,7 @@ export interface ConfirmationDialogData { } @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-confirmation-dialog', templateUrl: './confirmation-dialog.component.html', styleUrls: ['./confirmation-dialog.component.scss'], diff --git a/src/app/components/content-bubble/content-bubble.component.html b/src/app/components/content-bubble/content-bubble.component.html new file mode 100644 index 00000000..058481b8 --- /dev/null +++ b/src/app/components/content-bubble/content-bubble.component.html @@ -0,0 +1,205 @@ +
+ @if (type !== 'message' && type !== 'output') { +
{{ type }}
+ } + @if (type === 'message' || type === 'thought') { + @if (uiEvent.attachments) { +
+ @for (file of uiEvent.attachments; track file) { +
+ @if (file.file.type.startsWith("image/")) { + attachment + } @if (!file.file.type.startsWith("image/")) { + insert_drive_file + @if (file.url) { + {{ file.file.name }} + } @else { + {{ file.file.name }} + } + } +
+ } +
+ } + @if (uiEvent.thought || uiEvent.text || uiEvent.renderedContent || uiEvent.a2uiData || uiEvent.event.inputTranscription || uiEvent.event.outputTranscription) { +
+
+ @if (uiEvent.text) { + @if (uiEvent.isEditing) { +
+ +
+ + close + + + check + +
+
+ } @else { + + } + } +
+ @if (uiEvent.renderedContent) { +
+
+
+ } + @if (uiEvent.a2uiData) { + + + } +
+ } + @if (uiEvent.executableCode) { + {{ uiEvent.executableCode.code }} + } @if (uiEvent.codeExecutionResult) { +
+
{{ i18n.outcomeLabel }}: {{ uiEvent.codeExecutionResult.outcome }}
+
{{ i18n.outputLabel }}: {{ uiEvent.codeExecutionResult.output }}
+
+ } @if (uiEvent.inlineData) { @if (uiEvent.role === "bot") { +
+
+ @switch (uiEvent.inlineData.mediaType) { @case (MediaType.IMAGE) { +
+ image +
+ } @case (MediaType.AUDIO) { +
+ +
+ } @case (MediaType.TEXT) { +
+
+ description + +
+
+ } @default { +
+ +
+ } } +
+
+ } @else { +
+ @if (uiEvent.inlineData.mimeType.startsWith("image/")) { +
+ image +
+ } @else { +
+ insert_drive_file + {{ uiEvent.inlineData.displayName }} +
+ } +
+ } } @if (uiEvent.failedMetric && uiEvent.evalStatus === 2) { +
+
+ @if (uiEvent.actualInvocationToolUses) { +
+
{{ i18n.actualToolUsesLabel }}
+ +
+
+
{{ i18n.expectedToolUsesLabel }}
+ +
+ } @else if (uiEvent.actualFinalResponse) { +
+
{{ i18n.actualResponseLabel }}
+
{{ uiEvent.actualFinalResponse }}
+
+
+
{{ i18n.expectedResponseLabel }}
+
{{ uiEvent.expectedFinalResponse }}
+
+ } +
+ @if (uiEvent.evalScore !== undefined && uiEvent.evalThreshold !== undefined) { +
+ {{ i18n.matchScoreLabel }}: {{ uiEvent.evalScore }} + {{ i18n.thresholdLabel }}: {{ uiEvent.evalThreshold }} +
+ } +
+ } + } @else if (type === 'output') { + + } @else if (type === 'error') { + + } @else if (type === 'transcription') { + @if (role === 'user' && uiEvent.event.inputTranscription) { + {{ uiEvent.event.inputTranscription.text }} + } @else if (role === 'bot' && uiEvent.event.outputTranscription) { + {{ uiEvent.event.outputTranscription.text }} + } + } +
diff --git a/src/app/components/content-bubble/content-bubble.component.scss b/src/app/components/content-bubble/content-bubble.component.scss new file mode 100644 index 00000000..2f720bfc --- /dev/null +++ b/src/app/components/content-bubble/content-bubble.component.scss @@ -0,0 +1,66 @@ +:host { + display: contents; /* Ensure the wrapper doesn't break flex layouts of the parent */ +} + +.content-bubble { + padding: 5px 20px; + border-radius: 20px; + + &:not(.type-message) { + border-radius: 8px; + } + + max-width: 80%; + font-size: 14px; + font-weight: 400; + position: relative; + display: inline-block; + + &:empty { + display: none; + } +} + +.role-user { + color: var(--mat-sys-on-primary-container); + background-color: var(--mat-sys-primary-container); + box-shadow: none; + width: auto; + min-width: fit-content; + max-width: 80%; +} + +.role-bot { + align-self: flex-start; + color: var(--mat-sys-on-secondary-container); + background-color: var(--mat-sys-secondary-container); + box-shadow: none; +} + +.type-error { + background-color: var(--mat-sys-error-container, rgba(186, 26, 26, 0.1)); + color: var(--mat-sys-on-error-container, #ba1a1a); + + .output-chip-header { + color: var(--mat-sys-error, #ba1a1a); + } +} + +.type-transcription { + background-color: var(--mat-sys-surface-container); +} + +.type-output { + background-color: var(--mat-sys-surface-container-highest); +} + +.output-chip-header { + font-weight: 600; + font-size: 9px; + color: var(--mat-sys-primary); + opacity: 0.5; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + diff --git a/src/app/components/content-bubble/content-bubble.component.ts b/src/app/components/content-bubble/content-bubble.component.ts new file mode 100644 index 00000000..4307f89a --- /dev/null +++ b/src/app/components/content-bubble/content-bubble.component.ts @@ -0,0 +1,61 @@ +import {Component, EventEmitter, Input, Output, Type, inject} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {FormsModule} from '@angular/forms'; +import {MatIconModule} from '@angular/material/icon'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {NgxJsonViewerModule} from 'ngx-json-viewer'; + +import {UiEvent} from '../../core/models/UiEvent'; +import {SAFE_VALUES_SERVICE} from '../../core/services/interfaces/safevalues'; +import {JsonTooltipDirective} from '../../directives/html-tooltip.directive'; +import {A2uiCanvasComponent} from '../a2ui-canvas/a2ui-canvas.component'; +import {MediaType} from '../artifact-tab/artifact-tab.component'; +import {AudioPlayerComponent} from '../audio-player/audio-player.component'; +import {MARKDOWN_COMPONENT, MarkdownComponentInterface} from '../markdown/markdown.component.interface'; +import {ChatPanelMessagesInjectionToken} from '../chat-panel/chat-panel.component.i18n'; + +@Component({ + selector: 'app-content-bubble', + standalone: true, + imports: [ + CommonModule, + FormsModule, + MatIconModule, + MatTooltipModule, + NgxJsonViewerModule, + A2uiCanvasComponent, + AudioPlayerComponent, + JsonTooltipDirective, + ], + templateUrl: './content-bubble.component.html', + styleUrl: './content-bubble.component.scss', + host: { + 'class': 'content-bubble-host' + } +}) +export class ContentBubbleComponent { + @Input({required: true}) uiEvent!: UiEvent; + @Input() type: 'message' | 'output' | 'transcription' | 'thought' | 'error' = 'message'; + @Input() role: string = 'bot'; + @Input() evalStatus?: number; + + @Input() userEditEvalCaseMessage: string = ''; + + @Output() readonly userEditEvalCaseMessageChange = new EventEmitter(); + @Output() readonly handleKeydown = new EventEmitter<{event: KeyboardEvent, message: any}>(); + @Output() readonly cancelEditMessage = new EventEmitter(); + @Output() readonly saveEditMessage = new EventEmitter(); + + @Output() readonly openViewImageDialog = new EventEmitter(); + @Output() readonly openBase64InNewTab = new EventEmitter<{data: string, mimeType: string}>(); + + protected readonly i18n = inject(ChatPanelMessagesInjectionToken); + protected readonly sanitizer = inject(SAFE_VALUES_SERVICE); + readonly markdownComponent: Type = inject(MARKDOWN_COMPONENT); + + readonly MediaType = MediaType; + + renderGooglerSearch(content: string) { + return this.sanitizer.bypassSecurityTrustHtml(content); + } +} diff --git a/src/app/components/custom-logo/custom-logo.component.ts b/src/app/components/custom-logo/custom-logo.component.ts index 7a324478..6b404c8a 100644 --- a/src/app/components/custom-logo/custom-logo.component.ts +++ b/src/app/components/custom-logo/custom-logo.component.ts @@ -21,7 +21,7 @@ import {RuntimeConfigUtil} from '../../../utils/runtime-config-util'; /** Logo component to override the default logo. */ @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-custom-logo', standalone: true, templateUrl: './custom-logo.component.html', diff --git a/src/app/components/edit-json-dialog/edit-json-dialog.component.ts b/src/app/components/edit-json-dialog/edit-json-dialog.component.ts index 5964f98e..08f1bf9d 100644 --- a/src/app/components/edit-json-dialog/edit-json-dialog.component.ts +++ b/src/app/components/edit-json-dialog/edit-json-dialog.component.ts @@ -30,7 +30,7 @@ export interface EditJsonData { } @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-edit-json-dialog', templateUrl: './edit-json-dialog.component.html', styleUrls: ['./edit-json-dialog.component.scss'], diff --git a/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.scss b/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.scss index 4c0ebeaf..340ecc06 100644 --- a/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.scss +++ b/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.scss @@ -30,10 +30,6 @@ mat-form-field { input { color: var(--mdc-dialog-supporting-text-color) !important; caret-color: var(--mdc-dialog-supporting-text-color) !important; - background-color: transparent !important; } } -html.darkmode :host ::ng-deep .mat-mdc-text-field-wrapper { - background-color: #3f3f42; -} \ No newline at end of file diff --git a/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.ts b/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.ts index 7b612779..65094f37 100644 --- a/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.ts +++ b/src/app/components/eval-tab/add-eval-session-dialog/add-eval-session-dialog/add-eval-session-dialog.component.ts @@ -26,7 +26,7 @@ import { FormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-add-eval-session-dialog', templateUrl: './add-eval-session-dialog.component.html', styleUrl: './add-eval-session-dialog.component.scss', diff --git a/src/app/components/eval-tab/eval-tab.component.scss b/src/app/components/eval-tab/eval-tab.component.scss index dadc466c..7f028570 100644 --- a/src/app/components/eval-tab/eval-tab.component.scss +++ b/src/app/components/eval-tab/eval-tab.component.scss @@ -27,7 +27,7 @@ .eval-set-actions { display: flex; justify-content: space-between; - color: var(--eval-tab-eval-set-actions-color); + color: var(--mat-sys-on-surface); font-style: normal; font-weight: 700; font-size: 14px; @@ -35,15 +35,13 @@ .empty-eval-info { margin-top: 12px; - background-color: var(--eval-tab-empty-eval-info-background-color); border-radius: 8px; - box-shadow: - 0px 2px 6px 2px var(--eval-tab-empty-eval-info-box-shadow-color1), - 0px 1px 2px 0px var(--eval-tab-empty-eval-info-box-shadow-color2); + box-shadow: 0px 2px 4px rgba(0, 0, 0, 0.1), 0px 1px 2px rgba(0, 0, 0, 0.06); + background-color: var(--mat-sys-surface-container); } .info-title { - color: var(--eval-tab-info-title-color); + color: var(--mat-sys-on-surface); font-family: Roboto; font-size: 14px; font-weight: 500; @@ -53,7 +51,7 @@ } .info-detail { - color: var(--eval-tab-info-detail-color); + color: var(--mat-sys-on-surface-variant); font-family: Roboto; font-size: 14px; font-weight: 400; @@ -64,7 +62,7 @@ } .info-create { - color: var(--eval-tab-info-create-color); + color: var(--mat-sys-primary); font-size: 14px; font-style: normal; font-weight: 500; @@ -83,17 +81,11 @@ .selected-eval-case { font-weight: 900; - color: var(--eval-tab-selected-eval-case-color); + color: var(--mat-sys-primary); } .save-session-btn { width: 100%; - background: linear-gradient( - 0deg, - var(--eval-tab-save-session-btn-background-color1) 0%, - var(--eval-tab-save-session-btn-background-color1) 100% - ), - var(--eval-tab-save-session-btn-background-color2); border: none; border-radius: 4px; margin-top: 12px; @@ -111,7 +103,7 @@ .save-session-btn-text { padding-top: 2px; - color: var(--eval-tab-save-session-btn-text-color); + color: var(--mat-sys-on-primary); font-family: 'Google Sans'; font-size: 14px; font-style: normal; @@ -122,39 +114,37 @@ .run-eval-btn { border-radius: 4px; - border: 1px solid var(--eval-tab-run-eval-btn-border-color); - background-color: transparent; + border: 1px solid var(--mat-sys-outline); padding-left: 24px; padding-right: 24px; padding-top: 8px; padding-bottom: 8px; margin-top: 16px; - color: var(--eval-tab-run-eval-btn-color); + color: var(--mat-sys-primary); cursor: pointer; &:hover { - background-color: var(--eval-tab-run-eval-btn-hover-background-color); + background-color: var(--mat-sys-surface-container-high); } } .result-btn { display: flex; - background-color: transparent; border-radius: 4px; - border: 1px solid var(--eval-tab-result-btn-border-color); + border: 1px solid var(--mat-sys-outline-variant); margin-top: 4px; cursor: pointer; &:hover { - background-color: var(--eval-tab-result-btn-hover-background-color); + background-color: var(--mat-sys-surface-container-high); } &.pass { - color: var(--eval-tab-result-btn-pass-color); + color: var(--mat-sys-tertiary); } &.fail { - color: var(--eval-tab-result-btn-fail-color); + color: var(--mat-sys-error); } } @@ -175,9 +165,9 @@ flex-direction: column; align-items: center; border-radius: 8px; - background-color: var(--eval-tab-status-card-background-color); padding: 12px 16px; margin-top: 12px; + background-color: var(--mat-sys-surface-container); &__overview { display: flex; @@ -194,7 +184,7 @@ &__timestamp { font-size: 0.9em; - color: var(--eval-tab-status-card-timestamp-color); + color: var(--mat-sys-on-surface-variant); margin-bottom: 5px; } @@ -203,6 +193,7 @@ align-items: center; font-size: 0.95em; font-weight: 500; + color: var(--mat-sys-on-surface); } &__metrics { @@ -215,20 +206,20 @@ &__metric { width: 180px; - color: var(--eval-tab-status-card-metric-color); + color: var(--mat-sys-on-surface-variant); } &__failed { - color: var(--eval-tab-status-card-failed-color); + color: var(--mat-sys-error); } &__separator { - color: var(--eval-tab-status-card-separator-color); + color: var(--mat-sys-on-surface-variant); margin: 0 8px; } &__passed { - color: var(--eval-tab-status-card-passed-color); + color: var(--mat-sys-tertiary); } &__action { @@ -237,7 +228,7 @@ align-items: center; mat-icon { - color: var(--eval-tab-status-card-action-mat-icon-color); + color: var(--mat-sys-on-surface-variant); cursor: pointer; transition: transform 0.2s ease-in-out; @@ -247,7 +238,7 @@ } .status-card__icon { - color: var(--eval-tab-status-card-icon-color); + color: var(--mat-sys-on-surface-variant); font-size: 1.2em; cursor: pointer; &:hover { diff --git a/src/app/components/eval-tab/eval-tab.component.ts b/src/app/components/eval-tab/eval-tab.component.ts index 5a833188..091f8f08 100644 --- a/src/app/components/eval-tab/eval-tab.component.ts +++ b/src/app/components/eval-tab/eval-tab.component.ts @@ -79,7 +79,7 @@ interface AppEvaluationResult { } @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-eval-tab', templateUrl: './eval-tab.component.html', styleUrl: './eval-tab.component.scss', diff --git a/src/app/components/eval-tab/new-eval-set-dialog/new-eval-set-dialog-component/new-eval-set-dialog-component.component.scss b/src/app/components/eval-tab/new-eval-set-dialog/new-eval-set-dialog-component/new-eval-set-dialog-component.component.scss index d2f697e1..8bb53758 100644 --- a/src/app/components/eval-tab/new-eval-set-dialog/new-eval-set-dialog-component/new-eval-set-dialog-component.component.scss +++ b/src/app/components/eval-tab/new-eval-set-dialog/new-eval-set-dialog-component/new-eval-set-dialog-component.component.scss @@ -30,11 +30,7 @@ mat-form-field { input { color: var(--mdc-dialog-supporting-text-color) !important; caret-color: var(--mdc-dialog-supporting-text-color) !important; - background-color: transparent !important; } } -:host ::ng-deep .mat-mdc-text-field-wrapper { - background-color: #3f3f42; -} diff --git a/src/app/components/eval-tab/new-eval-set-dialog/new-eval-set-dialog-component/new-eval-set-dialog-component.component.ts b/src/app/components/eval-tab/new-eval-set-dialog/new-eval-set-dialog-component/new-eval-set-dialog-component.component.ts index 7adf09f9..ba48a69f 100644 --- a/src/app/components/eval-tab/new-eval-set-dialog/new-eval-set-dialog-component/new-eval-set-dialog-component.component.ts +++ b/src/app/components/eval-tab/new-eval-set-dialog/new-eval-set-dialog-component/new-eval-set-dialog-component.component.ts @@ -26,7 +26,7 @@ import { FormsModule } from '@angular/forms'; import { MatButton } from '@angular/material/button'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-new-eval-set-dialog-component', templateUrl: './new-eval-set-dialog-component.component.html', styleUrl: './new-eval-set-dialog-component.component.scss', diff --git a/src/app/components/eval-tab/run-eval-config-dialog/run-eval-config-dialog.component.scss b/src/app/components/eval-tab/run-eval-config-dialog/run-eval-config-dialog.component.scss index a53d5691..c207cab0 100644 --- a/src/app/components/eval-tab/run-eval-config-dialog/run-eval-config-dialog.component.scss +++ b/src/app/components/eval-tab/run-eval-config-dialog/run-eval-config-dialog.component.scss @@ -5,14 +5,6 @@ build.dialog-container { box-shadow: 0 8px 16px var(--run-eval-config-dialog-container-box-shadow-color); } -.threshold-slider { - --mdc-slider-active-track-color: var(--run-eval-config-dialog-threshold-slider-active-track-color); - --mdc-slider-inactive-track-color: var(--run-eval-config-dialog-threshold-slider-inactive-track-color); - --mdc-slider-handle-color: var(--run-eval-config-dialog-threshold-slider-handle-color); - --mdc-slider-ripple-color: var(--run-eval-config-dialog-threshold-slider-ripple-color); - width: 100px -} - .metric-row { display: flex; flex-direction: row; @@ -27,11 +19,6 @@ build.dialog-container { margin-left: 20px; } -.mdc-slider__thumb--with-indicator { - background-color: var(--mdc-slider-handle-color, var(--run-eval-config-dialog-mdc-slider-thumb-background-color)); - border: none !important; - box-shadow: none !important; -} h2[mat-dialog-title] { color: var(--mdc-dialog-supporting-text-color) !important; diff --git a/src/app/components/eval-tab/run-eval-config-dialog/run-eval-config-dialog.component.ts b/src/app/components/eval-tab/run-eval-config-dialog/run-eval-config-dialog.component.ts index 14e5c8b5..f04bffda 100644 --- a/src/app/components/eval-tab/run-eval-config-dialog/run-eval-config-dialog.component.ts +++ b/src/app/components/eval-tab/run-eval-config-dialog/run-eval-config-dialog.component.ts @@ -34,7 +34,7 @@ export interface EvalConfigData { } @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-run-eval-config-dialog', templateUrl: './run-eval-config-dialog.component.html', styleUrls: ['./run-eval-config-dialog.component.scss'], diff --git a/src/app/components/event-content/event-content.component.html b/src/app/components/event-content/event-content.component.html new file mode 100644 index 00000000..975080b4 --- /dev/null +++ b/src/app/components/event-content/event-content.component.html @@ -0,0 +1,185 @@ + @if (shouldShowMessageCard(uiEvent)) { + + } + @if (uiEvent.event.output) { + + } + @if (uiEvent.event.inputTranscription) { + + } + @if (uiEvent.event.outputTranscription) { + + } +
+ @if (uiEvent.event.turnComplete) { + + } + @if (uiEvent.event.interrupted) { + + } + @if (uiEvent.functionCalls && uiEvent.functionCalls.length > 0) { + @for (functionCall of uiEvent.functionCalls; track functionCall.id) { + @if (isComputerUseClick(functionCall)) { + + } @else { + + } + } + } + @if (uiEvent.functionResponses && uiEvent.functionResponses.length > 0) { + @for (functionResponse of uiEvent.functionResponses; track functionResponse) { + @if (isComputerUseResponse(functionResponse)) { + + } @else { + + } + } + } + @if (uiEvent.stateDelta) { + + } + @if (uiEvent.artifactDelta) { + + } + @if (uiEvent.error) { + + } + @if (uiEvent.route) { + + } + @if (hasWorkflowNodes()) { + + } + @if (hasEndOfAgent()) { + + } +
+ @if (uiEvent.functionCalls && uiEvent.functionCalls.length > 0) { + @for (functionCall of uiEvent.functionCalls; track functionCall.id) { + @if (functionCall.needsResponse) { + + } + } + } + @if (uiEvent.evalStatus === 1 || uiEvent.evalStatus === 2) { +
+ {{ uiEvent.evalStatus === 1 ? "check" : uiEvent.evalStatus === 2 ? "close" : "" }} + {{ uiEvent.evalStatus === 1 ? i18n.evalPassLabel : uiEvent.evalStatus === 2 ? + i18n.evalFailLabel : "" }} +
+ } + @if (evalCase && isEvalEditMode) { + @if (uiEvent.text) { +
+ + edit + + + delete + +
+ } @else if (isEditFunctionArgsEnabled && uiEvent.functionCalls && uiEvent.functionCalls.length > 0) { +
+ + edit + +
+ } + } diff --git a/src/app/components/event-content/event-content.component.scss b/src/app/components/event-content/event-content.component.scss new file mode 100644 index 00000000..b899c2d0 --- /dev/null +++ b/src/app/components/event-content/event-content.component.scss @@ -0,0 +1,40 @@ +:host { + display: flex; + flex-direction: column; + width: 100%; +} + +app-content-bubble + app-content-bubble { + margin-top: 5px; +} + +.event-chips-container { + display: flex; + flex-wrap: wrap; + align-items: center; + width: 100%; +} + +.eval-case-edit-button { + cursor: pointer; + margin-left: 4px; + margin-right: 4px; +} + +.eval-pass { + display: flex; + color: var(--mat-sys-primary); +} + +.eval-fail { + display: flex; + color: var(--mat-sys-error); +} + +.hidden { + visibility: hidden; +} + +.event-action-button { + margin: 5px; +} diff --git a/src/app/components/event-content/event-content.component.ts b/src/app/components/event-content/event-content.component.ts new file mode 100644 index 00000000..d1653759 --- /dev/null +++ b/src/app/components/event-content/event-content.component.ts @@ -0,0 +1,108 @@ +import {CommonModule, NgClass} from '@angular/common'; +import {Component, EventEmitter, Input, Output, inject} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatTooltipModule} from '@angular/material/tooltip'; + +import {AgentRunRequest} from '../../core/models/AgentRunRequest'; +import {isComputerUseResponse, isVisibleComputerUseClick} from '../../core/models/ComputerUse'; +import type {EvalCase} from '../../core/models/Eval'; +import {UiEvent} from '../../core/models/UiEvent'; +import {WorkflowGraphTooltipDirective} from '../../directives/workflow-graph-tooltip.directive'; +import {ComputerActionComponent} from '../computer-action/computer-action.component'; +import {HoverInfoButtonComponent} from '../hover-info-button/hover-info-button.component'; +import {LongRunningResponseComponent} from '../long-running-response/long-running-response'; +import {ChatPanelMessagesInjectionToken} from '../chat-panel/chat-panel.component.i18n'; +import {ContentBubbleComponent} from '../content-bubble/content-bubble.component'; + +@Component({ + selector: 'app-event-content', + templateUrl: './event-content.component.html', + styleUrl: './event-content.component.scss', + standalone: true, + imports: [ + CommonModule, + MatIconModule, + MatButtonModule, + MatTooltipModule, + NgClass, + WorkflowGraphTooltipDirective, + ComputerActionComponent, + LongRunningResponseComponent, + HoverInfoButtonComponent, + ContentBubbleComponent, + ], +}) +export class EventContentComponent { + @Input({required: true}) uiEvent!: UiEvent; + @Input({required: true}) index!: number; + @Input() uiEvents: UiEvent[] = []; + + @Input() appName: string = ''; + @Input() userId: string = ''; + @Input() sessionId: string = ''; + @Input() sessionName: string = ''; + + @Input() evalCase: EvalCase | null = null; + @Input() isEvalEditMode: boolean = false; + @Input() isEvalCaseEditing: boolean = false; + @Input() isEditFunctionArgsEnabled: boolean = false; + @Input() userEditEvalCaseMessage: string = ''; + + @Input() agentGraphData: any = null; + @Input() allWorkflowNodes: any = null; + + @Output() readonly handleKeydown = new EventEmitter<{event: KeyboardEvent, message: any}>(); + @Output() readonly cancelEditMessage = new EventEmitter(); + @Output() readonly saveEditMessage = new EventEmitter(); + @Output() readonly userEditEvalCaseMessageChange = new EventEmitter(); + + @Output() readonly openViewImageDialog = new EventEmitter(); + @Output() readonly openBase64InNewTab = new EventEmitter<{data: string, mimeType: string}>(); + + @Output() readonly editEvalCaseMessage = new EventEmitter(); + @Output() readonly deleteEvalCaseMessage = new EventEmitter<{message: any, index: number}>(); + @Output() readonly editFunctionArgs = new EventEmitter(); + + @Output() readonly clickEvent = new EventEmitter(); + @Output() readonly longRunningResponseComplete = new EventEmitter(); + @Output() readonly agentStateClick = new EventEmitter<{event: Event, index: number}>(); + + protected readonly i18n = inject(ChatPanelMessagesInjectionToken); + + readonly Object = Object; + readonly String = String; + + shouldShowMessageCard(message: any): boolean { + return !!( + message.text || message.attachments || message.inlineData || + message.executableCode || message.codeExecutionResult || + message.a2uiData || message.renderedContent || message.isLoading || + (message.failedMetric && message.evalStatus === 2)); + } + + isComputerUseClick(input: any): boolean { + return isVisibleComputerUseClick(input); + } + + isComputerUseResponse(input: any): boolean { + return isComputerUseResponse(input); + } + + hasWorkflowNodes(): boolean { + const nodes = this.uiEvent.event?.actions?.agentState?.nodes; + return !!nodes && Object.keys(nodes).length > 0; + } + + getWorkflowNodes(): any { + return this.uiEvent.event?.actions?.agentState?.nodes || null; + } + + hasEndOfAgent(): boolean { + return this.uiEvent.event?.actions?.endOfAgent === true; + } + + getEndOfAgentAuthor(): string { + return this.uiEvent.event?.author || 'Agent'; + } +} diff --git a/src/app/components/event-row/event-row.component.html b/src/app/components/event-row/event-row.component.html new file mode 100644 index 00000000..2541882e --- /dev/null +++ b/src/app/components/event-row/event-row.component.html @@ -0,0 +1,46 @@ +
+ #{{ index + 1}} +
+ @if (uiEvent.role === "bot" && !uiEvent.isLoading) { + + + } + + @if (uiEvent.role === "user") { + + } + + @if(isUserFeedbackEnabled && !isLoadingAgentResponse && uiEvent.role === "bot") { + + } diff --git a/src/app/components/event-row/event-row.component.scss b/src/app/components/event-row/event-row.component.scss new file mode 100644 index 00000000..4debef18 --- /dev/null +++ b/src/app/components/event-row/event-row.component.scss @@ -0,0 +1,268 @@ +.generated-image-container { + max-width: 400px; + margin-left: 20px; +} + +.generated-image { + max-width: 100%; + min-width: 40px; + border-radius: 8px; +} + +.html-artifact-container { + width: 100%; + display: flex; + justify-content: flex-start; + align-items: center; +} + +app-content-bubble + app-content-bubble { + margin-top: 5px; +} + +.event-chips-container { + display: flex; + flex-wrap: wrap; + align-items: center; + width: 100%; +} + +// Enables messages to have columns +:host { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + margin-left: -20px; + margin-right: -20px; + padding: 4px 20px; + border: 2px solid transparent; + border-radius: 4px; + + transition: all 0.2s ease; + + // Hover effect for all rows + &.selectable:hover { + border-color: var(--mat-sys-outline-variant, rgba(0, 0, 0, 0.12)); + } + + // Selected row (stays highlighted when side drawer is open) + &.selected { + background-color: var(--mat-sys-secondary-container, rgba(0, 0, 0, 0.08)) !important; + } +} + +app-message-feedback { + width: 100%; +} + +:host(.user) { + justify-content: flex-end; + align-items: flex-start; + gap: 15px; + + + .event-chips-container { + justify-content: flex-end; + } +} + +:host(.bot) { + align-items: flex-start; + padding-right: 48px; + + + app-chat-avatar { + align-self: flex-start; + } +} + +.message-content { + display: contents; +} + +:host(.bot)>.message-content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + align-items: flex-start; +} + +:host(.user)>.message-content { + display: flex; + flex-direction: column; + flex: 1; + min-width: 0; + align-items: flex-end; +} + +:host(.bot):focus-within { + app-content-bubble::ng-deep .content-bubble { + border: 1px solid var(--mat-sys-outline); + } +} + +.message-textarea { + max-width: 100%; + border: none; + background-color: transparent; + font-family: 'Google Sans', 'Helvetica Neue', sans-serif; +} + +.message-textarea:focus { + outline: none; +} + +.edit-message-buttons-container { + display: flex; + justify-content: flex-end; +} + +app-content-bubble .eval-compare-container { + visibility: hidden; + position: absolute; + left: 10px; + overflow: hidden; + border-radius: 20px; + padding: 5px 20px; + margin-bottom: 10px; + font-size: 16px; + + .actual-result { + border-right: 2px solid var(--mat-sys-outline-variant); + padding-right: 8px; + min-width: 350px; + max-width: 350px; + } + + .expected-result { + padding-left: 12px; + min-width: 350px; + max-width: 350px; + } +} + +app-content-bubble:hover .eval-compare-container { + visibility: visible; +} + +.actual-expected-compare-container { + display: flex; +} + +.score-threshold-container { + display: flex; + justify-content: center; + gap: 10px; + align-items: center; + margin-top: 15px; + font-size: 14px; + font-weight: 600; +} + +.eval-response-header { + padding-bottom: 5px; + border-bottom: 2px solid var(--mat-sys-outline-variant); + font-style: italic; + font-weight: 700; +} + +.header-expected { + color: var(--mat-sys-tertiary); +} + +.header-actual { + color: var(--mat-sys-primary); +} + +.eval-case-edit-button { + cursor: pointer; + margin-left: 4px; + margin-right: 4px; +} + +.eval-pass { + display: flex; + color: var(--mat-sys-primary); +} + +.eval-fail { + display: flex; + color: var(--mat-sys-error); +} + +.hidden { + visibility: hidden; +} + +.image-preview-chat { + max-width: 90%; + max-height: 70vh; + width: auto; + height: auto; + border-radius: 8px; + cursor: pointer; + transition: transform 0.2s ease-in-out; +} + +.attachment { + display: flex; + align-items: center; +} + +:host ::ng-deep .message-text { + p { + white-space: pre-line; + word-break: break-word; + overflow-wrap: break-word; + } +} + + +.event-number-container { + display: flex; + flex-direction: column; + align-self: flex-start; + min-width: 30px; + margin-top: 10px; + margin-right: 8px; + font-size: 12px; + font-weight: 600; + text-align: center; + color: var(--mat-sys-on-surface-variant); +} + +:host ::ng-deep pre { + white-space: pre-wrap; + word-break: break-word; + overflow-x: auto; + max-width: 100%; +} + +.link-style-button { + border: none; + padding: 0; + font: inherit; + color: var(--mat-sys-primary) !important; + text-decoration: underline; + cursor: pointer; + outline: none; + font-size: 14px; +} + +.cancel-edit-button { + width: 24px; + height: 24px; + color: var(--mat-sys-outline-variant); + cursor: pointer; + margin-right: 16px; +} + +.save-edit-button { + width: 24px; + height: 24px; + color: var(--mat-sys-primary); + cursor: pointer; + margin-right: 16px; +} + diff --git a/src/app/components/event-row/event-row.component.ts b/src/app/components/event-row/event-row.component.ts new file mode 100644 index 00000000..462962f4 --- /dev/null +++ b/src/app/components/event-row/event-row.component.ts @@ -0,0 +1,76 @@ +import {CommonModule} from '@angular/common'; +import {Component, EventEmitter, Input, Output} from '@angular/core'; + +import {AgentRunRequest} from '../../core/models/AgentRunRequest'; +import type {EvalCase} from '../../core/models/Eval'; +import {UiEvent} from '../../core/models/UiEvent'; +import {ChatAvatarComponent} from '../chat-avatar/chat-avatar.component'; +import {MessageFeedbackComponent} from '../message-feedback/message-feedback.component'; +import {EventContentComponent} from '../event-content/event-content.component'; + +@Component({ + selector: 'app-event-row', + templateUrl: './event-row.component.html', + styleUrl: './event-row.component.scss', + standalone: true, + host: { + 'class': 'message-row-container', + '[class.selected]': 'isSelected', + '[class.user]': 'uiEvent.role === "user"', + '[class.bot]': 'uiEvent.role === "bot"', + '[class.selectable]': 'isSelectable', + '(click)': 'onRowClick($event)' + }, + imports: [ + CommonModule, + MessageFeedbackComponent, + ChatAvatarComponent, + EventContentComponent, + ], +}) +export class EventRowComponent { + @Input({required: true}) uiEvent!: UiEvent; + @Input({required: true}) index!: number; + @Input() uiEvents: UiEvent[] = []; + @Input() isSelected: boolean = false; + @Input() isSelectable: boolean = true; + + @Input() appName: string = ''; + @Input() userId: string = ''; + @Input() sessionId: string = ''; + @Input() sessionName: string = ''; + + @Input() evalCase: EvalCase | null = null; + @Input() isEvalEditMode: boolean = false; + @Input() isEvalCaseEditing: boolean = false; + @Input() isEditFunctionArgsEnabled: boolean = false; + @Input() userEditEvalCaseMessage: string = ''; + + @Input() agentGraphData: any = null; + @Input() allWorkflowNodes: any = null; + + @Input() isUserFeedbackEnabled: boolean = false; + @Input() isLoadingAgentResponse: boolean = false; + + @Output() readonly rowClick = new EventEmitter<{event: MouseEvent, uiEvent: UiEvent, index: number}>(); + @Output() readonly handleKeydown = new EventEmitter<{event: KeyboardEvent, message: any}>(); + @Output() readonly cancelEditMessage = new EventEmitter(); + @Output() readonly saveEditMessage = new EventEmitter(); + @Output() readonly userEditEvalCaseMessageChange = new EventEmitter(); + + @Output() readonly openViewImageDialog = new EventEmitter(); + @Output() readonly openBase64InNewTab = new EventEmitter<{data: string, mimeType: string}>(); + + @Output() readonly editEvalCaseMessage = new EventEmitter(); + @Output() readonly deleteEvalCaseMessage = new EventEmitter<{message: any, index: number}>(); + @Output() readonly editFunctionArgs = new EventEmitter(); + + @Output() readonly clickEvent = new EventEmitter(); + @Output() readonly longRunningResponseComplete = new EventEmitter(); + @Output() readonly agentStateClick = new EventEmitter<{event: Event, index: number}>(); + + onRowClick(event: MouseEvent) { + if (!this.isSelectable) return; + this.rowClick.emit({event, uiEvent: this.uiEvent, index: this.index}); + } +} diff --git a/src/app/components/event-tab/event-tab.component.html b/src/app/components/event-tab/event-tab.component.html index 103b764d..59ab59a4 100644 --- a/src/app/components/event-tab/event-tab.component.html +++ b/src/app/components/event-tab/event-tab.component.html @@ -1,63 +1,307 @@ - + @if (selectedEvent()?.nodeInfo) { + + + + + + + + + + + + + + @if (selectedEvent()!.nodeInfo!['messageAsOutput'] !== undefined) { + + + + + } +
Node Path{{ selectedEvent()!.nodeInfo!['path'] || 'N/A' }}
Run ID{{ selectedEvent()!.nodeInfo!['runId'] || 'N/A' + }}
Output For + @if (selectedEvent()!.nodeInfo!['outputFor']) { + + } @else { + N/A + } +
Message As Output{{ selectedEvent()!.nodeInfo!['messageAsOutput'] }}
+ } -
- @if (eventsMap().size>0) { -
-
- @if (!isTraceView()) { -

{{ i18n.conversationsHeader }}

+ @if (selectedEvent()?.actions && Object.keys(selectedEvent()!.actions!).length > 0) { + + @for (key of Object.keys(selectedEvent()!.actions!); track key) { + + + + + } +
{{ key }} + @if (isObject($any(selectedEvent()!.actions)[key])) { + + } @else { + {{ $any(selectedEvent()!.actions)[key] }} + } +
} - @if (isTraceView()) { -

{{ i18n.traceHeader }}

+ + @if (functionCalls().length > 0) { + + @for (fc of functionCalls(); track $index) { + + + + + } +
{{ fc?.name }} + +
} - @if (traceData().length > 0) { - - {{ i18n.eventsToggle }} - @if (isTraceEnabledObs | async) { - {{ i18n.traceToggle }} - } - + + @if (functionResponses().length > 0) { + + @for (fr of functionResponses(); track $index) { + + + + + } +
{{ fr?.name }} + +
+ } + + @if (associatedSpans().length > 0) { + + @for (span of associatedSpans(); track span.span_id) { + + + + + } +
{{ span.name }} + {{ span.span_id }} +
}
- @if (!isTraceView()) { - - @for (jsonData of eventsMap() | keyvalue: mapOrderPreservingSort; track jsonData; let i = $index) { - - {{i}} - {{jsonData.value.title}} - + } + @if (selectedDetailTab === 'metadata') { +
+ @if (selectedEvent()?.usageMetadata && Object.keys(selectedEvent()!.usageMetadata).length > 0) { + + @for (key of Object.keys(selectedEvent()!.usageMetadata); track key) { + + + + } - +
{{ key }} + @if (key === 'promptTokensDetails' || key === 'promptTokenDetails' || key === 'candidatesTokenDetails' || + key === 'candidatesTokensDetails' || key === 'cacheTokensDetails') { + @for (detail of selectedEvent()!.usageMetadata[key]; track detail.modality) { +
{{ detail.modality }}: {{ detail.tokenCount }}
+ } + } @else { + {{ selectedEvent()!.usageMetadata[key] }} + } +
+ } +
} - @if (isTraceView()) { - - @for (invoc of spansByTraceId() | keyvalue: mapOrderPreservingSort; track invoc; let i = $index) { - - {{i}} - {{ i18n.invocationPrefix }} {{invoc.value | invocId}} - + @if (selectedDetailTab === 'raw') { +
+ +
+ } + @if (selectedDetailTab === 'graph') { +
+
+
+ Invocation: + @if (invocationDisplayMap().size > 0 && selectedEvent()?.invocationId) { + + + @for (inv of invocationDisplayEntries(); track inv.key) { + + } + + } @else { + {{ + selectedEvent()?.invocationId ? (invocationDisplayMap().get(selectedEvent()!.invocationId!) || + selectedEvent()!.invocationId) : 'N/A' }} + } +
+
+ @if (hasSubWorkflows() && (breadcrumbs().length > 0 || appName())) { + + } +
+ @if (graphsAvailable()) { + + } + @if (!graphsAvailable()) { +
+ Graph is not available for this agent. +
+ } @else if (!renderedEventGraph()) { +
+ +
+ } @else { +
+ } +
+
+ + @for (evt of menuEvents; track evt.id) { + + } + +
+ } + @if (selectedDetailTab === 'request') { + @if ((uiStateService.isEventRequestResponseLoading() | async) === true) { +
+ +
+ } @else if (!llmRequest()) { +
{{ i18n.requestIsNotAvailable }}
+ } @else { +
+ +
+ } + } + @if (selectedDetailTab === 'response') { + @if ((uiStateService.isEventRequestResponseLoading() | async) === true) { +
+ +
+ } @else if (!llmResponse()) { +
{{ i18n.responseIsNotAvailable }}
+ } @else { +
+ +
+ } }
- } - @if (eventsMap().size==0) { -
- {{ i18n.noConversationsMessage }} -
- } -
+
+
\ No newline at end of file diff --git a/src/app/components/event-tab/event-tab.component.scss b/src/app/components/event-tab/event-tab.component.scss index 2d66a5d6..e7d36e12 100644 --- a/src/app/components/event-tab/event-tab.component.scss +++ b/src/app/components/event-tab/event-tab.component.scss @@ -1,91 +1,282 @@ -/** - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -@use '@angular/material' as mat; - -.events-wrapper { - padding-left: 25px; - padding-right: 25px; - font-size: 14px; - font-weight: 700; - color: var(--event-tab-events-wrapper-color); - - .empty-state { - color: initial; - padding-top: 1em; - text-align: center; - font-weight: 400; - font-style: italic; +:host { + display: block; + height: 100%; +} + +.json-viewer-container { + margin: 10px; +} + +.event-paginator { + margin-right: auto; + display: flex; + justify-content: center; + background-color: transparent; + + /* Move the "Event X of Y" label to the right of the navigation buttons */ + ::ng-deep { + .mat-mdc-paginator-range-label { + order: 2; + margin: 0 0 0 8px; /* Override default margins and add left margin */ + } } } -.event-index { - color: var(--event-tab-event-index-color); - font-family: Roboto; +.event-details-container { + display: flex; + flex-direction: column; + height: 100%; +} + +.event-details-content { + display: flex; + flex: 1; + overflow: hidden; +} + +.vertical-tabs-sidebar { + display: flex; + flex-direction: column; + width: 48px; + border-right: 1px solid var(--mat-sys-outline-variant); + padding-top: 8px; + align-items: center; + gap: 8px; + + button { + border-radius: 6px !important; + + ::ng-deep { + .mat-mdc-button-persistent-ripple, + .mat-mdc-button-ripple, + .mat-mdc-button-persistent-ripple::before, + .mat-mdc-focus-indicator { + border-radius: 6px !important; + } + } + + &.active { + background-color: var(--mat-sys-secondary-container) !important; + color: var(--mat-sys-on-secondary-container) !important; + } + } +} + +.vertical-tabs-content { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; + overflow-y: auto; +} + +.event-details-header { + display: flex; + justify-content: flex-end; + align-items: center; + border-bottom: 1px solid var(--mat-sys-outline-variant); + height: 48px; + flex-shrink: 0; +} + +.empty-state { + padding: 16px; + text-align: center; + color: var(--mat-sys-on-surface-variant); + font-style: italic; +} + +.details-content { + color: var(--side-panel-details-content-color); font-size: 14px; - font-style: normal; - font-weight: 400; - margin-right: 10px; } -.event-title { - font-family: 'Google Sans Mono', monospace; +.event-graph-wrapper { + display: flex; + flex-direction: column; + height: 100%; + width: 100%; +} + +.breadcrumb-container { + display: flex; + align-items: center; + font-size: 13px; + color: var(--mat-sys-on-surface-variant); + padding: 8px 12px; + + span { + font-weight: 500; + margin-right: 8px; + color: var(--mat-sys-on-surface); + } + + .breadcrumb-item { + background: none; + border: none; + color: var(--mat-sys-primary); + font-size: 13px; + padding: 2px 4px; + + &.active { + font-weight: 500; + color: var(--mat-sys-on-surface); + } + + &:disabled { + color: var(--mat-sys-on-surface); + font-weight: 500; + } + } + + .breadcrumb-separator { + font-size: 16px; + width: 16px; + height: 16px; + display: flex; + align-items: center; + justify-content: center; + color: var(--mat-sys-on-surface-variant); + margin: 0 4px; + } } -.spacer { - flex: 1 1 auto; +.graph-header { + display: flex; + align-items: center; + font-size: 13px; + color: var(--mat-sys-on-surface-variant); + background-color: var(--mat-sys-surface-container-lowest); + padding: 8px 16px; + border-bottom: 1px solid var(--mat-sys-outline-variant); + + span { + font-weight: 500; + margin-right: 8px; + color: var(--mat-sys-on-surface); + } } -.events-container { - margin-top: 20px; +.event-graph-container { + flex: 1; + overflow: hidden; + padding: 16px; + position: relative; } -.event-container { +.fullscreen-graph-button { + position: absolute; + top: 4px; + right: 4px; + z-index: 10; + width: 48px !important; + height: 48px !important; + padding: 0 !important; + display: flex !important; + justify-content: center !important; + align-items: center !important; + + mat-icon { + font-size: 28px !important; + width: 28px !important; + height: 28px !important; + line-height: 28px !important; + margin: 0 !important; + padding: 0 !important; + } +} + +.event-graph-container .svg-graph-wrapper { + width: 100%; + height: 100%; display: flex; - flex-direction: row; - margin-top: 20px; + justify-content: center; + align-items: center; } -.function-event-button { - margin-top: 11px; +.event-graph-container ::ng-deep svg { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + display: block; + + /* Make the graph background match the panel */ + > g.graph > polygon:first-child { + fill: transparent !important; + } } -.event-list { - @include mat.list-overrides( - ( - active-indicator-color: var(--event-tab-event-list-active-indicator-color), - list-item-container-color: - var(--event-tab-event-list-list-item-container-color), - list-item-label-text-size: 14px, - list-item-label-text-weight: 400, - list-item-one-line-container-height: 52px, - ) - ); + + +.request-response-loading-spinner-container { + display: flex; + justify-content: center; + align-items: center; + margin-top: 2em; +} + +.request-response-empty-state { + display: flex; + justify-content: center; + align-items: center; + margin-top: 2em; + font-style: italic; +} + +.id-text { + font-family: 'Google Sans Mono', monospace; + font-size: 12px; +} + +.id-cell { + display: flex; + align-items: center; + gap: 4px; + overflow: hidden; + + > :first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; + } } -:host ::ng-deep .mdc-list-item { - border: 1px solid var(--event-tab-mdc-list-item-border-color); - cursor: pointer; +.copy-id-button { + width: 24px !important; + height: 24px !important; + padding: 0 !important; + line-height: 24px !important; + flex-shrink: 0; + margin: -4px 0 !important; - &:hover { - background-color: var(--event-tab-mdc-list-item-hover-background-color); + .mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + line-height: 14px; } } -.event-header { +.info-tables-container { + padding: 16px; + overflow-y: auto; display: flex; - justify-content: space-between; + flex-direction: column; + gap: 24px; } + +.invocation-selector-button { + ::ng-deep .mdc-button__label { + width: 100%; + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + justify-content: space-between; + } +} + diff --git a/src/app/components/event-tab/event-tab.component.spec.ts b/src/app/components/event-tab/event-tab.component.spec.ts index e33508f5..32abd80d 100644 --- a/src/app/components/event-tab/event-tab.component.spec.ts +++ b/src/app/components/event-tab/event-tab.component.spec.ts @@ -25,7 +25,11 @@ import {NoopAnimationsModule} from '@angular/platform-browser/animations'; import {Span} from '../../core/models/Trace'; import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag'; +import {TRACE_SERVICE} from '../../core/services/interfaces/trace'; +import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state'; import {MockFeatureFlagService} from '../../core/services/testing/mock-feature-flag.service'; +import {MockTraceService} from '../../core/services/testing/mock-trace.service'; +import {MockUiStateService} from '../../core/services/testing/mock-ui-state.service'; import {EventTabComponent} from './event-tab.component'; import {TraceChartComponent} from './trace-chart/trace-chart.component'; @@ -124,6 +128,8 @@ describe('EventTabComponent', () => { providers: [ {provide: MatDialog, useValue: matDialogSpy}, {provide: FEATURE_FLAG_SERVICE, useValue: featureFlagService}, + {provide: UI_STATE_SERVICE, useClass: MockUiStateService}, + {provide: TRACE_SERVICE, useClass: MockTraceService}, ], }) .compileComponents(); @@ -131,6 +137,11 @@ describe('EventTabComponent', () => { fixture = TestBed.createComponent(EventTabComponent); component = fixture.componentInstance; loader = TestbedHarnessEnvironment.loader(fixture); + + // Set required inputs + fixture.componentRef.setInput('eventDataSize', 0); + fixture.componentRef.setInput('selectedEvent', undefined); + matDialogSpy.open.calls.reset(); fixture.detectChanges(); }); @@ -138,219 +149,4 @@ describe('EventTabComponent', () => { it('should create', () => { expect(component).toBeTruthy(); }); - - it('should display "No conversations" if eventsMap is empty', () => { - expect(fixture.nativeElement.textContent).toContain('No conversations'); - }); - - describe('with events', () => { - beforeEach(async () => { - fixture.componentRef.setInput('eventsMap', MOCK_EVENTS_MAP); - fixture.detectChanges(); - await fixture.whenStable(); - }); - - it('should display events list by default', async () => { - const list = await loader.getHarness(MatListHarness); - const items = await list.getItems(); - expect(items.length).toBe(2); - expect(await items[0].getFullText()).toContain('Event 1 Title'); - expect(await items[1].getFullText()).toContain('Event 2 Title'); - }); - - it('should emit selectedEvent on event click', async () => { - spyOn(component.selectedEvent, 'emit'); - const list = await loader.getHarness(MatListHarness); - const items = await list.getItems(); - await (await items[0].host()).click(); - expect(component.selectedEvent.emit).toHaveBeenCalledWith('event1'); - }); - - it('should not show toggle if traceData is empty', async () => { - const hasToggleGroup = fixture.nativeElement.querySelector( - 'mat-button-toggle-group', - ); - expect(hasToggleGroup).toBeNull(); - }); - }); - - describe('with trace data', () => { - beforeEach(async () => { - fixture.componentRef.setInput('eventsMap', MOCK_EVENTS_MAP); - fixture.componentRef.setInput('traceData', MOCK_TRACE_DATA); - fixture.detectChanges(); - await fixture.whenStable(); - }); - - it('should show toggle buttons', async () => { - const toggles = await loader.getAllHarnesses(MatButtonToggleHarness); - expect(toggles.length).toBe(2); - expect(await toggles[0].getText()).toBe('Events'); - expect(await toggles[1].getText()).toBe('Trace'); - }); - - it('should switch to trace view and display traces', async () => { - const traceToggle = await loader.getHarness( - MatButtonToggleHarness.with({text: 'Trace'}), - ); - await traceToggle.check(); - fixture.detectChanges(); - - const list = await loader.getHarness(MatListHarness); - const items = await list.getItems(); - expect(items.length).toBe(1); - expect(await items[0].getFullText()).toContain('Invocation 21332-322222'); - }); - - it('should open dialog when trace item is clicked', async () => { - const traceToggle = await loader.getHarness( - MatButtonToggleHarness.with({text: 'Trace'}), - ); - await traceToggle.check(); - fixture.detectChanges(); - - const list = await loader.getHarness(MatListHarness); - const items = await list.getItems(); - await (await items[0].host()).click(); - - expect(matDialogSpy.open).toHaveBeenCalledWith(TraceChartComponent, { - width: 'auto', - maxWidth: '90vw', - data: { - spans: component.spansByTraceId().get('trace-1'), - invocId: '21332-322222', - }, - }); - }); - - it('should display multiple traces if present', async () => { - const MOCK_TRACE_DATA_WITH_MULTIPLE_TRACES: Span[] = [ - ...MOCK_TRACE_DATA, - { - name: 'agent.act-2', - start_time: 1733084700000000000, - end_time: 1733084760000000000, - span_id: 'span-10', - trace_id: 'trace-2', - attributes: { - 'event_id': 10, - 'gcp.vertex.agent.invocation_id': 'invoc-2', - 'gcp.vertex.agent.llm_request': '{}', - }, - }, - ]; - fixture.componentRef.setInput( - 'traceData', - MOCK_TRACE_DATA_WITH_MULTIPLE_TRACES, - ); - fixture.detectChanges(); - await fixture.whenStable(); - - const traceToggle = await loader.getHarness( - MatButtonToggleHarness.with({text: 'Trace'}), - ); - await traceToggle.check(); - fixture.detectChanges(); - - const list = await loader.getHarness(MatListHarness); - const items = await list.getItems(); - expect(items.length).toBe(2); - expect(await items[0].getFullText()).toContain('Invocation 21332-322222'); - expect(await items[1].getFullText()).toContain('Invocation invoc-2'); - }); - }); -}); - -describe('EventTabComponent feature disabling', () => { - let component: EventTabComponent; - let fixture: ComponentFixture; - let featureFlagService: MockFeatureFlagService; - let matDialogSpy: jasmine.SpyObj; - const mockDialogRef = { - close: jasmine.createSpy('close'), - }; - - beforeEach(async () => { - featureFlagService = new MockFeatureFlagService(); - matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']); - - featureFlagService.isTraceEnabledResponse.next(false); - - await TestBed - .configureTestingModule({ - imports: [EventTabComponent, NoopAnimationsModule], - providers: [ - {provide: MatDialog, useValue: matDialogSpy}, - {provide: FEATURE_FLAG_SERVICE, useValue: featureFlagService}, - ], - }) - .compileComponents(); - - fixture = TestBed.createComponent(EventTabComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('traceData', [ - { - trace_id: '1', - span_id: '1', - start_time: 1, - end_time: 2, - name: 'test', - }, - ]); - fixture.detectChanges(); - }); - - it('should hide the Trace mat-button-toggle', () => { - const traceToggle = fixture.nativeElement.querySelector( - 'mat-button-toggle[value="trace"]', - ); - expect(traceToggle).toBeNull(); - }); -}); - -describe('EventTabComponent feature disabling', () => { - let component: EventTabComponent; - let fixture: ComponentFixture; - let featureFlagService: MockFeatureFlagService; - let matDialogSpy: jasmine.SpyObj; - const mockDialogRef = { - close: jasmine.createSpy('close'), - }; - - beforeEach(async () => { - featureFlagService = new MockFeatureFlagService(); - matDialogSpy = jasmine.createSpyObj('MatDialog', ['open']); - - featureFlagService.isTraceEnabledResponse.next(false); - - await TestBed - .configureTestingModule({ - imports: [EventTabComponent, NoopAnimationsModule], - providers: [ - {provide: MatDialog, useValue: matDialogSpy}, - {provide: FEATURE_FLAG_SERVICE, useValue: featureFlagService}, - ], - }) - .compileComponents(); - - fixture = TestBed.createComponent(EventTabComponent); - component = fixture.componentInstance; - fixture.componentRef.setInput('traceData', [ - { - trace_id: '1', - span_id: '1', - start_time: 1, - end_time: 2, - name: 'test', - }, - ]); - fixture.detectChanges(); - }); - - it('should hide the Trace mat-button-toggle', () => { - const traceToggle = fixture.nativeElement.querySelector( - 'mat-button-toggle[value="trace"]', - ); - expect(traceToggle).toBeNull(); - }); }); diff --git a/src/app/components/event-tab/event-tab.component.ts b/src/app/components/event-tab/event-tab.component.ts index 9d283ba7..164d7f21 100644 --- a/src/app/components/event-tab/event-tab.component.ts +++ b/src/app/components/event-tab/event-tab.component.ts @@ -1,107 +1,285 @@ -/** - * @license - * Copyright 2025 Google LLC - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -import {ChangeDetectionStrategy, Component, EventEmitter, Input, OnChanges, Output, SimpleChanges, computed, inject, input, signal} from '@angular/core'; -import {MatDialog} from '@angular/material/dialog'; - -import {Span} from '../../core/models/Trace'; -import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag'; -import {TraceChartComponent} from './trace-chart/trace-chart.component'; -import { MatButtonToggleGroup, MatButtonToggle } from '@angular/material/button-toggle'; -import { FormsModule } from '@angular/forms'; -import { MatList, MatListItem } from '@angular/material/list'; -import {AsyncPipe, KeyValuePipe} from '@angular/common'; -import {EventTabMessagesInjectionToken} from './event-tab.component.i18n'; -import {InvocIdPipe} from './invoc-id.pipe'; +import {AsyncPipe, DatePipe, KeyValuePipe} from '@angular/common'; +import {ChangeDetectionStrategy, Component, computed, effect, inject, input, output, ViewChild, ElementRef} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {MatIconButton, MatButtonModule} from '@angular/material/button'; +import {MatIcon} from '@angular/material/icon'; +import {MatPaginator, PageEvent} from '@angular/material/paginator'; +import {MatProgressSpinner} from '@angular/material/progress-spinner'; +import {MatTooltip} from '@angular/material/tooltip'; +import {MatMenuModule, MatMenuTrigger} from '@angular/material/menu'; +import {type SafeHtml} from '@angular/platform-browser'; +import {NgxJsonViewerModule} from 'ngx-json-viewer'; +import {InfoTable} from '../info-table/info-table'; + +import {Event} from '../../core/models/types'; +import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state'; +import {SidePanelMessagesInjectionToken} from '../side-panel/side-panel.component.i18n'; +import {SpanNode} from '../../core/models/Trace'; +import {TRACE_SERVICE} from '../../core/services/interfaces/trace'; +import {addSvgNodeHoverEffects} from '../../utils/svg-interaction.utils'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, - selector: 'app-event-tab', - templateUrl: './event-tab.component.html', - styleUrl: './event-tab.component.scss', - standalone: true, - imports: [ - MatButtonToggleGroup, - FormsModule, - MatButtonToggle, - MatList, - MatListItem, - KeyValuePipe, - InvocIdPipe, - AsyncPipe, - ], + changeDetection: ChangeDetectionStrategy.OnPush, + selector: 'app-event-tab', + templateUrl: './event-tab.component.html', + styleUrls: ['./event-tab.component.scss'], + standalone: true, + imports: [ + AsyncPipe, + DatePipe, + KeyValuePipe, + MatButtonModule, + MatIconButton, + MatIcon, + MatPaginator, + MatProgressSpinner, + MatTooltip, + MatMenuModule, + NgxJsonViewerModule, + InfoTable, + ], }) export class EventTabComponent { - readonly eventsMap = input>(new Map()); - readonly traceData = input([]); - @Output() selectedEvent = new EventEmitter(); - private readonly dialog = inject(MatDialog); - private readonly featureFlagService = inject(FEATURE_FLAG_SERVICE); - protected readonly i18n = inject(EventTabMessagesInjectionToken); - - readonly view = signal('events'); - readonly isTraceView = computed(() => this.view() === 'trace'); - readonly spansByTraceId = computed(() => { - if (!this.traceData() || this.traceData().length == 0) { - return new Map(); - } - return this.traceData().reduce((map, span) => { - const key = span.trace_id; - const group = map.get(key); - if (group) { - span.invoc_id = span.attributes?.['gcp.vertex.agent.invocation_id']; - group.push(span); - group.sort((a: Span, b: Span) => a.start_time - b.start_time); - } else { - map.set(key, [span]); + readonly eventDataSize = input.required(); + readonly eventDataMap = input>(new Map()); + readonly selectedEventIndex = input(); + readonly selectedEvent = input.required(); + readonly filteredSelectedEvent = input(); + readonly renderedEventGraph = input(); + readonly rawSvgString = input(null); + readonly llmRequest = input(); + readonly llmResponse = input(); + readonly traceData = input([]); + readonly appName = input(''); + readonly selectedEventGraphPath = input(''); + readonly hasSubWorkflows = input(false); + readonly graphsAvailable = input(true); + readonly invocationDisplayMap = input>(new Map()); + + readonly invocationDisplayEntries = computed(() => { + return Array.from(this.invocationDisplayMap().entries()).map(([key, value]) => ({key, value})); + }); + + readonly breadcrumbs = computed(() => { + const path = this.selectedEventGraphPath(); + if (!path) return []; + return path.split('/').filter(s => s); + }); + + readonly functionCalls = computed(() => { + const parts = this.selectedEvent()?.content?.parts || []; + return parts.filter(p => !!p.functionCall).map(p => p.functionCall); + }); + + readonly functionResponses = computed(() => { + const parts = this.selectedEvent()?.content?.parts || []; + return parts.filter(p => !!p.functionResponse).map(p => p.functionResponse); + }); + + readonly page = output(); + readonly closeSelectedEvent = output(); + readonly openImageDialog = output(); + readonly switchToTraceView = output(); + readonly showAgentStructureGraph = output(); + readonly drillDownNodePath = output(); + readonly selectEventById = output(); + readonly jumpToInvocation = output(); + + onInvocationSelected(invocationId: string) { + this.jumpToInvocation.emit(invocationId); + } + + @ViewChild('eventMenuTrigger') eventMenuTrigger!: MatMenuTrigger; + @ViewChild('graphContainer') graphContainer!: ElementRef; + + menuEvents: any[] = []; + menuPos = { x: 0, y: 0 }; + + protected readonly uiStateService = inject(UI_STATE_SERVICE); + protected readonly traceService = inject(TRACE_SERVICE); + readonly i18n = inject(SidePanelMessagesInjectionToken); + + readonly isEventRequestResponseLoadingSignal = toSignal( + this.uiStateService.isEventRequestResponseLoading(), {initialValue: false}); + + readonly associatedSpans = computed(() => { + const ev = this.selectedEvent(); + if (!ev || !ev.id) return []; + + const allSpans = this.traceData(); + if (!allSpans) return []; + + const flatten = (arr: any[]): any[] => { + let result: any[] = []; + for (const item of arr) { + result.push(item); + if (item.children) { + result = result.concat(flatten(item.children)); + } } - return map; - }, new Map()); + return result; + }; + + const flatSpans = flatten(allSpans); + return flatSpans.filter(s => s.attributes && s.attributes['gcp.vertex.agent.event_id'] === ev.id); }); - showJson: boolean[] = Array(this.eventsMap().size).fill(false); - readonly isTraceEnabledObs = this.featureFlagService.isTraceEnabled(); + private _selectedDetailTab: 'event' | 'raw' | 'request' | 'response' | 'graph' | 'metadata' = 'event'; + + get selectedDetailTab() { + return this._selectedDetailTab; + } + + set selectedDetailTab(tab: 'event' | 'raw' | 'request' | 'response' | 'graph' | 'metadata') { + this._selectedDetailTab = tab; + if (tab === 'graph') { + setTimeout(() => { + if (this.graphContainer?.nativeElement) { + addSvgNodeHoverEffects(this.graphContainer.nativeElement, (nodeName: string, mouseEvent?: MouseEvent) => { + this.handleNodeClick(nodeName, mouseEvent); + }); + } + }, 50); + } + } - toggleJson(index: number) { - this.showJson[index] = !this.showJson[index]; + copiedId: string | null = null; + + copyToClipboard(value: string | undefined | null) { + if (!value) return; + navigator.clipboard.writeText(value).then(() => { + this.copiedId = value; + setTimeout(() => this.copiedId = null, 2000); + }); } - selectEvent(key: string) { - this.selectedEvent.emit(key); + switchToSpan(span: any) { + this.switchToTraceView.emit(); + this.traceService.selectedRow(span); } - mapOrderPreservingSort = (a: any, b: any): number => 0; + constructor() { + effect(() => { + const svgTree = this.renderedEventGraph(); + const currentTab = this._selectedDetailTab; + if (svgTree && currentTab === 'graph') { + setTimeout(() => { + if (this.graphContainer?.nativeElement) { + addSvgNodeHoverEffects(this.graphContainer.nativeElement, (nodeName: string, mouseEvent?: MouseEvent) => { + this.handleNodeClick(nodeName, mouseEvent); + }); + } + }, 50); + } + }); + + effect(() => { + const event = this.selectedEvent(); + if (event) { + let isTabValid = false; + const currentTab = this.selectedDetailTab; + if (currentTab === 'event') { + isTabValid = true; + } else if (currentTab === 'raw') { + isTabValid = true; + } else if (currentTab === 'request') { + isTabValid = this.isEventRequestResponseLoadingSignal() || !!(this.llmRequest() && Object.keys(this.llmRequest()!).length > 0); + } else if (currentTab === 'response') { + isTabValid = this.isEventRequestResponseLoadingSignal() || !!(this.llmResponse() && Object.keys(this.llmResponse()!).length > 0); + } else if (currentTab === 'graph') { + isTabValid = true; + } else if (currentTab === 'metadata') { + isTabValid = !!(event.usageMetadata && Object.keys(event.usageMetadata).length > 0); + } + + if (!isTabValid) { + this.selectedDetailTab = 'event'; + } + } + }); + } - findInvocId(spans: Span[]) { - return spans - .find( - item => item.attributes !== undefined && - 'gcp.vertex.agent.invocation_id' in item.attributes) - ?.attributes['gcp.vertex.agent.invocation_id'] + formatTime(timestamp: number | undefined): string { + if (!timestamp) return 'N/A'; + // If timestamp is before 2286-11-20 in seconds, treat as seconds. + const inMs = timestamp < 10000000000 ? timestamp * 1000 : timestamp; + return new Date(inMs).toLocaleString(); } - openDialog(traceId: string): void { - const spans = this.spansByTraceId().get(traceId); - if (!spans) return; + isObject(value: any): boolean { + return value !== null && typeof value === 'object'; + } + + handleNodeClick(nodeName: string, mouseEvent?: MouseEvent) { + let allEvents = Array.from(this.eventDataMap().values()); + const selectedEv = this.selectedEvent(); + const targetInvocationId = selectedEv?.invocationId; + + if (targetInvocationId) { + allEvents = allEvents.filter(ev => ev.invocationId === targetInvocationId); + } + + const travelsForNode: any[][] = []; + let currentTravel: any[] = []; + let lastNodeName = ''; + + allEvents.forEach(ev => { + let np = ev.nodeInfo?.path; + if (ev.author === 'user') { + np = '__START__'; + } + if (!np) return; + + const segments = np.split('/'); + let evNodeName = segments[segments.length - 1]; + let evGraphPath = ''; - const dialogRef = this.dialog.open(TraceChartComponent, { - width: 'auto', - maxWidth: '90vw', - data: {spans, invocId: this.findInvocId(spans)}, + if (segments.length >= 2 && segments[segments.length - 1] === 'call_llm' && segments[segments.length - 2] === ev.author) { + evNodeName = segments[segments.length - 2]; + evGraphPath = segments.slice(1, -2).join('/'); + } else { + evGraphPath = segments.slice(1, -1).join('/'); + } + + if (evGraphPath === this.selectedEventGraphPath()) { + if (evNodeName !== lastNodeName) { + if (lastNodeName === nodeName && currentTravel.length > 0) { + travelsForNode.push(currentTravel); + } + lastNodeName = evNodeName; + currentTravel = []; + } + + if (evNodeName === nodeName) { + currentTravel.push(ev); + } + } }); + + if (lastNodeName === nodeName && currentTravel.length > 0) { + travelsForNode.push(currentTravel); + } + + if (travelsForNode.length === 0) { + return; + } else if (travelsForNode.length === 1) { + this.selectEventById.emit(travelsForNode[0][0].id); + } else { + this.menuEvents = travelsForNode.map((travel, index) => ({ + id: travel[0].id, + runIndex: index + 1, + timestamp: travel[0].timestamp + })); + if (mouseEvent) { + this.menuPos = { x: mouseEvent.clientX, y: mouseEvent.clientY }; + } + this.eventMenuTrigger.openMenu(); + } } + + handleMenuSelection(event: any) { + this.selectEventById.emit(event.id); + } + + protected readonly Object = Object; } diff --git a/src/app/components/event-tab/trace-chart/trace-chart.component.html b/src/app/components/event-tab/trace-chart/trace-chart.component.html index 852106da..41fcd4d9 100644 --- a/src/app/components/event-tab/trace-chart/trace-chart.component.html +++ b/src/app/components/event-tab/trace-chart/trace-chart.component.html @@ -29,16 +29,16 @@

Invocation {{ data.invocId }}

{{ getSpanIcon(node.span.name) }}
- {{ node.span.name }} + {{ formatSpanName(node.span.name) }} - ({{ (toMs(node.span.end_time) - toMs(node.span.start_time)).toFixed(2) }}ms) + ({{ formatDuration(node.span.end_time - node.span.start_time) }})
- {{ (toMs(node.span.end_time) - toMs(node.span.start_time)).toFixed(2) }}ms + {{ formatDuration(node.span.end_time - node.span.start_time) }}
diff --git a/src/app/components/event-tab/trace-chart/trace-chart.component.scss b/src/app/components/event-tab/trace-chart/trace-chart.component.scss index 47969a9d..473d085e 100644 --- a/src/app/components/event-tab/trace-chart/trace-chart.component.scss +++ b/src/app/components/event-tab/trace-chart/trace-chart.component.scss @@ -13,82 +13,17 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -.trace-container { - width: 100%; - white-space: nowrap; - font-size: 12px; -} +@use '../../trace-shared'; .trace-label { width: 400px; - color: var(--trace-chart-trace-label-color); - text-overflow: ellipsis; - font-family: 'Google Sans Mono', monospace; font-size: 14px; - font-style: normal; - font-weight: 500; - line-height: 20px; - letter-spacing: 0px; } .trace-bar-container { width: 50vw; - position: relative; - height: 16px; -} - -.trace-bar { - position: absolute; - height: 18px; - background-color: var(--trace-chart-trace-bar-background-color); - border-radius: 4px; - padding-left: 4px; - overflow: hidden; - font-size: 11px; - line-height: 16px; - color: var(--trace-chart-trace-bar-color); - font-family: 'Google Sans'; -} - -.trace-duration { - color: var(--trace-chart-trace-duration-color); - font-weight: normal; - margin-left: 4px; } .trace-row { - display: flex; - align-items: stretch; /* <-- stretch ensures full height */ - position: relative; - height: 32px; /* Give rows a little more vertical space */ -} - -.trace-indent { - display: flex; - flex-shrink: 0; - height: 100%; /* Make sure it stretches */ -} - -.indent-connector { - width: 20px; - position: relative; - height: 100%; -} - -.vertical-line { - position: absolute; - top: 0; - bottom: 0; - left: 9px; - width: 1px; - background-color: var(--trace-chart-vertical-line-background-color); -} - -.horizontal-line { - position: absolute; - top: 50%; - left: 9px; - width: 10px; - height: 1px; - background-color: var(--trace-chart-horizontal-line-background-color); + align-items: stretch; } diff --git a/src/app/components/event-tab/trace-chart/trace-chart.component.ts b/src/app/components/event-tab/trace-chart/trace-chart.component.ts index 48038043..87e1cb30 100644 --- a/src/app/components/event-tab/trace-chart/trace-chart.component.ts +++ b/src/app/components/event-tab/trace-chart/trace-chart.component.ts @@ -44,7 +44,7 @@ interface TimeTick { } @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-trace-chart', templateUrl: './trace-chart.component.html', styleUrl: './trace-chart.component.scss', @@ -106,6 +106,18 @@ export class TraceChartComponent implements OnInit { return nanos / 1_000_000; } + formatDuration(nanos: number): string { + if (nanos === 0) return '0us'; + if (nanos < 1000) return `${nanos}ns`; + if (nanos < 1_000_000) return `${(nanos / 1000).toFixed(2)}us`; + if (nanos < 1_000_000_000) return `${(nanos / 1_000_000).toFixed(2)}ms`; + if (nanos < 60_000_000_000) return `${(nanos / 1_000_000_000).toFixed(2)}s`; + + const minutes = Math.floor(nanos / 60_000_000_000); + const seconds = ((nanos % 60_000_000_000) / 1_000_000_000).toFixed(2); + return `${minutes}m ${seconds}s`; + } + getRelativeStart(span: Span): number { return ((this.toMs(span.start_time) - this.baseStartTimeMs) / this.totalDurationMs) * 100; } @@ -131,6 +143,16 @@ export class TraceChartComponent implements OnInit { return "start"; } + formatSpanName(name: string): string { + if (name.startsWith('invoke_agent ')) { + return name.substring('invoke_agent '.length); + } + if (name.startsWith('execute_tool ')) { + return name.substring('execute_tool '.length); + } + return name; + } + getArray(n: number): number[] { return Array.from({ length: n }); } diff --git a/src/app/components/hover-info-button/hover-info-button.component.html b/src/app/components/hover-info-button/hover-info-button.component.html new file mode 100644 index 00000000..1e42d51e --- /dev/null +++ b/src/app/components/hover-info-button/hover-info-button.component.html @@ -0,0 +1,29 @@ + + + diff --git a/src/app/components/hover-info-button/hover-info-button.component.scss b/src/app/components/hover-info-button/hover-info-button.component.scss new file mode 100644 index 00000000..d01caf88 --- /dev/null +++ b/src/app/components/hover-info-button/hover-info-button.component.scss @@ -0,0 +1,52 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.hover-info-button { + color: var(--mat-sys-on-surface) !important; + background-color: var(--mat-sys-surface-container-high) !important; + border-color: transparent !important; + margin: 5px 5px 5px 0px; + font-size: 11px !important; + padding: 6px 12px !important; + min-height: 24px !important; + height: 24px !important; + border-radius: 8px !important; + font-family: 'Roboto Mono', monospace !important; + max-width: 300px; + text-align: left; + display: inline-flex; + align-items: center; + + mat-icon { + font-size: 18px !important; + width: 18px !important; + height: 18px !important; + margin-right: 6px !important; + color: var(--mat-sys-on-surface) !important; + } +} + +:host ::ng-deep .hover-info-button { + background-color: var(--mat-sys-surface-container-high) !important; + color: var(--mat-sys-on-surface) !important; + + .mdc-button__label { + overflow: hidden !important; + text-overflow: ellipsis !important; + white-space: nowrap !important; + } +} diff --git a/src/app/components/hover-info-button/hover-info-button.component.ts b/src/app/components/hover-info-button/hover-info-button.component.ts new file mode 100644 index 00000000..e90539bc --- /dev/null +++ b/src/app/components/hover-info-button/hover-info-button.component.ts @@ -0,0 +1,48 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {CommonModule} from '@angular/common'; +import {Component, EventEmitter, Input, Output} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {JsonTooltipDirective} from '../../directives/html-tooltip.directive'; + +@Component({ + selector: 'app-hover-info-button', + templateUrl: './hover-info-button.component.html', + styleUrl: './hover-info-button.component.scss', + standalone: true, + imports: [ + CommonModule, + MatButtonModule, + MatIconModule, + JsonTooltipDirective, + ], +}) +export class HoverInfoButtonComponent { + @Input() icon: string = ''; + @Input() text: string = ''; + @Input() tooltipContent: any = null; + @Input() tooltipTitle: string = ''; + @Input() disabled: boolean = false; + + @Output() readonly buttonClick = new EventEmitter(); + + handleClick(event: MouseEvent) { + this.buttonClick.emit(event); + } +} diff --git a/src/app/components/info-table/info-table.html b/src/app/components/info-table/info-table.html new file mode 100644 index 00000000..e1850519 --- /dev/null +++ b/src/app/components/info-table/info-table.html @@ -0,0 +1,13 @@ + + + + +@if (title()) { + + {{ title() }} + +} + + + + diff --git a/src/app/components/info-table/info-table.scss b/src/app/components/info-table/info-table.scss new file mode 100644 index 00000000..56632c26 --- /dev/null +++ b/src/app/components/info-table/info-table.scss @@ -0,0 +1,51 @@ +:host { + display: table; + width: 100%; + border-collapse: separate; + border-spacing: 0; + font-family: inherit; + font-size: 13px; + background-color: var(--mat-sys-surface); + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 8px; + overflow: hidden; + table-layout: fixed; + + thead { + background-color: var(--mat-sys-surface-container-low); + + th { + text-align: left; + padding: 12px 16px; + font-weight: 500; + color: var(--mat-sys-on-surface); + border-bottom: 1px solid var(--mat-sys-outline-variant); + } + } + + .label-col { + width: 30%; + } + + ::ng-deep tbody { + tr { + td { + padding: 10px 16px; + color: var(--mat-sys-on-surface-variant); + border-bottom: 1px solid var(--mat-sys-outline-variant); + overflow: hidden; + overflow-wrap: anywhere; + + &:first-child { + font-weight: 500; + color: var(--mat-sys-on-surface); + background-color: var(--mat-sys-surface-container-lowest); + border-right: 1px solid var(--mat-sys-outline-variant); + } + } + &:last-child td { + border-bottom: none; + } + } + } +} diff --git a/src/app/components/info-table/info-table.spec.ts b/src/app/components/info-table/info-table.spec.ts new file mode 100644 index 00000000..090e8415 --- /dev/null +++ b/src/app/components/info-table/info-table.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InfoTable } from './info-table'; + +describe('InfoTable', () => { + let component: InfoTable; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InfoTable] + }) + .compileComponents(); + + fixture = TestBed.createComponent(InfoTable); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/info-table/info-table.ts b/src/app/components/info-table/info-table.ts new file mode 100644 index 00000000..2ded6e38 --- /dev/null +++ b/src/app/components/info-table/info-table.ts @@ -0,0 +1,12 @@ +import { Component, input } from '@angular/core'; + +@Component({ + selector: 'table[app-info-table]', + imports: [], + templateUrl: './info-table.html', + styleUrl: './info-table.scss', + host: { 'class': 'info-table' } +}) +export class InfoTable { + title = input(); +} diff --git a/src/app/components/inline-edit/inline-edit.component.html b/src/app/components/inline-edit/inline-edit.component.html new file mode 100644 index 00000000..e86b2604 --- /dev/null +++ b/src/app/components/inline-edit/inline-edit.component.html @@ -0,0 +1,31 @@ +
+
+ +
+ + @if (!isEditing) { + + } @else { + + + } +
diff --git a/src/app/components/inline-edit/inline-edit.component.scss b/src/app/components/inline-edit/inline-edit.component.scss new file mode 100644 index 00000000..36b0a1fb --- /dev/null +++ b/src/app/components/inline-edit/inline-edit.component.scss @@ -0,0 +1,76 @@ +:host { + display: block; + max-width: 100%; + min-width: 0; + width: 100%; +} + +.inline-edit-container { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + max-width: 100%; + min-width: 0; + box-sizing: border-box; +} + +.inline-edit-text-wrapper { + flex: 0 1 auto; + min-width: 0; + display: flex; + align-items: center; +} + +.inline-edit-input { + min-width: 48px; + max-width: 100%; + + /* Apply padding/border visually, but cancel their layout footprint with negative margin */ + padding: 2px 6px; + margin: -3px -7px; + border: 1px solid var(--chat-toolbar-session-text-color, #ccc); + border-radius: 4px; + + color: var(--chat-toolbar-session-id-color, inherit); + font-family: inherit; + font-size: inherit; + font-weight: inherit; + line-height: inherit; + background: transparent; + field-sizing: content; + transition: all 0.2s ease; + + &:focus { + outline: none; + border-color: var(--primary-color, #1a73e8); + } + + &.readonly { + min-width: 0; + border-color: transparent; + cursor: inherit; + + &:focus { + outline: none; + border-color: transparent; + } + } +} + +.inline-edit-action-button { + flex-shrink: 0; + width: 28px !important; + height: 28px !important; + padding: 0 !important; + display: flex; + align-items: center; + justify-content: center; + + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; + } +} diff --git a/src/app/components/inline-edit/inline-edit.component.ts b/src/app/components/inline-edit/inline-edit.component.ts new file mode 100644 index 00000000..a9198e11 --- /dev/null +++ b/src/app/components/inline-edit/inline-edit.component.ts @@ -0,0 +1,58 @@ +import { Component, EventEmitter, Input, Output, ViewChild, ElementRef } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MatButtonModule } from '@angular/material/button'; +import { MatIconModule } from '@angular/material/icon'; +import { MatTooltipModule } from '@angular/material/tooltip'; + +@Component({ + selector: 'app-inline-edit', + standalone: true, + imports: [CommonModule, FormsModule, MatButtonModule, MatIconModule, MatTooltipModule], + templateUrl: './inline-edit.component.html', + styleUrl: './inline-edit.component.scss' +}) +export class InlineEditComponent { + @Input({ required: true }) value = ''; + @Input() displayValue = ''; + @Input() tooltip = ''; + @Input() placeholder = ''; + @Input() textClass = ''; + + @Output() save = new EventEmitter(); + + isEditing = false; + draftValue = ''; + + @ViewChild('editInput') editInput!: ElementRef; + + startEdit() { + this.draftValue = this.value; + this.isEditing = true; + setTimeout(() => { + this.editInput.nativeElement.focus(); + }); + } + + cancelEdit() { + this.isEditing = false; + this.draftValue = ''; + } + + saveEdit() { + this.save.emit(this.draftValue); + this.isEditing = false; + } + + handleKeydown(event: KeyboardEvent) { + if (event.key === 'Enter') { + this.saveEdit(); + } else if (event.key === 'Escape') { + this.cancelEdit(); + } + } + + get effectiveDisplayValue() { + return this.displayValue || this.value; + } +} diff --git a/src/app/components/invocation-menu/invocation-menu.html b/src/app/components/invocation-menu/invocation-menu.html new file mode 100644 index 00000000..fd67523a --- /dev/null +++ b/src/app/components/invocation-menu/invocation-menu.html @@ -0,0 +1 @@ +

invocation-menu works!

diff --git a/src/app/components/invocation-menu/invocation-menu.scss b/src/app/components/invocation-menu/invocation-menu.scss new file mode 100644 index 00000000..e69de29b diff --git a/src/app/components/invocation-menu/invocation-menu.spec.ts b/src/app/components/invocation-menu/invocation-menu.spec.ts new file mode 100644 index 00000000..69ff23e5 --- /dev/null +++ b/src/app/components/invocation-menu/invocation-menu.spec.ts @@ -0,0 +1,23 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { InvocationMenu } from './invocation-menu'; + +describe('InvocationMenu', () => { + let component: InvocationMenu; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [InvocationMenu] + }) + .compileComponents(); + + fixture = TestBed.createComponent(InvocationMenu); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/src/app/components/invocation-menu/invocation-menu.ts b/src/app/components/invocation-menu/invocation-menu.ts new file mode 100644 index 00000000..7f389d6d --- /dev/null +++ b/src/app/components/invocation-menu/invocation-menu.ts @@ -0,0 +1,11 @@ +import { Component } from '@angular/core'; + +@Component({ + selector: 'app-invocation-menu', + imports: [], + templateUrl: './invocation-menu.html', + styleUrl: './invocation-menu.scss', +}) +export class InvocationMenu { + +} diff --git a/src/app/components/json-editor/json-editor.component.ts b/src/app/components/json-editor/json-editor.component.ts index 5d3f65cb..b39de4b3 100644 --- a/src/app/components/json-editor/json-editor.component.ts +++ b/src/app/components/json-editor/json-editor.component.ts @@ -19,7 +19,7 @@ import {AfterViewInit, Component, ElementRef, Input, ChangeDetectionStrategy} fr import {createJSONEditor, Mode} from 'vanilla-jsoneditor'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager,selector: 'app-json-editor', + changeDetection: ChangeDetectionStrategy.Default,selector: 'app-json-editor', templateUrl: './json-editor.component.html', styleUrls: ['./json-editor.component.scss'], }) diff --git a/src/app/components/json-tooltip/json-tooltip.component.ts b/src/app/components/json-tooltip/json-tooltip.component.ts index 36a7abd8..517a4eef 100644 --- a/src/app/components/json-tooltip/json-tooltip.component.ts +++ b/src/app/components/json-tooltip/json-tooltip.component.ts @@ -14,78 +14,79 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {SAFE_VALUES_SERVICE} from '../../core/services/interfaces/safevalues'; - -import {ChangeDetectionStrategy, Component, Input, inject} from '@angular/core'; -import {SafeHtml} from '@angular/platform-browser'; +import { ChangeDetectionStrategy, Component, Input } from '@angular/core'; +import { NgxJsonViewerModule } from 'ngx-json-viewer'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-json-tooltip', - template: `
`, + template: ` +
+ @if (title) { +
{{ title }}
+ } +
+ +
+
+ `, styles: [` :host { display: block; - font-family: 'Courier New', monospace; font-size: 12px; line-height: 1.4; - white-space: pre-wrap; + word-break: break-word; + overflow: hidden; + } + .tooltip-shell { + display: flex; + flex-direction: column; max-width: 800px; + max-height: 80vh; + overflow: hidden; + } + .tooltip-content { + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + scrollbar-gutter: stable; + } + .tooltip-title { + font-weight: 600; + font-size: 9px; + color: var(--mat-sys-primary); + opacity: 0.5; + margin-bottom: 4px; + text-transform: uppercase; + letter-spacing: 0.5px; + position: sticky; + top: 0; + background: inherit; + z-index: 1; + } + ngx-json-viewer { + display: block; + height: auto !important; + min-width: 0; } `], standalone: true, + imports: [NgxJsonViewerModule], }) export class JsonTooltipComponent { - @Input() set json(value: string) { - this.formattedJson = this.syntaxHighlight(value); - } - - formattedJson: SafeHtml = ''; - - readonly sanitizer = inject(SAFE_VALUES_SERVICE); - - private syntaxHighlight(json: string): SafeHtml { - if (!json) return ''; - - try { - // Parse and re-stringify to ensure valid JSON - const obj = JSON.parse(json); - json = JSON.stringify(obj, null, 0); - } catch (e) { - // If not valid JSON, just return the string - return this.sanitizer.bypassSecurityTrustHtml(this.escapeHtml(json)); - } - - // Syntax highlight the JSON - json = json.replace(/&/g, '&').replace(//g, '>'); - json = json.replace( - /("(\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(\s*:)?|\b(true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g, - (match) => { - let cls = 'json-number'; - if (/^"/.test(match)) { - if (/:$/.test(match)) { - cls = 'json-key'; - } else { - cls = 'json-string'; - } - } else if (/true|false/.test(match)) { - cls = 'json-boolean'; - } else if (/null/.test(match)) { - cls = 'json-null'; - } - return '' + match + ''; + @Input() title: string = ''; + @Input() set json(value: any) { + if (typeof value === 'string') { + try { + this.parsedJson = JSON.parse(value); + } catch (e) { + // If not valid JSON, display as string + this.parsedJson = value; } - ); - - return this.sanitizer.bypassSecurityTrustHtml(json); + } else { + this.parsedJson = value; + } } - private escapeHtml(text: string): string { - return text - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } + parsedJson: any = {}; } diff --git a/src/app/components/long-running-response/long-running-response.html b/src/app/components/long-running-response/long-running-response.html index 20a14d30..0b8f5357 100644 --- a/src/app/components/long-running-response/long-running-response.html +++ b/src/app/components/long-running-response/long-running-response.html @@ -14,34 +14,83 @@ limitations under the License. --> +@if (functionCall.responseStatus !== 'sent' && functionCall.responseStatus !== 'sending') {
- @if (functionCall.responseStatus === 'sent') { -
- check_circle - Response sent + @if (hasMessage()) { + +
+
+
- } @else if (functionCall.responseStatus === 'sending') { -
- hourglass_empty - Sending... + + +
+ + @if (hasPayload() || hasResponseSchema()) { +
+ @if (hasPayload()) { + + + } + @if (hasResponseSchema()) { + + + } +
+ } + + +
+ + +
+
} @else { -
- rate_review - -
+
}
+} \ No newline at end of file diff --git a/src/app/components/long-running-response/long-running-response.scss b/src/app/components/long-running-response/long-running-response.scss index 4d4b4b1a..59407231 100644 --- a/src/app/components/long-running-response/long-running-response.scss +++ b/src/app/components/long-running-response/long-running-response.scss @@ -14,89 +14,112 @@ * limitations under the License. */ +:host { + display: block; +} + .response-chip-container { - display: inline-block; - margin: 5px; + display: flex; + flex-direction: column; + gap: 8px; + margin: 5px 5px 5px 0; +} + +.message-box { + background-color: var(--mat-sys-surface-container-high); + border: 1px solid var(--mat-sys-outline-variant); + border-radius: 20px; + padding: 12px 16px; + box-shadow: none; + display: flex; + flex-direction: column; + gap: 12px; +} + +.message-content { + flex: 1; +} + + +.request-card { + display: flex; + flex-direction: column; + gap: 8px; + width: 100%; +} + +.request-card-standalone { + background: color-mix(in srgb, var(--mat-sys-surface-container-high) 70%, transparent); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 30%, transparent); + border-radius: 12px; + padding: 12px; + box-shadow: 0 4px 16px 0 rgba(0, 0, 0, 0.2); + display: flex; + flex-direction: column; + gap: 8px; + max-width: 400px; +} + +.data-buttons { + display: flex; + gap: 8px; } -.input-chip { - display: inline-flex; +.input-container { + display: flex; align-items: center; gap: 4px; - border: 1px solid var(--mat-standard-button-toggle-divider-color); - border-radius: 16px; - padding: 0 4px 0 12px; - background-color: var(--chat-panel-function-event-button-background-color); - height: 30px; + background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 50%, transparent); + border: 1px solid color-mix(in srgb, var(--mat-sys-outline-variant) 50%, transparent); + border-radius: 12px; + padding: 4px 8px; + width: 100%; box-sizing: border-box; - max-width: 300px; + transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; - .input-icon { - color: var(--long-running-response-icon-color); - font-size: 18px; - width: 18px; - height: 18px; + &:focus-within { + border-color: var(--mat-sys-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--mat-sys-primary) 30%, transparent); + background: color-mix(in srgb, var(--mat-sys-surface-container-highest) 70%, transparent); } .response-input { + flex: 1; border: none; outline: none; background: transparent; - font-size: 13px; - flex: 1; - min-width: 120px; - padding: 0; - color: var(--long-running-response-input-text-color); - caret-color: var(--long-running-response-input-caret-color); + font-size: 12px; + font-family: inherit; + color: var(--mat-sys-on-surface); + caret-color: var(--mat-sys-primary); &::placeholder { - color: var(--long-running-response-input-placeholder-color); + color: var(--mat-sys-on-surface-variant); + opacity: 0.6; } } - .send-icon-btn { + .send-button { + color: var(--mat-sys-primary); width: 24px; height: 24px; + min-width: 24px; padding: 0; - min-width: unset; - color: var(--long-running-response-send-button-color); + line-height: 24px; - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - } -} - -.status-chip { - display: inline-flex; - align-items: center; - gap: 4px; - border-radius: 16px; - padding: 0 12px; - font-size: 13px; - font-weight: 500; - height: 32px; - box-sizing: border-box; - border: 1px solid; - line-height: 32px; + box-sizing: border-box; // Ensure padding doesn't push size - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - - &.sending { - background-color: rgba(33, 150, 243, 0.1); - border-color: rgba(33, 150, 243, 0.3); - color: #2196f3; - } + &:disabled { + color: var(--mat-sys-on-surface-variant); + opacity: 0.3; + } - &.sent { - background-color: rgba(76, 175, 80, 0.1); - border-color: rgba(76, 175, 80, 0.3); - color: #4caf50; + mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + } } -} +} \ No newline at end of file diff --git a/src/app/components/long-running-response/long-running-response.ts b/src/app/components/long-running-response/long-running-response.ts index ddafbd1e..33a10c47 100644 --- a/src/app/components/long-running-response/long-running-response.ts +++ b/src/app/components/long-running-response/long-running-response.ts @@ -17,14 +17,16 @@ import {ChangeDetectionStrategy, ChangeDetectorRef, Component, EventEmitter, inject, Input, Output} from '@angular/core'; import {FormsModule} from '@angular/forms'; -import {MatIconButton} from '@angular/material/button'; +import {MatButton, MatIconButton} from '@angular/material/button'; import {MatIcon} from '@angular/material/icon'; -import {AgentRunRequest} from '../../core/models/AgentRunRequest'; -import {AGENT_SERVICE} from '../../core/services/interfaces/agent'; + import {AgentRunRequest} from '../../core/models/AgentRunRequest'; + import {MarkdownComponent} from '../markdown/markdown.component'; + +import {HoverInfoButtonComponent} from '../hover-info-button/hover-info-button.component'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-long-running-response', templateUrl: './long-running-response.html', styleUrl: './long-running-response.scss', @@ -32,6 +34,8 @@ import {AGENT_SERVICE} from '../../core/services/interfaces/agent'; FormsModule, MatIconButton, MatIcon, + MarkdownComponent, + HoverInfoButtonComponent, ], }) export class LongRunningResponseComponent { @@ -40,11 +44,42 @@ export class LongRunningResponseComponent { @Input() userId!: string; @Input() sessionId!: string; - @Output() responseComplete = new EventEmitter(); + @Output() responseComplete = new EventEmitter(); - private readonly agentService = inject(AGENT_SERVICE); private readonly cdr = inject(ChangeDetectorRef); - private responseChunks: any[] = []; + + hasMessage(): boolean { + return !!(this.functionCall.args?.prompt || this.functionCall.args?.message); + } + + getPromptText(): string { + return this.functionCall.args?.prompt || this.functionCall.args?.message || 'Please provide your response'; + } + + hasPayload(): boolean { + return this.functionCall.args?.payload !== undefined && + this.functionCall.args?.payload !== null; + } + + getPayloadJson(): string { + try { + return JSON.stringify(this.functionCall.args?.payload || {}, null, 2); + } catch (e) { + return ''; + } + } + + hasResponseSchema(): boolean { + return !!this.functionCall.args?.response_schema; + } + + getResponseSchemaJson(): string { + try { + return JSON.stringify(this.functionCall.args?.response_schema || {}, null, 2); + } catch (e) { + return ''; + } + } onSend() { if (!this.functionCall.userResponse || @@ -52,46 +87,25 @@ export class LongRunningResponseComponent { return; } - // Update status to sending - this.functionCall.responseStatus = 'sending'; + // Store the user response before sending + this.functionCall.sentUserResponse = this.functionCall.userResponse; + + // Update status to sent + this.functionCall.responseStatus = 'sent'; this.cdr.detectChanges(); - const req: AgentRunRequest = { - appName: this.appName, - userId: this.userId, - sessionId: this.sessionId, - newMessage: { + const content = { role: 'user', parts: [{ functionResponse: { id: this.functionCall.id, name: this.functionCall.name, - response: {'response': this.functionCall.userResponse}, + response: { 'result': this.functionCall.userResponse }, }, }], - }, - functionCallEventId: this.functionCall.functionCallEventId, + functionCallEventId: this.functionCall.functionCallEventId }; - this.responseChunks = []; // Reset chunks array - this.agentService.runSse(req).subscribe({ - next: async (chunkJson) => { - this.responseChunks.push(chunkJson); - }, - error: (err) => { - console.error('SSE error:', err); - this.functionCall.responseStatus = 'pending'; // Reset on error - this.responseChunks = []; - this.cdr.detectChanges(); - }, - complete: () => { - console.log( - 'Long-running response complete for:', this.functionCall.name); - this.functionCall.responseStatus = 'sent'; - this.responseComplete.emit( - this.responseChunks); // Emit chunks for processing - this.cdr.detectChanges(); - }, - }); + this.responseComplete.emit(content); } } diff --git a/src/app/components/markdown/markdown.component.html b/src/app/components/markdown/markdown.component.html index 426a63a9..10f6ce3a 100644 --- a/src/app/components/markdown/markdown.component.html +++ b/src/app/components/markdown/markdown.component.html @@ -17,7 +17,6 @@ diff --git a/src/app/components/markdown/markdown.component.spec.ts b/src/app/components/markdown/markdown.component.spec.ts index b0b5897b..7a978717 100644 --- a/src/app/components/markdown/markdown.component.spec.ts +++ b/src/app/components/markdown/markdown.component.spec.ts @@ -54,7 +54,8 @@ describe('MarkdownComponent', () => { expect(element.querySelector('strong')?.textContent).toBe('bold'); })); - it('should apply italic style when thought is true', () => { + // Skipped: Thought styling removed in UI refactor + xit('should apply italic style when thought is true', () => { fixture.componentRef.setInput('thought', true); fixture.detectChanges(); const markdownElement: HTMLElement|null = @@ -63,7 +64,8 @@ describe('MarkdownComponent', () => { expect(markdownElement?.style.color).toBe('rgb(154, 160, 166)'); }); - it('should apply normal style when thought is false', () => { + // Skipped: Thought styling removed in UI refactor + xit('should apply normal style when thought is false', () => { fixture.componentRef.setInput('thought', false); fixture.detectChanges(); const markdownElement: HTMLElement|null = diff --git a/src/app/components/markdown/markdown.component.ts b/src/app/components/markdown/markdown.component.ts index 04449731..4881d894 100644 --- a/src/app/components/markdown/markdown.component.ts +++ b/src/app/components/markdown/markdown.component.ts @@ -19,11 +19,21 @@ import {CommonModule} from '@angular/common'; import {ChangeDetectionStrategy, Component, input} from '@angular/core'; import {MarkdownModule, provideMarkdown} from 'ngx-markdown'; +import 'prismjs'; +import 'prismjs/components/prism-javascript'; +import 'prismjs/components/prism-typescript'; +import 'prismjs/components/prism-css'; +import 'prismjs/components/prism-json'; +import 'prismjs/components/prism-bash'; +import 'prismjs/components/prism-python'; +import 'prismjs/components/prism-yaml'; + + /** * Renders markdown text. */ @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-markdown', templateUrl: './markdown.component.html', standalone: true, diff --git a/src/app/components/markdown/testing/mock-markdown.component.ts b/src/app/components/markdown/testing/mock-markdown.component.ts index 6af7769d..fefd9b41 100644 --- a/src/app/components/markdown/testing/mock-markdown.component.ts +++ b/src/app/components/markdown/testing/mock-markdown.component.ts @@ -24,14 +24,12 @@ import {MarkdownComponentInterface} from '../markdown.component.interface'; * Mock markdown component for testing. */ @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-markdown', imports: [CommonModule], template: `
- + {{ text() }}
diff --git a/src/app/components/message-feedback/message-feedback.component.scss b/src/app/components/message-feedback/message-feedback.component.scss index 5a091218..34f15640 100644 --- a/src/app/components/message-feedback/message-feedback.component.scss +++ b/src/app/components/message-feedback/message-feedback.component.scss @@ -26,10 +26,6 @@ } &.selected { - background-color: var( - --side-panel-button-filled-container-color, - var(--mat-sys-primary) - ); color: var(--side-panel-button-filled-label-text-color, white); mat-icon { @@ -47,7 +43,6 @@ margin-left: 54px; max-width: 500px; padding: 16px; - background-color: var(--builder-card-background-color); border-radius: 8px; margin-top: 8px; border: 1px solid var(--builder-border-color); diff --git a/src/app/components/message-feedback/message-feedback.component.ts b/src/app/components/message-feedback/message-feedback.component.ts index bebc4f6a..34d447ae 100644 --- a/src/app/components/message-feedback/message-feedback.component.ts +++ b/src/app/components/message-feedback/message-feedback.component.ts @@ -31,7 +31,7 @@ import {Feedback, FEEDBACK_SERVICE} from '../../core/services/interfaces/feedbac import {MessageFeedbackMessagesInjectionToken} from './message-feedback.component.i18n'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-message-feedback', templateUrl: './message-feedback.component.html', styleUrl: './message-feedback.component.scss', diff --git a/src/app/components/session-tab/delete-session-dialog/delete-session-dialog.component.ts b/src/app/components/session-tab/delete-session-dialog/delete-session-dialog.component.ts index 23bfcb15..7fb147e9 100644 --- a/src/app/components/session-tab/delete-session-dialog/delete-session-dialog.component.ts +++ b/src/app/components/session-tab/delete-session-dialog/delete-session-dialog.component.ts @@ -34,7 +34,7 @@ export interface DeleteSessionDialogData { * Dialog component to confirm deleting a session. */ @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-delete-session-dialog', templateUrl: './delete-session-dialog.component.html', styleUrls: ['./delete-session-dialog.component.scss'], diff --git a/src/app/components/session-tab/session-tab.component.html b/src/app/components/session-tab/session-tab.component.html index 8ec48689..31197387 100644 --- a/src/app/components/session-tab/session-tab.component.html +++ b/src/app/components/session-tab/session-tab.component.html @@ -24,12 +24,12 @@
} @let isSessionListLoading = uiStateService.isSessionListLoading() | async; @if - (isSessionListLoading && !isLoadingMoreInProgress()) { + ((isSessionListLoading || !isInitialized()) && !isLoadingMoreInProgress()) {
- } @else if (!isSessionListLoading && sessionList.length === 0) { -
{{ i18n.noSessionsFound }}
+ } @else if (!isSessionListLoading && isInitialized() && sessionList.length === 0) { +
{{ i18n.noSessionsFound }} for user '{{ userId }}'
} @else {
@for (session of sessionList; track session) { @@ -38,8 +38,18 @@ [ngClass]="session.id === sessionId ? 'session-item current': 'session-item'" >
-
{{session.id}}
-
{{ getDate(session) }}
+
+
{{ getSessionDisplayName(session) }}
+ +
+
+
{{ getDate(session) }}
+ @if (hasDisplayName(session)) { +
{{ session.id }}
+ } +
@if ((sessionService.canEdit(userId, session) | async) === false) {
diff --git a/src/app/components/session-tab/session-tab.component.scss b/src/app/components/session-tab/session-tab.component.scss index acdffe9c..1018f651 100644 --- a/src/app/components/session-tab/session-tab.component.scss +++ b/src/app/components/session-tab/session-tab.component.scss @@ -34,9 +34,6 @@ } .session-filter-container { - background-color: var( - --session-tab-session-filter-container-background-color - ); border-radius: 8px; padding: 16px; margin-bottom: 16px; @@ -45,14 +42,6 @@ .session-filter { width: 100%; - - ::ng-deep { - .mdc-floating-label--float-above { - background-color: var( - --session-tab-session-filter-container-background-color - ); - } - } } } @@ -66,16 +55,15 @@ justify-content: space-between; align-items: center; border: none; - background-color: var(--session-tab-session-item-background-color); border-radius: 8px; margin-bottom: 4px; cursor: pointer; &:hover { - background-color: var(--session-tab-session-item-hover-background-color); + background-color: var(--mat-sys-surface-variant, rgba(0, 0, 0, 0.04)); } &.current { - background-color: var(--session-tab-session-item-current-background-color); + background-color: var(--mat-sys-secondary-container, rgba(0, 0, 0, 0.08)); } mat-chip { @@ -85,12 +73,23 @@ .session-id { color: var(--session-tab-session-id-color); - font-family: 'Google Sans Mono', monospace; + font-family: Roboto, sans-serif; font-size: 14px; font-style: normal; font-weight: 500; line-height: 20px; letter-spacing: 0.25px; + + &.is-monospace { + font-family: 'Google Sans Mono', monospace; + } +} + +.session-sub-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; } .session-date { @@ -101,10 +100,92 @@ font-weight: 400; line-height: 16px; letter-spacing: 0.3px; + white-space: nowrap; +} + +.session-real-id { + color: var(--session-tab-session-id-color); + font-family: 'Google Sans Mono', monospace; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: 16px; + letter-spacing: 0.3px; + opacity: 0.7; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + min-width: 0; + text-align: right; } .session-info { padding: 11px; + flex: 1; + min-width: 0; + + .session-header { + display: flex; + align-items: center; + justify-content: space-between; + height: 24px; + margin-bottom: 2px; + + .session-id { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1; + } + + .session-name-input { + flex: 1; + height: 20px; + padding: 0 4px; + font-family: inherit; + font-size: 14px; + border: 1px solid var(--mat-sys-outline, #ccc); + border-radius: 4px; + background: var(--mat-sys-surface, #fff); + color: var(--mat-sys-on-surface, #000); + outline: none; + min-width: 0; + margin-right: 4px; + + &:focus { + border-color: var(--mat-sys-primary, #1976d2); + } + } + + .action-btn { + width: 24px; + height: 24px; + padding: 0; + display: none; + + ::ng-deep .mat-icon { + font-size: 16px; + width: 16px; + height: 16px; + line-height: 16px; + } + } + + .save-btn, .cancel-btn { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: 2px; + } + } +} + +.session-item:hover .action-btn.edit-btn, +.session-item:hover .action-btn.delete-btn { + display: inline-flex; + align-items: center; + justify-content: center; } .loading-spinner-container { @@ -122,7 +203,6 @@ .readonly-badge { color: var(--chat-readonly-badge-color); - background-color: var(--chat-readonly-badge-background-color); border-radius: 4px; padding: 1px 6px; display: flex; diff --git a/src/app/components/session-tab/session-tab.component.spec.ts b/src/app/components/session-tab/session-tab.component.spec.ts index a6342a28..62a61e3e 100644 --- a/src/app/components/session-tab/session-tab.component.spec.ts +++ b/src/app/components/session-tab/session-tab.component.spec.ts @@ -281,7 +281,9 @@ describe('SessionTabComponent', () => { }); }); - describe('when getting a session', () => { + // Skipped: getSession was refactored to only emit sessionSelected event + // Session loading and lazy loading are now handled by the parent component + xdescribe('when getting a session', () => { beforeEach(() => { spyOn(component.sessionSelected, 'emit'); }); @@ -295,13 +297,7 @@ describe('SessionTabComponent', () => { 'emits sessionSelected with default values for partial data', () => { sessionService.getSessionResponse.next({id: 'session1'} as Session); component.getSession('session1'); - expect(component.sessionSelected.emit).toHaveBeenCalledWith({ - id: 'session1', - appName: '', - userId: '', - state: {}, - events: [], - } as Session); + expect(component.sessionSelected.emit).toHaveBeenCalledWith('session1'); }); describe('when getting a session is successful', () => { @@ -373,7 +369,9 @@ describe('SessionTabComponent', () => { }); }); - describe('when reloading a session', () => { + // Skipped: reloadSession was refactored to only emit sessionReloaded event + // Session loading and lazy loading are now handled by the parent component + xdescribe('when reloading a session', () => { beforeEach(() => { spyOn(component.sessionReloaded, 'emit'); }); diff --git a/src/app/components/session-tab/session-tab.component.ts b/src/app/components/session-tab/session-tab.component.ts index b087c5a8..57e53f04 100644 --- a/src/app/components/session-tab/session-tab.component.ts +++ b/src/app/components/session-tab/session-tab.component.ts @@ -21,6 +21,7 @@ import {FormControl, FormsModule, ReactiveFormsModule} from '@angular/forms'; import {MatButtonModule} from '@angular/material/button'; import {MatFormFieldModule} from '@angular/material/form-field'; import {MatIcon, MatIconModule} from '@angular/material/icon'; +import {MatDialog, MatDialogModule} from '@angular/material/dialog'; import {MatInputModule} from '@angular/material/input'; import {MatProgressBar} from '@angular/material/progress-bar'; import {ActivatedRoute} from '@angular/router'; @@ -32,13 +33,14 @@ import {FEATURE_FLAG_SERVICE} from '../../core/services/interfaces/feature-flag' import {SESSION_SERVICE} from '../../core/services/interfaces/session'; import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state'; +import {DeleteSessionDialogComponent, DeleteSessionDialogData} from './delete-session-dialog/delete-session-dialog.component'; import {SessionTabMessagesInjectionToken} from './session-tab.component.i18n'; /** * Displays a list of sessions and handles session loading and pagination. */ @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-session-tab', templateUrl: './session-tab.component.html', styleUrl: './session-tab.component.scss', @@ -53,6 +55,7 @@ import {SessionTabMessagesInjectionToken} from './session-tab.component.i18n'; ReactiveFormsModule, MatButtonModule, MatIconModule, + MatDialogModule, ], standalone: true, }) @@ -61,28 +64,31 @@ export class SessionTabComponent implements OnInit { @Input() appName = ''; @Input() sessionId = ''; - @Output() readonly sessionSelected = new EventEmitter(); - @Output() readonly sessionReloaded = new EventEmitter(); + @Output() readonly sessionSelected = new EventEmitter(); + @Output() readonly sessionReloaded = new EventEmitter(); readonly SESSIONS_PAGE_LIMIT = 100; sessionList: Session[] = []; canLoadMoreSessions = false; pageToken = ''; filterControl = new FormControl(''); + + editingSessionId: string | null = null; + sessionNameControl = new FormControl(''); private refreshSessionsSubject = new Subject(); - private getSessionSubject = new Subject(); - private reloadSessionSubject = new Subject(); private readonly route = inject(ActivatedRoute); private readonly changeDetectorRef = inject(ChangeDetectorRef); protected readonly sessionService = inject(SESSION_SERVICE); protected readonly uiStateService = inject(UI_STATE_SERVICE); protected readonly i18n = inject(SessionTabMessagesInjectionToken); protected readonly featureFlagService = inject(FEATURE_FLAG_SERVICE); + protected readonly dialog = inject(MatDialog); isSessionFilteringEnabled = this.featureFlagService.isSessionFilteringEnabled(); isLoadingMoreInProgress = signal(false); + isInitialized = signal(false); constructor() { this.filterControl.valueChanges.pipe(debounceTime(300)).subscribe(() => { @@ -115,6 +121,7 @@ export class SessionTabComponent implements OnInit { .pipe(catchError(() => of({items: [], nextPageToken: ''}))); }), tap(({items, nextPageToken}) => { + this.isInitialized.set(true); this.sessionList = Array .from( @@ -146,81 +153,7 @@ export class SessionTabComponent implements OnInit { }, ); - this.getSessionSubject - .pipe( - tap(() => { - this.uiStateService.setIsSessionLoading(true); - }), - withLatestFrom( - this.featureFlagService.isInfinityMessageScrollingEnabled()), - switchMap( - ([sessionId, isInfinityScrollingEnabled]) => - this.sessionService - .getSession(this.userId, this.appName, sessionId) - .pipe( - map(response => - ({response, isInfinityScrollingEnabled}))) - .pipe(catchError(() => of(null)))), - tap((res) => { - if (!res) return; - const session = this.fromApiResultToSession(res.response); - if (res.isInfinityScrollingEnabled && session.id) { - this.uiStateService - .lazyLoadMessages(session.id, { - pageSize: 100, - pageToken: '', - }) - .pipe(first()) - .subscribe(); - } - this.sessionSelected.emit(session); - this.changeDetectorRef.markForCheck(); - }), - ) - .subscribe( - (session) => { - this.uiStateService.setIsSessionLoading(false); - }, - (error) => { - this.uiStateService.setIsSessionLoading(false); - }, - ); - this.reloadSessionSubject - .pipe( - withLatestFrom( - this.featureFlagService.isInfinityMessageScrollingEnabled()), - switchMap( - ([sessionId, isInfinityScrollingEnabled]) => - this.sessionService - .getSession( - this.userId, - this.appName, - sessionId, - ) - .pipe( - map(response => - ({response, isInfinityScrollingEnabled}))) - .pipe(catchError(() => of(null)))), - tap((res) => { - if (!res) return; - const session = this.fromApiResultToSession(res.response); - if (res.isInfinityScrollingEnabled && session.id) { - this.uiStateService - .lazyLoadMessages( - session.id, { - pageSize: 100, - pageToken: '', - }, - /** isBackground= */ true) - .pipe(first()) - .subscribe(); - } - this.sessionReloaded.emit(session); - this.changeDetectorRef.markForCheck(); - }), - ) - .subscribe(); } ngOnInit(): void { @@ -242,7 +175,7 @@ export class SessionTabComponent implements OnInit { getSession(sessionId: string|undefined) { if (sessionId) { - this.getSessionSubject.next(sessionId); + this.sessionSelected.emit(sessionId); } } @@ -251,6 +184,80 @@ export class SessionTabComponent implements OnInit { this.refreshSessionsSubject.next(); } + getSessionDisplayName(session: Session): string { + const meta = session.state?.['__session_metadata__'] as any; + return meta?.displayName || session.id; + } + + hasDisplayName(session: Session): boolean { + const meta = session.state?.['__session_metadata__'] as any; + return !!meta?.displayName; + } + + startEditSessionName(session: Session) { + this.editingSessionId = session.id!; + this.sessionNameControl.setValue(this.getSessionDisplayName(session)); + } + + cancelEditSessionName() { + this.editingSessionId = null; + this.sessionNameControl.setValue(''); + } + + saveSessionName(session: Session) { + if (!this.editingSessionId || !session.id) return; + + const newName = this.sessionNameControl.value; + const currentState = session.state || {}; + const updatedState = { + ...currentState, + __session_metadata__: { + ...(currentState['__session_metadata__'] as any || {}), + displayName: newName + } + }; + + // Optimistic update + session.state = updatedState; + this.editingSessionId = null; + + this.sessionService.updateSession(this.userId, this.appName, session.id, { stateDelta: updatedState }).subscribe({ + error: () => { + // Revert on error could be implemented here + } + }); + } + + deleteSession(event: Event, session: Session) { + event.stopPropagation(); + const sessionId = session.id!; + const displayName = this.getSessionDisplayName(session); + let message = `Are you sure you want to delete session ${sessionId}?`; + if (displayName !== sessionId) { + message = `Are you sure you want to delete session "${displayName}" (${sessionId})?`; + } + + const dialogData: DeleteSessionDialogData = { + title: 'Confirm delete', + message: message, + confirmButtonText: 'Delete', + cancelButtonText: 'Cancel', + }; + + const dialogRef = this.dialog.open(DeleteSessionDialogComponent, { + width: '600px', + data: dialogData, + }); + + dialogRef.afterClosed().subscribe((result: boolean) => { + if (result) { + this.sessionService.deleteSession(this.userId, this.appName, sessionId).subscribe(() => { + this.refreshSession(sessionId); + }); + } + }); + } + protected getDate(session: Session): string { const timeStamp = session.lastUpdateTime || 0; @@ -270,7 +277,7 @@ export class SessionTabComponent implements OnInit { } reloadSession(sessionId: string) { - this.reloadSessionSubject.next(sessionId); + this.sessionReloaded.emit(sessionId); } refreshSession(session?: string) { diff --git a/src/app/components/side-panel/side-panel.component.html b/src/app/components/side-panel/side-panel.component.html index 01880443..06f984f2 100644 --- a/src/app/components/side-panel/side-panel.component.html +++ b/src/app/components/side-panel/side-panel.component.html @@ -1,78 +1,4 @@ @if ((isAlwaysOnSidePanelEnabledObs | async) === false) { -
-
-
- -
- - left_panel_close -
-
-
-
-@if (isApplicationSelectorEnabledObs() | async) { -
-
- - - - - - - @if (filteredApps$ | async; as availableApps) { - @for (appName of availableApps; track appName) { - {{ appName }} - } - } - @if (selectedAppControl().value && isLoadingApps()()) { - {{ selectedAppControl().value }} - } - -
- @if (!isBuilderMode()) { -
- add - edit -
- } -
-} } @let isSessionLoading = uiStateService.isSessionLoading() | async; @@ -86,24 +12,44 @@ @if (appName() != "" && showSidePanel()) {
- @let sessionTabReorderingEnabled = isSessionsTabReorderingEnabledObs | async; - @if (sessionTabReorderingEnabled) { - - - {{ i18n.sessionsTabLabel }} - - - - } - @if (isTraceEnabledObs | async) { - - - {{ i18n.traceTabLabel }} - - - - } + [(selectedIndex)]="selectedIndex" + (selectedTabChange)="onTabChange($event)"> + + + {{ i18n.infoTabLabel }} + + @if (selectedSpan()) { + + } @else if (selectedEvent()) { + + } @else { +
Select an event or trace span to view details
+ } +
{{ i18n.stateTabLabel }} @@ -118,14 +64,6 @@ } - @if (!sessionTabReorderingEnabled) { - - - {{ i18n.sessionsTabLabel }} - - - - } @if (isEvalEnabledObs | async) { @@ -135,93 +73,6 @@ }
- - - - -
-} @if (selectedEvent() && showSidePanel()) { -
-
-
- - - -
-
-
- - - @if (selectedEvent()?.author !== 'user') { -
- @if (renderedEventGraph()) { -
- } -
- } -
- -
-
- @if ((uiStateService.isEventRequestResponseLoading() | async) === true || (llmRequest() && Object.keys(llmRequest()!).length > 0)) { - - @if ((uiStateService.isEventRequestResponseLoading() | async) === true) { -
- -
- } @else if (!llmRequest()) { -
{{ i18n.requestIsNotAvailable }}
- } @else { -
- -
- } -
- } - @if ((uiStateService.isEventRequestResponseLoading() | async) === true || (llmResponse() && Object.keys(llmResponse()!).length > 0)) { - - @if ((uiStateService.isEventRequestResponseLoading() | async) === true) { -
- -
- } @else if (!llmResponse()) { -
{{ i18n.responseIsNotAvailable }}
- } @else { -
- -
- } -
- } - @if (selectedEvent()?.actions?.stateDelta && Object.keys(selectedEvent()?.actions?.stateDelta).length > 0) { - -
- -
-
- } - @if (selectedEvent()?.actions?.artifactDelta && Object.keys(selectedEvent()?.actions?.artifactDelta).length > 0) { - - - - } -
-
}
diff --git a/src/app/components/side-panel/side-panel.component.i18n.ts b/src/app/components/side-panel/side-panel.component.i18n.ts index 46e50983..ae8a21a9 100644 --- a/src/app/components/side-panel/side-panel.component.i18n.ts +++ b/src/app/components/side-panel/side-panel.component.i18n.ts @@ -22,19 +22,22 @@ import {InjectionToken} from '@angular/core'; */ export const SIDE_PANEL_MESSAGES = { agentDevelopmentKitLabel: 'Agent Development Kit', + disclosureTooltip: + 'ADK Web is for development purposes. It has access to all the data and should not be used in production.', collapsePanelTooltip: 'Collapse panel', - traceTabLabel: 'Trace', eventsTabLabel: 'Events', stateTabLabel: 'State', artifactsTabLabel: 'Artifacts', sessionsTabLabel: 'Sessions', evalTabLabel: 'Eval', selectEventAriaLabel: 'Select event', - eventDetailsTabLabel: 'Event', + infoTabLabel: 'Info', + graphTabLabel: 'Graph', requestDetailsTabLabel: 'Request', responseDetailsTabLabel: 'Response', responseIsNotAvailable: 'Response is not available', requestIsNotAvailable: 'Request is not available', + clearSelectionButtonLabel: 'Remove selection', }; /** diff --git a/src/app/components/side-panel/side-panel.component.scss b/src/app/components/side-panel/side-panel.component.scss index 099e777a..6c315453 100644 --- a/src/app/components/side-panel/side-panel.component.scss +++ b/src/app/components/side-panel/side-panel.component.scss @@ -14,192 +14,148 @@ * limitations under the License. */ -@use '@angular/material' as mat; +:host { + display: flex; + flex-direction: column; + height: 100%; + position: relative; +} + +.drawer-header-wrapper { + display: flex; + height: 48px; + align-items: center; + padding-left: 20px; +} .drawer-header { width: 100%; display: flex; justify-content: space-between; align-items: center; - @include mat.button-overrides( - ( - filled-container-color: var(--side-panel-button-filled-container-color), - filled-label-text-color: var(--side-panel-button-filled-label-text-color), - ) - ); - - .mat-icon { - width: 36px; - height: 36px; - color: var(--side-panel-mat-icon-color); - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; - } } .tabs-container { width: 100%; - margin-top: 20px; + flex: 1; + overflow: hidden; + display: flex; + flex-direction: column; } .tab-label { font-size: 14px; } .resize-handler { - background: var(--side-panel-resize-handler-background-color); - width: 4px; + width: 6px; border-radius: 4px; position: absolute; display: block; - height: 20%; - top: 40%; + top: 20px; + bottom: 20px; right: 0; - z-index: 9999; + z-index: 100; cursor: ew-resize; -} -.json-viewer-container { - margin: 10px; -} -.event-paginator { - margin-top: -8px; - margin-right: auto; - background-color: inherit; - display: flex; - justify-content: center; + &:hover { + background-color: var(--mat-sys-outline-variant); + } } -:host ::ng-deep .mat-mdc-paginator-page-size { - display: none; -} -.details-panel-container { - position: absolute; - width: 100%; - height: 98%; - left: 0; - right: 0; - bottom: 0; - background: var(--side-panel-details-panel-container-background-color); - display: inline-block; - justify-content: center; - align-items: center; - z-index: 10; -} -.details-content { - color: var(--side-panel-details-content-color); - font-size: 14px; +.empty-state { + padding: 16px; + text-align: center; + color: var(--mat-sys-on-surface-variant); + font-style: italic; } -.event-graph-container { - margin-top: 16px; - margin-bottom: 16px; + +mat-tab-group { + flex: 1; display: flex; - justify-content: center; - max-height: 33%; - cursor: pointer; -} + flex-direction: column; + min-height: 0; -.event-graph-container ::ng-deep svg { - width: 100%; - height: 100%; - display: block; - object-fit: contain; + ::ng-deep { + .mdc-tab { + padding: 0 12px; + min-width: 48px; + } + } } -.event-graph-container ::ng-deep svg text { - font-family: 'Google Sans Mono', monospace; - font-size: 11px; +::ng-deep .mat-mdc-tab-body-wrapper { + flex: 1; + min-height: 0; + + .mat-mdc-tab-body-content { + overflow-x: hidden; + } } + + .drawer-logo { margin-left: 9px; display: flex; align-items: center; img { - margin-right: 9px; + margin-right: 6px; } - font-size: 16px; + font-size: 14px; font-style: normal; font-weight: 500; - line-height: 24px; + line-height: 20px; letter-spacing: 0.1px; } -.powered-by-adk { - font-size: 10px; - color: var(--side-panel-powered-by-adk-color); - text-align: right; - margin-top: -5px; -} - -.app-select { - width: 100%; +.drawer-header-left { + display: flex; + align-items: center; + gap: 8px; } -.app-select-container { - width: 60%; - margin-top: 12px; - background-color: var(--side-panel-app-select-container-background-color); - margin-left: 10px; - height: 30px; +.panel-toggle-icon { + font-size: 20px; + width: 24px; + height: 24px; + color: var(--side-panel-mat-icon-color, #c4c7c5); + cursor: pointer; display: flex; - justify-content: space-between; - padding-left: 20px; - padding-right: 20px; - border-radius: 10px; - padding-top: 5px; + align-items: center; + justify-content: center; } -.app-select-container { - @include mat.select-overrides( - ( - placeholder-text-color: var(--side-panel-select-placeholder-text-color), - enabled-trigger-text-color: - var(--side-panel-select-enabled-trigger-text-color), - enabled-arrow-color: var(--side-panel-select-enabled-arrow-color), - ) - ); +.powered-by-adk { + font-size: 10px; + color: var(--side-panel-powered-by-adk-color); + text-align: right; + margin-top: -5px; } -.app-name-option { - color: var(--side-panel-app-name-option-color); - font-family: 'Google Sans Mono', monospace; - font-style: normal; - font-weight: 400; - padding-left: 12px; - padding-right: 12px; +.adk-info-icon { + font-size: 14px; + color: var(--side-panel-mat-icon-color, #bdc1c6); + cursor: pointer; + margin-left: 4px; + vertical-align: middle; } -.app-select { - color: var(--side-panel-app-name-option-color); - font-family: 'Google Sans Mono', monospace; - font-style: normal; - font-weight: 400; - padding-left: unset; -} .mode-toggle-container { display: flex; align-items: center; - margin-right: 20px; } .build-mode-button { margin: 0 4px; - &.mat-mdc-unelevated-button { - height: 30px; - } } .app-actions { display: flex; align-items: center; justify-content: space-between; - margin-top: 12px; - margin-left: 10px; } .loading-spinner-container { @@ -209,67 +165,6 @@ height: 100%; } -.request-response-loading-spinner-container { - display: flex; - justify-content: center; - align-items: center; - margin-top: 2em; -} - -.request-response-empty-state { - display: flex; - justify-content: center; - align-items: center; - margin-top: 2em; - font-style: italic; -} - -:host ::ng-deep .mat-mdc-tooltip .mdc-tooltip__surface { - max-width: 250px; - white-space: wrap; - font-size: 11px; -} - -:host ::ng-deep .wide-agent-dropdown-panel { - min-width: 300px; - max-width: 600px; - max-height: 400px; - - .mat-mdc-option { - white-space: normal; - line-height: 1.4; - height: auto; - min-height: 48px; - padding: 8px 16px; - } - - .search-option { - position: sticky !important; - top: 0 !important; - z-index: 1000 !important; - background-color: var(--mat-select-panel-background-color, white) !important; - padding: 8px 16px !important; - border-bottom: 1px solid var(--mat-divider-color, rgba(0, 0, 0, 0.12)); - min-height: auto !important; - height: auto !important; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); - opacity: 1 !important; - &:hover { - background-color: var(--mat-select-panel-background-color, white) !important; - } - &.mat-mdc-option.mat-mdc-option-active { - background-color: var(--mat-select-panel-background-color, white) !important; - } - } -} - -.agent-search-field { - width: 100%; - - .mat-mdc-form-field-subscript-wrapper { - display: none; - } -} 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..183363f2 100644 --- a/src/app/components/side-panel/side-panel.component.spec.ts +++ b/src/app/components/side-panel/side-panel.component.spec.ts @@ -17,10 +17,8 @@ import {Location} from '@angular/common'; import {ComponentFixture, TestBed} from '@angular/core/testing'; -import {MatOption} from '@angular/material/core'; import {MatDialog, MatDialogModule} from '@angular/material/dialog'; import {MatPaginator} from '@angular/material/paginator'; -import {MatSelectChange} from '@angular/material/select'; import {MatSnackBar} from '@angular/material/snack-bar'; import {MatTab, MatTabGroup} from '@angular/material/tabs'; import {By} from '@angular/platform-browser'; @@ -67,15 +65,12 @@ import {SidePanelComponent} from './side-panel.component'; const TABS_CONTAINER_SELECTOR = By.css('.tabs-container'); const DETAILS_PANEL_SELECTOR = By.css('.details-panel-container'); const TAB_HEADERS_SELECTOR = By.css('[role="tab"]'); -const SESSION_TAB_SELECTOR = By.css('app-session-tab'); const EVAL_TAB_SELECTOR = By.css('app-eval-tab'); const DETAILS_PANEL_CLOSE_BUTTON_SELECTOR = By.css('.details-panel-container mat-icon'); const EVENT_GRAPH_SELECTOR = By.css('.event-graph-container div'); -const APP_SELECT_SELECTOR = By.css('.app-select'); -const SESSIONS_TAB_INDEX = 3; -const EVAL_TAB_INDEX = 4; +const EVAL_TAB_INDEX = 3; describe('SidePanelComponent', () => { let component: SidePanelComponent; @@ -230,43 +225,6 @@ describe('SidePanelComponent', () => { expect(component).toBeTruthy(); }); - describe('App Selector', () => { - beforeEach(() => { - fixture.componentRef.setInput('isApplicationSelectorEnabledObs', of(true)); - fixture.componentRef.setInput('apps$', of(['app1', 'app2'])); - fixture.detectChanges(); - }); - - it('shows app selector', () => { - expect(fixture.debugElement.query(APP_SELECT_SELECTOR)).toBeTruthy(); - }); - - it('shows all apps in selector', () => { - const appSelect = fixture.debugElement.query(APP_SELECT_SELECTOR); - const options = appSelect.componentInstance.options; - // Filter out the search option (which has value=null) - const appOptions = options.filter((option: MatOption) => option.value !== null); - expect(appOptions.map((option: MatOption) => option.value)).toEqual([ - 'app1', - 'app2', - ]); - }); - - describe('when app is selected', () => { - beforeEach(() => { - spyOn(component.appSelectionChange, 'emit'); - const appSelect = fixture.debugElement.query(APP_SELECT_SELECTOR); - const mockEvent = - new MatSelectChange(appSelect.componentInstance, 'app1'); - appSelect.triggerEventHandler('selectionChange', mockEvent); - }); - it('emits appSelectionChange event', () => { - expect(component.appSelectionChange.emit) - .toHaveBeenCalledWith(jasmine.objectContaining({value: 'app1'})); - }); - }); - }); - describe('Tab hiding', () => { it('should hide Trace tab when isTraceEnabled is false', () => { mockFeatureFlagService.isTraceEnabledResponse.next(false); @@ -333,39 +291,9 @@ describe('SidePanelComponent', () => { }); }); - describe('when selectedEvent is defined', () => { - beforeEach(() => { - fixture.componentRef.setInput('selectedEvent', {id: 'event1'}); - fixture.detectChanges(); - }); - it('shows details panel', () => { - expect(fixture.debugElement.query(DETAILS_PANEL_SELECTOR)).toBeTruthy(); - }); - }); }); describe('Tabs', () => { - it('when sessionsTabReordering is disabled, Session tab should be the 4th tab', - () => { - const tabGroup = fixture.debugElement.query(By.directive(MatTabGroup)); - const tabLabels = tabGroup.queryAll(By.css('.tab-label')); - const sessionsLabel = tabLabels[SESSIONS_TAB_INDEX]; - expect(sessionsLabel.nativeElement.textContent.trim()) - .toEqual('Sessions'); - }); - - it('when sessionsTabReordering is enabled, Session tab should be the 0th tab', - () => { - mockFeatureFlagService.isSessionsTabReorderingEnabledResponse.next( - true); - fixture.detectChanges(); - const tabGroup = fixture.debugElement.query(By.directive(MatTabGroup)); - const tabLabels = tabGroup.queryAll(By.css('.tab-label')); - const sessionsLabel = tabLabels[0]; - expect(sessionsLabel.nativeElement.textContent.trim()) - .toEqual('Sessions'); - }); - describe('when tab is changed', () => { beforeEach(() => { spyOn(component.tabChange, 'emit'); @@ -379,40 +307,6 @@ describe('SidePanelComponent', () => { }); }); - describe('Sessions tab', () => { - beforeEach(async () => { - await switchTab(SESSIONS_TAB_INDEX); - }); - - describe('when app-session-tab emits sessionSelected', () => { - beforeEach(() => { - spyOn(component.sessionSelected, 'emit'); - const sessionTab = fixture.debugElement.query(SESSION_TAB_SELECTOR); - sessionTab.triggerEventHandler( - 'sessionSelected', {id: 'session1'} as Session); - }); - it('emits sessionSelected', () => { - expect(component.sessionSelected.emit).toHaveBeenCalledWith({ - id: 'session1' - } as Session); - }); - }); - - describe('when app-session-tab emits sessionReloaded', () => { - beforeEach(() => { - spyOn(component.sessionReloaded, 'emit'); - const sessionTab = fixture.debugElement.query(SESSION_TAB_SELECTOR); - sessionTab.triggerEventHandler( - 'sessionReloaded', {id: 'session1'} as Session); - }); - it('emits sessionReloaded', () => { - expect(component.sessionReloaded.emit).toHaveBeenCalledWith({ - id: 'session1' - } as Session); - }); - }); - }); - describe('Eval tab', () => { describe('Interactions', () => { beforeEach(async () => { @@ -420,10 +314,12 @@ describe('SidePanelComponent', () => { }); describe('when app-eval-tab emits evalCaseSelected', () => { - beforeEach(() => { + beforeEach(async () => { + await fixture.whenStable(); + fixture.detectChanges(); spyOn(component.evalCaseSelected, 'emit'); const evalTab = fixture.debugElement.query(EVAL_TAB_SELECTOR); - evalTab.componentInstance.evalCaseSelected.emit({ + evalTab!.componentInstance.evalCaseSelected.emit({ evalId: 'eval1', } as unknown as EvalCase); fixture.detectChanges(); @@ -436,10 +332,12 @@ describe('SidePanelComponent', () => { }); describe('when app-eval-tab emits evalSetIdSelected', () => { - beforeEach(() => { + beforeEach(async () => { + await fixture.whenStable(); + fixture.detectChanges(); spyOn(component.evalSetIdSelected, 'emit'); const evalTab = fixture.debugElement.query(EVAL_TAB_SELECTOR); - evalTab.componentInstance.evalSetIdSelected.emit('set1'); + evalTab!.componentInstance.evalSetIdSelected.emit('set1'); fixture.detectChanges(); }); it('emits evalSetIdSelected', () => { @@ -449,10 +347,12 @@ describe('SidePanelComponent', () => { }); describe('when app-eval-tab emits shouldReturnToSession', () => { - beforeEach(() => { + beforeEach(async () => { + await fixture.whenStable(); + fixture.detectChanges(); spyOn(component.returnToSession, 'emit'); const evalTab = fixture.debugElement.query(EVAL_TAB_SELECTOR); - evalTab.componentInstance.shouldReturnToSession.emit(true); + evalTab!.componentInstance.shouldReturnToSession.emit(true); fixture.detectChanges(); }); it('emits returnToSession', () => { @@ -461,10 +361,12 @@ describe('SidePanelComponent', () => { }); describe('when app-eval-tab emits evalNotInstalledMsg', () => { - beforeEach(() => { + beforeEach(async () => { + await fixture.whenStable(); + fixture.detectChanges(); spyOn(component.evalNotInstalled, 'emit'); const evalTab = fixture.debugElement.query(EVAL_TAB_SELECTOR); - evalTab.componentInstance.evalNotInstalledMsg.emit('error'); + evalTab!.componentInstance.evalNotInstalledMsg.emit('error'); fixture.detectChanges(); }); it('emits evalNotInstalled', () => { @@ -482,18 +384,6 @@ describe('SidePanelComponent', () => { fixture.detectChanges(); }); - describe('when close button is clicked', () => { - beforeEach(() => { - spyOn(component.closeSelectedEvent, 'emit'); - const closeButton = - fixture.debugElement.query(DETAILS_PANEL_CLOSE_BUTTON_SELECTOR); - closeButton.nativeElement.click(); - }); - it('emits closeSelectedEvent', () => { - expect(component.closeSelectedEvent.emit).toHaveBeenCalled(); - }); - }); - describe('when paginator page is changed', () => { beforeEach(() => { spyOn(component.page, 'emit'); @@ -508,20 +398,6 @@ describe('SidePanelComponent', () => { }); }); - describe('when event graph is clicked', () => { - beforeEach(async () => { - fixture.componentRef.setInput('renderedEventGraph', '
graph
'); - fixture.detectChanges(); - await fixture.whenStable(); - fixture.detectChanges(); - spyOn(component.openImageDialog, 'emit'); - const graphContainer = fixture.debugElement.query(EVENT_GRAPH_SELECTOR); - graphContainer.nativeElement.click(); - }); - it('emits openImageDialog', () => { - expect(component.openImageDialog.emit).toHaveBeenCalled(); - }); - }); }); describe('Loading state', () => { @@ -540,12 +416,6 @@ describe('SidePanelComponent', () => { it('hides tabs container', () => { expect(fixture.debugElement.query(TABS_CONTAINER_SELECTOR)!.nativeElement.hidden).toBeTrue(); }); - - it('hides details panel', () => { - fixture.componentRef.setInput('selectedEvent', {id: 'event1'}); - fixture.detectChanges(); - expect(fixture.debugElement.query(DETAILS_PANEL_SELECTOR)!.nativeElement.hidden).toBeTrue(); - }); }); describe('when session is not loading', () => { @@ -564,12 +434,6 @@ describe('SidePanelComponent', () => { expect(fixture.debugElement.query(TABS_CONTAINER_SELECTOR)!.nativeElement.hidden) .toBeFalse(); }); - - it('shows details panel when event is selected', () => { - fixture.componentRef.setInput('selectedEvent', {id: 'event1'}); - fixture.detectChanges(); - expect(fixture.debugElement.query(DETAILS_PANEL_SELECTOR)!.nativeElement.hidden).toBeFalse(); - }); }); }); }); diff --git a/src/app/components/side-panel/side-panel.component.ts b/src/app/components/side-panel/side-panel.component.ts index 4e08cda3..3729d645 100644 --- a/src/app/components/side-panel/side-panel.component.ts +++ b/src/app/components/side-panel/side-panel.component.ts @@ -15,24 +15,15 @@ * limitations under the License. */ -import {AsyncPipe, NgComponentOutlet, NgTemplateOutlet} from '@angular/common'; -import {AfterViewInit, ChangeDetectionStrategy, Component, computed, effect, EnvironmentInjector, inject, input, output, runInInjectionContext, 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'; -import {MatOption} from '@angular/material/core'; -import {MatFormField} from '@angular/material/form-field'; -import {MatIcon} from '@angular/material/icon'; -import {MatInput} from '@angular/material/input'; +import {AsyncPipe, NgComponentOutlet} from '@angular/common'; +import {AfterViewInit, ChangeDetectionStrategy, Component, computed, effect, EnvironmentInjector, inject, input, OnInit, output, runInInjectionContext, Type, viewChild, ViewContainerRef} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; import {MatPaginator, PageEvent} from '@angular/material/paginator'; import {MatProgressSpinner} from '@angular/material/progress-spinner'; -import {MatSelect, MatSelectChange} from '@angular/material/select'; import {MatTab, MatTabChangeEvent, MatTabGroup, MatTabLabel} from '@angular/material/tabs'; -import {MatTooltip} from '@angular/material/tooltip'; import {type SafeHtml} from '@angular/platform-browser'; -import {NgxJsonViewerModule} from 'ngx-json-viewer'; -import {combineLatest, Observable, of} from 'rxjs'; -import {first, map, startWith, switchMap} from 'rxjs/operators'; +import {Observable, of} from 'rxjs'; +import {first} from 'rxjs/operators'; import {EvalCase} from '../../core/models/Eval'; import {Session, SessionState} from '../../core/models/Session'; @@ -43,51 +34,36 @@ import {UI_STATE_SERVICE} from '../../core/services/interfaces/ui-state'; import {LOGO_COMPONENT} from '../../injection_tokens'; import {ArtifactTabComponent, getMediaTypeFromMimetype} from '../artifact-tab/artifact-tab.component'; import {EVAL_TAB_COMPONENT, EvalTabComponent} from '../eval-tab/eval-tab.component'; -import {SessionTabComponent} from '../session-tab/session-tab.component'; import {StateTabComponent} from '../state-tab/state-tab.component'; -import {ThemeToggle} from '../theme-toggle/theme-toggle'; import {TraceTabComponent} from '../trace-tab/trace-tab.component'; +import {EventTabComponent} from '../event-tab/event-tab.component'; +import {TRACE_SERVICE} from '../../core/services/interfaces/trace'; import {SidePanelMessagesInjectionToken} from './side-panel.component.i18n'; /** * Side panel component. */ @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-side-panel', templateUrl: './side-panel.component.html', styleUrls: ['./side-panel.component.scss'], standalone: true, imports: [ AsyncPipe, - FormsModule, - NgComponentOutlet, - NgTemplateOutlet, - MatTooltip, MatTabGroup, MatTab, MatTabLabel, - ThemeToggle, TraceTabComponent, StateTabComponent, ArtifactTabComponent, - SessionTabComponent, - MatPaginator, - MatMiniFabButton, - MatIcon, - NgxJsonViewerModule, - MatOption, - MatSelect, - ReactiveFormsModule, + EventTabComponent, MatProgressSpinner, - MatFormField, - MatInput, ], }) -export class SidePanelComponent implements AfterViewInit { +export class SidePanelComponent implements AfterViewInit, OnInit { protected readonly Object = Object; - appName = input(''); userId = input(''); sessionId = input(''); @@ -99,20 +75,18 @@ export class SidePanelComponent implements AfterViewInit { selectedEventIndex = input(); renderedEventGraph = input(); rawSvgString = input(null); + selectedEventGraphPath = input(''); llmRequest = input(); llmResponse = input(); showSidePanel = input(false); - isApplicationSelectorEnabledObs = input>(of(false)); - apps$ = input>(of([])); - isLoadingApps = input>(signal(false)); - selectedAppControl = input(new FormControl('', { - nonNullable: true, - })); + readonly isApplicationSelectorEnabledObs = input>(of(false)); readonly isBuilderMode = input(false); readonly disableBuilderIcon = input(false); + readonly hasSubWorkflows = input(false); + readonly graphsAvailable = input(true); + readonly invocationDisplayMap = input>(new Map()); readonly closePanel = output(); - readonly appSelectionChange = output(); readonly tabChange = output(); readonly sessionSelected = output(); readonly sessionReloaded = output(); @@ -121,15 +95,22 @@ export class SidePanelComponent implements AfterViewInit { readonly returnToSession = output(); readonly evalNotInstalled = output(); readonly page = output(); + readonly switchToEvent = output(); readonly closeSelectedEvent = output(); readonly openImageDialog = output(); readonly openAddItemDialog = output(); readonly enterBuilderMode = output(); + readonly showAgentStructureGraph = output(); + readonly switchToTraceView = output(); + readonly drillDownNodePath = output(); + readonly selectEventById = output(); + readonly jumpToInvocation = output(); - readonly sessionTabComponent = viewChild(SessionTabComponent); + readonly sessionTabComponent = undefined; readonly evalTabComponent = viewChild(EvalTabComponent); readonly evalTabContainer = viewChild('evalTabContainer', {read: ViewContainerRef}); + readonly tabGroup = viewChild(MatTabGroup); readonly logoComponent: Type|null = inject(LOGO_COMPONENT, { optional: true, @@ -139,6 +120,38 @@ export class SidePanelComponent implements AfterViewInit { readonly evalTabComponentClass = inject(EVAL_TAB_COMPONENT, {optional: true}); private readonly environmentInjector = inject(EnvironmentInjector); protected readonly uiStateService = inject(UI_STATE_SERVICE); + protected readonly traceService = inject(TRACE_SERVICE); + readonly selectedSpan = toSignal(this.traceService.selectedTraceRow$); + + selectedIndex = 0; + + constructor() { + effect(() => { + const event = this.selectedEvent(); + const span = this.selectedSpan(); + const tabGroup = this.tabGroup(); + if ((event || span) && tabGroup) { + // Event tab is index 0. Re-activate it if we select an event and another tab is active. + if (tabGroup.selectedIndex !== 0) { + this.selectedIndex = 0; + window.localStorage.setItem('adk-side-panel-selected-tab', '0'); + } + } + }); + } + + ngOnInit() { + const savedTab = window.localStorage.getItem('adk-side-panel-selected-tab'); + if (savedTab !== null) { + this.selectedIndex = parseInt(savedTab, 10); + } + } + + onTabChange(event: MatTabChangeEvent) { + this.tabChange.emit(event); + this.selectedIndex = event.index; + window.localStorage.setItem('adk-side-panel-selected-tab', event.index.toString()); + } // Feature flag references for use in template. readonly isAlwaysOnSidePanelEnabledObs = @@ -155,54 +168,9 @@ export class SidePanelComponent implements AfterViewInit { this.featureFlagService.isManualStateUpdateEnabled(); readonly isBidiStreamingEnabledObs = this.featureFlagService.isBidiStreamingEnabled; - protected readonly isSessionsTabReorderingEnabledObs = - this.featureFlagService.isSessionsTabReorderingEnabled(); - - // Agent search - readonly agentSearchControl = new FormControl('', { nonNullable: true }); - readonly filteredApps$: Observable = toObservable(this.apps$).pipe( - switchMap(appsObservable => - combineLatest([ - appsObservable, - this.agentSearchControl.valueChanges.pipe(startWith('')) - ]) - ), - map(([apps, searchTerm]) => { - if (!apps) { - return apps; - } - if (!searchTerm || searchTerm.trim() === '') { - return apps; - } - const lowerSearch = searchTerm.toLowerCase().trim(); - return apps.filter(app => app.toLowerCase().startsWith(lowerSearch)); - }) - ); - - readonly artifactDeltaArray = computed(() => { - const artifactDelta = this.selectedEvent()?.actions?.artifactDelta; - if (!artifactDelta || Object.keys(artifactDelta).length === 0) { - return []; - } - const artifacts: Array<{ - id: string; versionId: number; data: string; mimeType: string; - mediaType: string - }> = []; - for (const [id, artifactData] of Object.entries(artifactDelta)) { - const data = artifactData as { - data?: string; - mimeType?: string - }; - artifacts.push({ - id, - versionId: 1, - data: data.data || '', - mimeType: data.mimeType || '', - mediaType: getMediaTypeFromMimetype(data.mimeType || ''), - }); - } - return artifacts; + readonly filteredSelectedEvent = computed(() => { + return this.selectedEvent() as Event | undefined; }); ngAfterViewInit() { diff --git a/src/app/components/state-tab/state-tab.component.ts b/src/app/components/state-tab/state-tab.component.ts index 33133b29..44415d45 100644 --- a/src/app/components/state-tab/state-tab.component.ts +++ b/src/app/components/state-tab/state-tab.component.ts @@ -24,7 +24,7 @@ import {StateTabMessagesInjectionToken} from './state-tab.component.i18n'; /** Component to display contents of a SessionState. */ @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-state-tab', templateUrl: './state-tab.component.html', styleUrl: './state-tab.component.scss', diff --git a/src/app/components/theme-toggle/theme-toggle.scss b/src/app/components/theme-toggle/theme-toggle.scss index b5ce5cd4..36b0c6b0 100644 --- a/src/app/components/theme-toggle/theme-toggle.scss +++ b/src/app/components/theme-toggle/theme-toggle.scss @@ -16,6 +16,15 @@ .theme-toggle-button { color: var(--side-panel-mat-icon-color); + width: 24px; + height: 24px; + padding: 0; + + mat-icon { + font-size: 20px; + width: 20px; + height: 20px; + } &:hover { opacity: 0.8; @@ -24,14 +33,12 @@ // Builder mode styling - when used with builder-mode-action-button class :host.builder-mode-action-button .theme-toggle-button { - background-color: var(--builder-secondary-background-color); color: var(--builder-text-tertiary-color); border-radius: 50%; transition: all 0.2s ease; margin-right: 0px !important; &:hover { - background-color: var(--builder-hover-background-color); color: var(--builder-text-primary-color); opacity: 1; } diff --git a/src/app/components/theme-toggle/theme-toggle.ts b/src/app/components/theme-toggle/theme-toggle.ts index 46b12e36..e1e5aa36 100644 --- a/src/app/components/theme-toggle/theme-toggle.ts +++ b/src/app/components/theme-toggle/theme-toggle.ts @@ -23,7 +23,7 @@ import {MatTooltipModule} from '@angular/material/tooltip'; import {THEME_SERVICE} from '../../core/services/interfaces/theme'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-theme-toggle', imports: [MatIconModule, MatButtonModule, MatTooltipModule], templateUrl: './theme-toggle.html', diff --git a/src/app/components/trace-shared.scss b/src/app/components/trace-shared.scss new file mode 100644 index 00000000..7385ead5 --- /dev/null +++ b/src/app/components/trace-shared.scss @@ -0,0 +1,85 @@ +/** + * Shared styling for trace components (tree and chart). + */ + +.trace-container { + white-space: nowrap; + font-size: 12px; + overflow-x: auto; + padding: 8px; +} + +.trace-label { + color: var(--trace-label-color, #e3e3e3); + font-family: 'Google Sans Mono', monospace; + font-style: normal; + font-weight: 500; + line-height: 20px; + letter-spacing: 0px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + font-size: 12px; +} + +.trace-bar-container { + position: relative; + height: 18px; +} + +.trace-bar { + position: absolute; + height: 18px; + background-color: var(--mat-sys-primary); + border-radius: 4px; + padding-left: 6px; + box-sizing: border-box; + overflow: hidden; + font-size: 11px; + line-height: 18px; + color: var(--mat-sys-on-primary); + font-family: 'Google Sans'; + transition: background-color 0.2s, color 0.2s; +} + +.trace-duration { + color: var(--trace-duration-color, #888); + font-weight: normal; + margin-left: 4px; +} + +.trace-row { + display: flex; + position: relative; + height: 32px; +} + +.trace-indent { + display: flex; + flex-shrink: 0; + height: 100%; +} + +.indent-connector { + width: 20px; + position: relative; + height: 100%; +} + +.vertical-line { + position: absolute; + top: 0; + bottom: 0; + left: 9px; + width: 1px; + background-color: #ccc; +} + +.horizontal-line { + position: absolute; + top: 50%; + left: 9px; + width: 10px; + height: 1px; + background-color: #ccc; +} diff --git a/src/app/components/trace-tab/trace-event/trace-event.component.scss b/src/app/components/trace-tab/trace-event/trace-event.component.scss index 6f22f011..a8877baf 100644 --- a/src/app/components/trace-tab/trace-event/trace-event.component.scss +++ b/src/app/components/trace-tab/trace-event/trace-event.component.scss @@ -17,12 +17,33 @@ padding-top: 8px; padding-left: 12px; padding-right: 12px; - background-color: var(--trace-event-json-viewer-container-background-color); } .event-graph-container { - text-align: center; - padding-top: 20px; + padding: 20px; + box-sizing: border-box; + height: 100%; +} + +.event-graph-container > div { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + cursor: pointer; +} + +.event-graph-container ::ng-deep svg { + max-width: 100%; + max-height: 100%; + width: auto; + height: auto; + display: block; + + > g.graph > polygon:first-child { + fill: transparent !important; + } } .event-graph-container ::ng-deep svg text { @@ -32,6 +53,22 @@ .wrapper { position: relative; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +mat-tab-group { + flex: 1; + display: flex; + flex-direction: column; + min-height: 0; +} + +::ng-deep .mat-mdc-tab-body-wrapper { + flex: 1; + min-height: 0; } .tab-header-action { @@ -39,6 +76,6 @@ top: 0; right: 0; height: 48px; /* match tab header height */ - z-index: 2; margin-right: 10px; + z-index: 10; } diff --git a/src/app/components/trace-tab/trace-event/trace-event.component.spec.ts b/src/app/components/trace-tab/trace-event/trace-event.component.spec.ts index a73c377b..409cff11 100644 --- a/src/app/components/trace-tab/trace-event/trace-event.component.spec.ts +++ b/src/app/components/trace-tab/trace-event/trace-event.component.spec.ts @@ -137,20 +137,6 @@ describe('TraceEventComponent', () => { expect(component.selectedRow).toEqual(span); }); - it('should call event service to get trace for the selected row', () => { - expect(eventService.getEventTrace).toHaveBeenCalledWith({id: EVENT_ID}); - }); - - it('should set loading state for event trace', () => { - expect(uiStateService.setIsEventRequestResponseLoading) - .toHaveBeenCalledWith(true); - expect(uiStateService.setIsEventRequestResponseLoading) - .toHaveBeenCalledWith(false); - const calls = - uiStateService.setIsEventRequestResponseLoading.calls.allArgs(); - expect(calls).toEqual([[true], [false]]); - }); - it('should call event service to get event details for the selected row', () => { expect(eventService.getEvent) @@ -162,39 +148,27 @@ describe('TraceEventComponent', () => { ); }); - it('should parse LLM request from the event trace', () => { + it('should parse LLM request from the selected span attributes', () => { + traceService.selectedTraceRow$.next({ + ...span, + attributes: { + 'gcp.vertex.agent.event_id': EVENT_ID, + 'gcp.vertex.agent.llm_request': JSON.stringify({data: 'request'}), + } + }); expect(component.llmRequest).toEqual({data: 'request'}); }); - it('should parse LLM response from the event trace', () => { + it('should parse LLM response from the selected span attributes', () => { + traceService.selectedTraceRow$.next({ + ...span, + attributes: { + 'gcp.vertex.agent.event_id': EVENT_ID, + 'gcp.vertex.agent.llm_response': JSON.stringify({data: 'response'}), + } + }); expect(component.llmResponse).toEqual({data: 'response'}); }); - - it('should call getEventTrace with event and parse llm request/response', - () => { - const invocationId = 'inv-1'; - const startTime = 123456789000000; - const llmRequest = {prompt: 'test prompt'}; - const llmResponse = {response: 'test response'}; - eventService.getEventTraceResponse.next({ - 'gcp.vertex.agent.llm_request': JSON.stringify(llmRequest), - 'gcp.vertex.agent.llm_response': JSON.stringify(llmResponse), - }); - - traceService.selectedTraceRow$.next({ - ...span, - invoc_id: invocationId, - start_time: startTime, - }); - - expect(eventService.getEventTrace).toHaveBeenCalledWith({ - id: EVENT_ID, - invocationId, - timestamp: startTime / 1000000, - }); - expect(component.llmRequest).toEqual(llmRequest); - expect(component.llmResponse).toEqual(llmResponse); - }); }); describe('getEventIdFromSpan()', () => { diff --git a/src/app/components/trace-tab/trace-event/trace-event.component.ts b/src/app/components/trace-tab/trace-event/trace-event.component.ts index d2dd94df..f36fd7de 100644 --- a/src/app/components/trace-tab/trace-event/trace-event.component.ts +++ b/src/app/components/trace-tab/trace-event/trace-event.component.ts @@ -36,7 +36,7 @@ import {UI_STATE_SERVICE} from '../../../core/services/interfaces/ui-state'; import {ViewImageDialogComponent} from '../../view-image-dialog/view-image-dialog.component'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-trace-event', templateUrl: './trace-event.component.html', styleUrl: './trace-event.component.scss', @@ -80,30 +80,27 @@ export class TraceEventComponent implements OnInit { this.selectedRow = span; const eventId = this.getEventIdFromSpan(); if (eventId) { - let filter = undefined; - if (this.isEventFilteringEnabled() && this.selectedRow?.invoc_id && - this.selectedRow?.start_time) { - filter = { - invocationId: this.selectedRow.invoc_id, - timestamp: this.selectedRow.start_time / 1000000, - }; + this.llmRequest = undefined; + this.llmResponse = undefined; + + const requestStr = this.selectedRow?.attributes?.[this.llmRequestKey]; + const responseStr = this.selectedRow?.attributes?.[this.llmResponseKey]; + + if (requestStr) { + try { + this.llmRequest = typeof requestStr === 'string' ? JSON.parse(requestStr) : requestStr; + } catch (e) { + console.warn('Failed to parse LLM request', e); + } + } + + if (responseStr) { + try { + this.llmResponse = typeof responseStr === 'string' ? JSON.parse(responseStr) : responseStr; + } catch (e) { + console.warn('Failed to parse LLM response', e); + } } - const eventTraceParam = {id: eventId, ...filter}; - - this.eventService.getEventTrace(eventTraceParam) - .pipe(tap(() => { - this.uiStateService.setIsEventRequestResponseLoading(true); - })) - .subscribe( - (res) => { - this.llmRequest = JSON.parse(res[this.llmRequestKey]); - this.llmResponse = JSON.parse(res[this.llmResponseKey]); - - this.uiStateService.setIsEventRequestResponseLoading(false); - }, - () => { - this.uiStateService.setIsEventRequestResponseLoading(false); - }); this.getEventGraph(eventId); } }); diff --git a/src/app/components/trace-tab/trace-tab.component.html b/src/app/components/trace-tab/trace-tab.component.html index 3e40fb86..b49bd087 100644 --- a/src/app/components/trace-tab/trace-tab.component.html +++ b/src/app/components/trace-tab/trace-tab.component.html @@ -14,25 +14,102 @@ limitations under the License. --> -
- @if (invocTraces.size === 0) { -
{{ i18n.noInvocationsFound }}
- } @else { -

{{ i18n.invocationsTitle }}

-
- @for (invoc of invocTraces | keyvalue: mapOrderPreservingSort; track invoc; let i = $index) { -
- - - {{invocToUserMsg.get(invoc.key)}} - - - +@if (selectedSpan() !== undefined) { +
+
+ + +
+ {{ selectedSpan()?.name }} +
+
+ +
+
+
+ + + +
+
+ @if (selectedDetailTab() === 'info') { +
+ + + + + + + + + + +
Name{{ selectedSpan()?.name }}
Span ID{{ selectedSpan()?.span_id }}
Parent ID + @if (selectedSpan()?.parent_span_id) { + {{ selectedSpan()?.parent_span_id }} + } @else { + None + } +
Trace ID{{ selectedSpan()?.trace_id }}
Start Time{{ formatTime(selectedSpan()?.start_time) }}
End Time{{ formatTime(selectedSpan()?.end_time) }}
+ @if (selectedSpanChildren.length > 0) { + + @for (child of selectedSpanChildren; track child.span_id) { + + + + + } +
{{ child.name }}{{ child.span_id }}
+ } + + @if (selectedSpan()?.attributes && selectedSpan()!.attributes['gcp.vertex.agent.event_id']) { + + + + + +
Event ID{{ selectedSpan()!.attributes['gcp.vertex.agent.event_id'] }}
+ } + +
+ } + @if (selectedDetailTab() === 'attributes') { +
+ @if (selectedSpan()?.attributes && Object.keys(selectedSpan()!.attributes).length > 0) { + + @for (key of Object.keys(selectedSpan()!.attributes); track key) { + + } +
{{ key }}{{ selectedSpan()!.attributes[key] }}
+ } @else { +
No attributes available
+ } +
+ } + @if (selectedDetailTab() === 'raw') { +
+ +
+ }
- }
- }
+} @else { +
Select a trace span to view its details
+} diff --git a/src/app/components/trace-tab/trace-tab.component.scss b/src/app/components/trace-tab/trace-tab.component.scss index 87c730c9..7b105acc 100644 --- a/src/app/components/trace-tab/trace-tab.component.scss +++ b/src/app/components/trace-tab/trace-tab.component.scss @@ -14,152 +14,158 @@ * limitations under the License. */ -@use '@angular/material' as mat; - -.trace-wrapper { - padding-left: 25px; - padding-right: 25px; - - .empty-state { - padding-top: 1em; - text-align: center; - font-style: italic; - } +:host { + display: block; + height: 100%; } -.trace-container { - width: 100%; - white-space: nowrap; - font-size: 12px; +.json-viewer-container { + margin: 10px; } -.trace-title { - color: var(--trace-tab-trace-title-color); - font-size: 14px; - font-style: normal; - font-weight: 700; - line-height: 20px; - letter-spacing: 0px; +.event-paginator { + display: flex; + justify-content: center; + background-color: transparent; + + /* Move the label to the right of the navigation buttons */ + ::ng-deep { + .mat-mdc-paginator-range-label { + order: 2; + margin: 0 0 0 8px; /* Override default margins and add left margin */ + } + } } -.trace-label { - width: 400px; - color: var(--trace-tab-trace-label-color); - text-overflow: ellipsis; - font-family: 'Google Sans Mono', monospace; - font-size: 14px; - font-style: normal; +.span-title { font-weight: 500; - line-height: 20px; - letter-spacing: 0px; + font-family: 'Google Sans Mono', monospace; + font-size: 13px; + color: var(--mat-sys-on-surface); + text-overflow: ellipsis; + overflow: hidden; + white-space: nowrap; + max-width: 300px; + margin-left: 16px; } -.trace-bar-container { - width: 50vw; - position: relative; - height: 16px; +.event-details-container { + display: flex; + flex-direction: column; + height: 100%; } -.trace-bar { - position: absolute; - height: 18px; - background-color: var(--trace-tab-trace-bar-background-color); - border-radius: 4px; - padding-left: 4px; +.event-details-content { + display: flex; + flex: 1; overflow: hidden; - font-size: 11px; - line-height: 16px; - color: var(--trace-tab-trace-bar-color); - font-family: 'Google Sans'; } -.trace-duration { - color: var(--trace-tab-trace-duration-color); - font-weight: normal; - margin-left: 4px; +.vertical-tabs-sidebar { + display: flex; + flex-direction: column; + width: 48px; + border-right: 1px solid var(--mat-sys-outline-variant); + padding-top: 8px; + align-items: center; + gap: 8px; + + button { + border-radius: 6px !important; + + ::ng-deep { + .mat-mdc-button-persistent-ripple, + .mat-mdc-button-ripple, + .mat-mdc-button-persistent-ripple::before, + .mat-mdc-focus-indicator { + border-radius: 6px !important; + } + } + + &.active { + background-color: var(--mat-sys-secondary-container) !important; + color: var(--mat-sys-on-secondary-container) !important; + } + } } -.trace-row { +.vertical-tabs-content { + flex: 1; display: flex; - align-items: stretch; - /* <-- stretch ensures full height */ - position: relative; - height: 32px; - /* Give rows a little more vertical space */ + flex-direction: column; + overflow: hidden; + overflow-y: auto; } -.trace-indent { +.event-details-header { display: flex; + justify-content: flex-end; + align-items: center; + border-bottom: 1px solid var(--mat-sys-outline-variant); + height: 48px; flex-shrink: 0; - height: 100%; - /* Make sure it stretches */ } -.indent-connector { - width: 20px; - position: relative; - height: 100%; +.empty-state { + padding: 16px; + text-align: center; + color: var(--mat-sys-on-surface-variant); + font-style: italic; + font-size: 14px; } -.vertical-line { - position: absolute; - top: 0; - bottom: 0; - left: 9px; - width: 1px; - background-color: var(--trace-tab-vertical-line-background-color); +.info-tables-container { + padding: 16px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 24px; } -.horizontal-line { - position: absolute; - top: 50%; - left: 9px; - width: 10px; - height: 1px; - background-color: var(--trace-tab-horizontal-line-background-color); -} -.trace-item { - margin-top: 5px; - @include mat.expansion-overrides( - ( - container-background-color: - var(--trace-tab-trace-item-container-background-color), - header-focus-state-layer-color: - var(--trace-tab-trace-item-header-focus-state-layer-color), - header-description-color: - var(--trace-tab-trace-item-header-description-color), - header-text-size: 15, - ) - ); - - ::ng-deep .mat-expansion-panel-header.mat-expanded:focus { - background-color: var( - --trace-tab-mat-expansion-panel-header-focus-background-color - ); - } - ::ng-deep .mat-expansion-panel-header.mat-expanded { - background-color: var( - --trace-tab-mat-expansion-panel-header-background-color - ); +.span-link { + color: var(--mat-sys-primary); + text-decoration: none; + cursor: pointer; + + &:hover { + text-decoration: underline; } +} - ::ng-deep .mat-expansion-panel-header.mat-expanded:hover { - background-color: var( - --trace-tab-mat-expansion-panel-header-hover-background-color - ); - } +.id-text { + font-family: 'Google Sans Mono', monospace; + font-size: 11px; } -::ng-deep .mat-expansion-panel-header-title { - text-overflow: ellipsis; - white-space: nowrap; +.id-cell { + display: flex; + align-items: center; + gap: 4px; overflow: hidden; + + > :first-child { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 1; + } } -::ng-deep .mat-expansion-panel-header-description { - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; +.copy-id-button { + width: 24px !important; + height: 24px !important; + padding: 0 !important; + line-height: 24px !important; + flex-shrink: 0; + margin: -4px 0 !important; + + .mat-icon { + font-size: 14px; + width: 14px; + height: 14px; + line-height: 14px; + } } diff --git a/src/app/components/trace-tab/trace-tab.component.spec.ts b/src/app/components/trace-tab/trace-tab.component.spec.ts index d157f168..cc08b5b7 100644 --- a/src/app/components/trace-tab/trace-tab.component.spec.ts +++ b/src/app/components/trace-tab/trace-tab.component.spec.ts @@ -85,7 +85,8 @@ describe('TraceTabComponent', () => { expect(expansionPanels.length).toBe(0); }); - describe('with trace data', () => { + // Skipped: mat-expansion-panel UI removed in UI refactor + xdescribe('with trace data', () => { const MOCK_TRACE_DATA_WITH_MULTIPLE_TRACES: Span[] = [ ...MOCK_TRACE_DATA, { @@ -121,9 +122,12 @@ describe('TraceTabComponent', () => { }); it('should display user message as panel title', async () => { + fixture.detectChanges(); + await fixture.whenStable(); const expansionPanels = await loader.getAllHarnesses( MatExpansionPanelHarness, ); + expect(expansionPanels.length).toBe(2); expect(await expansionPanels[0].getTitle()) .toBe( 'I need help with my project.', @@ -132,90 +136,19 @@ describe('TraceTabComponent', () => { }); it('should pass correct data to trace-tree component', async () => { - spyOn(component, 'findInvocIdFromTraceId').and.callThrough(); + fixture.detectChanges(); + await fixture.whenStable(); const expansionPanels = await loader.getAllHarnesses( MatExpansionPanelHarness, ); + expect(expansionPanels.length).toBeGreaterThan(0); await expansionPanels[0].expand(); fixture.detectChanges(); - expect(component.findInvocIdFromTraceId).toHaveBeenCalledWith('trace-1'); const traceTree = fixture.nativeElement.querySelector('app-trace-tree'); expect(traceTree).toBeTruthy(); // Further inspection of trace-tree inputs would require a harness or // mocking TraceTreeComponent }); }); - - describe('findUserMsgFromInvocGroup', () => { - it('should find user message from span with both invocation_id and llm_request', - () => { - // First span has only invocation_id, second span has both - const group: Span[] = [ - { - name: 'invocation', - start_time: 1733084700000000000, - end_time: 1733084760000000000, - span_id: 'span-1', - trace_id: 'trace-1', - attributes: { - 'gcp.vertex.agent.invocation_id': 'invoc-1', - }, - }, - { - name: 'call_llm', - start_time: 1733084710000000000, - end_time: 1733084750000000000, - span_id: 'span-2', - parent_span_id: 'span-1', - trace_id: 'trace-1', - attributes: { - 'gcp.vertex.agent.invocation_id': 'invoc-1', - 'gcp.vertex.agent.llm_request': - '{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}', - }, - }, - ]; - - const result = component.findUserMsgFromInvocGroup(group); - expect(result).toBe('hi'); - }); - - it('should return fallback when no span has llm_request', () => { - const group: Span[] = [ - { - name: 'invocation', - start_time: 1733084700000000000, - end_time: 1733084760000000000, - span_id: 'span-1', - trace_id: 'trace-1', - attributes: { - 'gcp.vertex.agent.invocation_id': 'invoc-1', - }, - }, - ]; - - const result = component.findUserMsgFromInvocGroup(group); - expect(result).toBe('[no invocation id found]'); - }); - - it('should return error message on invalid JSON', () => { - const group: Span[] = [ - { - name: 'call_llm', - start_time: 1733084700000000000, - end_time: 1733084760000000000, - span_id: 'span-1', - trace_id: 'trace-1', - attributes: { - 'gcp.vertex.agent.invocation_id': 'invoc-1', - 'gcp.vertex.agent.llm_request': 'invalid json{', - }, - }, - ]; - - const result = component.findUserMsgFromInvocGroup(group); - expect(result).toBe('[error parsing request]'); - }); - }); }); diff --git a/src/app/components/trace-tab/trace-tab.component.ts b/src/app/components/trace-tab/trace-tab.component.ts index c1187a63..201b3294 100644 --- a/src/app/components/trace-tab/trace-tab.component.ts +++ b/src/app/components/trace-tab/trace-tab.component.ts @@ -14,100 +14,159 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {KeyValuePipe} from '@angular/common'; -import {ChangeDetectionStrategy, Component, inject, Input, OnChanges, OnInit, SimpleChanges} from '@angular/core'; -import {MatDialogTitle} from '@angular/material/dialog'; -import {MatExpansionPanel, MatExpansionPanelHeader, MatExpansionPanelTitle} from '@angular/material/expansion'; - -import {LlmRequest} from '../../core/models/types'; - -import {TraceTabMessagesInjectionToken} from './trace-tab.component.i18n'; -import {TraceTreeComponent} from './trace-tree/trace-tree.component'; +import {ChangeDetectionStrategy, Component, inject, Input, output, signal, Injectable, HostListener} from '@angular/core'; +import {toSignal} from '@angular/core/rxjs-interop'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatPaginator, MatPaginatorIntl, PageEvent} from '@angular/material/paginator'; +import {MatTooltipModule} from '@angular/material/tooltip'; +import {NgxJsonViewerModule} from 'ngx-json-viewer'; +import {InfoTable} from '../info-table/info-table'; + +import {TRACE_SERVICE} from '../../core/services/interfaces/trace'; + +@Injectable() +export class SpanPaginatorIntl extends MatPaginatorIntl { + override nextPageLabel = 'Next Span'; + override previousPageLabel = 'Previous Span'; + override firstPageLabel = 'First Span'; + override lastPageLabel = 'Last Span'; + + override getRangeLabel = (page: number, pageSize: number, length: number) => { + if (length === 0) { + return `Span 0 of 0`; + } + length = Math.max(length, 0); + const startIndex = page * pageSize; + return `Span ${startIndex + 1} of ${length}`; + }; +} @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-trace-tab', templateUrl: './trace-tab.component.html', styleUrl: './trace-tab.component.scss', standalone: true, imports: [ - MatDialogTitle, MatExpansionPanel, MatExpansionPanelHeader, - MatExpansionPanelTitle, TraceTreeComponent, KeyValuePipe + MatButtonModule, MatIconModule, MatTooltipModule, NgxJsonViewerModule, MatPaginator, InfoTable + ], + providers: [ + { provide: MatPaginatorIntl, useClass: SpanPaginatorIntl } ] }) +export class TraceTabComponent { + _traceData: any[] = []; + orderedTraceData: any[] = []; + + // Input kept so we don't break side-panel binding, though not used here anymore + @Input() set traceData(val: any[]) { + this._traceData = val || []; + this.orderedTraceData = this.computeOrdered(this._traceData); + } -export class TraceTabComponent implements OnInit, OnChanges { - @Input() traceData: any = []; - invocTraces = new Map(); - invocToUserMsg = new Map(); - protected readonly i18n = inject(TraceTabMessagesInjectionToken); + get traceData(): any[] { + return this._traceData; + } - constructor() {} + computeOrdered(spans: any[]): any[] { + const spanClones = spans.map(span => ({...span})); + const spanMap = new Map(); + const roots: any[] = []; + + spanClones.forEach(span => spanMap.set(span.span_id, span)); + spanClones.forEach(span => { + if (span.parent_span_id && spanMap.has(span.parent_span_id)) { + const parent = spanMap.get(span.parent_span_id)!; + parent.children = parent.children || []; + parent.children.push(span); + } else { + roots.push(span); + } + }); - ngOnInit(): void {} + const flatten = (spansArray: any[]): any[] => { + return spansArray.flatMap(span => [ + span, + ...(span.children ? flatten(span.children) : []) + ]); + }; - ngOnChanges(changes: SimpleChanges) { - if ('traceData' in changes) { - this.rebuildTrace(); - } + return flatten(roots); + } + + protected readonly traceService = inject(TRACE_SERVICE); + selectedSpan = toSignal(this.traceService.selectedTraceRow$); + selectedDetailTab = signal<'info' | 'attributes' | 'raw'>('info'); + switchToEvent = output(); + + formatTime(nanos: number | undefined): string { + if (!nanos) return 'N/A'; + return new Date(nanos / 1_000_000).toLocaleString(); } - rebuildTrace() { - this.invocTraces = this.traceData.reduce((map: any, item: any) => { - const key = item.trace_id; - const group = map.get(key); - if (group) { - group.push(item); - group.sort((a: any, b: any) => a.start_time - b.start_time); - } else { - map.set(key, [item]); - } - return map; - }, new Map()); + get selectedSpanChildren() { + const span = this.selectedSpan(); + if (!span) return []; + if (span.children && span.children.length > 0) return span.children; + return this.traceData.filter(s => s.parent_span_id === span.span_id); + } - for (const [key, value] of this.invocTraces) { - this.invocToUserMsg.set(key, this.findUserMsgFromInvocGroup(value)) + selectSpanById(id: string | undefined): void { + if (!id) return; + const span = this.traceData.find(s => String(s.span_id) === String(id)); + if (span) { + this.traceService.selectedRow(span); } } + get selectedSpanIndex(): number | undefined { + const span = this.selectedSpan(); + if (!span) return undefined; + const index = this.orderedTraceData.findIndex(s => s.span_id === span.span_id); + return index === -1 ? undefined : index; + } - getArray(n: number): number[] { - return Array.from({length: n}); + onPage(event: PageEvent) { + if (event.pageIndex >= 0 && event.pageIndex < this.orderedTraceData.length) { + this.traceService.selectedRow(this.orderedTraceData[event.pageIndex]); + } } - findUserMsgFromInvocGroup(group: any[]) { - // Find a span that has both invocation_id and llm_request - // The invocation_id is present on multiple spans, but llm_request - // is only on the call_llm span - const eventItem = group?.find( - item => item.attributes !== undefined && - 'gcp.vertex.agent.invocation_id' in item.attributes && - 'gcp.vertex.agent.llm_request' in item.attributes) - - if (!eventItem) { - return '[no invocation id found]'; + @HostListener('window:keydown', ['$event']) + handleKeyboardNavigation(event: KeyboardEvent) { + if (this.selectedSpanIndex === undefined) return; + + const activeElement = document.activeElement as HTMLElement | null; + if (activeElement && (activeElement.tagName === 'INPUT' || activeElement.tagName === 'TEXTAREA' || activeElement.isContentEditable)) { + return; } - try { - const requestJson = - JSON.parse(eventItem.attributes['gcp.vertex.agent.llm_request']) as - LlmRequest - const userContent = - requestJson.contents.filter((c: any) => c.role == 'user').at(-1) - return userContent?.parts[0]?.text ?? '[attachment]'; - } catch { - return '[error parsing request]'; + // Only handle arrow keys + if (event.key !== 'ArrowUp' && event.key !== 'ArrowDown') return; + + event.preventDefault(); + + // Navigate to next or previous + let newIndex: number; + if (event.key === 'ArrowDown') { + newIndex = this.selectedSpanIndex + 1 >= this.orderedTraceData.length ? 0 : this.selectedSpanIndex + 1; + } else { + newIndex = this.selectedSpanIndex - 1 < 0 ? this.orderedTraceData.length - 1 : this.selectedSpanIndex - 1; } - } - findInvocIdFromTraceId(traceId: string) { - const group = this.invocTraces.get(traceId); - return group - ?.find( - item => item.attributes !== undefined && - 'gcp.vertex.agent.invocation_id' in item.attributes) - .attributes['gcp.vertex.agent.invocation_id'] + this.traceService.selectedRow(this.orderedTraceData[newIndex]); } + + readonly Object = Object; - mapOrderPreservingSort = (a: any, b: any): number => 0; + copiedId: string | null = null; + + copyToClipboard(value: string | undefined | null) { + if (!value) return; + navigator.clipboard.writeText(value).then(() => { + this.copiedId = value; + setTimeout(() => this.copiedId = null, 2000); + }); + } } diff --git a/src/app/components/trace-tab/trace-tree/trace-tree.component.html b/src/app/components/trace-tab/trace-tree/trace-tree.component.html index ec6ebdac..f26e976f 100644 --- a/src/app/components/trace-tab/trace-tree/trace-tree.component.html +++ b/src/app/components/trace-tab/trace-tree/trace-tree.component.html @@ -14,57 +14,71 @@ limitations under the License. --> -
-
Invocation ID: -
{{invocationId}}
+
+
+ Invocation ID: +
{{invocationId}}
+ Total latency: {{formatDuration(rootLatencyNanos)}}
@for (node of flatTree; track node) { -
-
-
- @for (i of getArray(node.level); track $index) { -
- } -
- - {{ getSpanIcon(node.span.name) }} - -
- {{ node.span.name }} -
-
-
+ @if (shouldShowNode(node)) {
- {{ (toMs(node.span.end_time) - toMs(node.span.start_time)).toFixed(2) }}ms +
+
+ @for (i of getArray(node.level); track $index) { +
+ } +
+ + {{ getSpanIcon(node.span.name) }} + +
+ {{ formatSpanName(node.span.name) }} +
+
+
+
+ {{ formatDuration(node.span.end_time - node.span.start_time) }} +
+ @if (getRelativeWidth(node.span) < 10) { + {{ formatDuration(node.span.end_time - node.span.start_time) }} + } +
- @if (getRelativeWidth(node.span) < 10) { - {{ (toMs(node.span.end_time) - toMs(node.span.start_time)).toFixed(2) }}ms - } -
-
+ } }
+ + + @if (uiEvent) { +
+ +
+ } +
diff --git a/src/app/components/trace-tab/trace-tree/trace-tree.component.scss b/src/app/components/trace-tab/trace-tree/trace-tree.component.scss index 7b2214d2..69244711 100644 --- a/src/app/components/trace-tab/trace-tree/trace-tree.component.scss +++ b/src/app/components/trace-tab/trace-tree/trace-tree.component.scss @@ -13,121 +13,95 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -.trace-container { - width: 100%; - white-space: nowrap; - font-size: 12px; -} +@use '../../trace-shared'; .trace-label { - width: 400px; - color: var(--trace-tree-trace-label-color); - font-family: 'Google Sans Mono', monospace; + flex: 1; + min-width: 0; font-size: 13px; - font-style: normal; - font-weight: 500; - line-height: 20px; - letter-spacing: 0px; - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; } .trace-bar-container { - width: 100%; - position: relative; - height: 16px; -} - -.trace-bar { - position: absolute; - height: 18px; - background-color: var(--trace-tree-trace-bar-background-color); - border-radius: 4px; - padding-left: 4px; - overflow: hidden; - font-size: 11px; - line-height: 16px; - color: var(--trace-tree-trace-bar-color); - font-family: 'Google Sans'; + flex: 1; + min-width: 0; } .short-trace-bar-duration { position: absolute; color: var(--trace-tree-short-trace-bar-duration-color); -} - -.trace-duration { - color: var(--trace-tree-trace-duration-color); - font-weight: normal; - margin-left: 4px; + padding-right: 6px; } .trace-row { - display: flex; - align-items: stretch; - position: relative; - height: 32px; align-items: center; cursor: pointer; + scroll-margin-top: 40px; &:hover { - background-color: var(--trace-tree-trace-row-hover-background-color); + background-color: var(--mat-sys-surface-variant, rgba(0, 0, 0, 0.04)); } } .trace-row.selected { - background-color: var(--trace-tree-trace-row-selected-background-color); + background-color: var(--mat-sys-secondary-container, rgba(0, 0, 0, 0.08)); } -.trace-indent { +.trace-row-left { display: flex; - flex-shrink: 0; - height: 100%; - /* Make sure it stretches */ + min-width: 250px; + width: 20%; + max-width: 350px; } -.indent-connector { - width: 20px; - position: relative; - height: 100%; -} +.invocation-id-container { + color: var(--mat-sys-on-surface-variant); + font-size: 11px; + font-weight: 600; + letter-spacing: 0.3px; + margin-bottom: 6px; + padding: 8px 12px; + border-radius: 12px 12px 0 0; + background-color: var(--mat-sys-surface); + display: flex; + width: 100%; + box-sizing: border-box; + align-items: center; + position: sticky; + top: -20px; + z-index: 10; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); + cursor: pointer; -.vertical-line { - position: absolute; - top: 0; - bottom: 0; - left: 9px; - width: 1px; - background-color: var(--trace-tree-vertical-line-background-color); -} + &:hover { + background-color: var(--mat-sys-surface-variant); + } -.horizontal-line { - position: absolute; - top: 50%; - left: 9px; - width: 10px; - height: 1px; - background-color: var(--trace-tree-horizontal-line-background-color); -} + &.selected { + background-color: var(--mat-sys-secondary-container, rgba(0, 0, 0, 0.08)); + } -.trace-row-left { - display: flex; - width: 50%; + > span:first-child { + opacity: 0.8; + margin-right: 6px; + text-transform: uppercase; + } } -.invocation-id-container { - color: var(--trace-tree-invocation-id-container-color); - font-size: 14px; - font-style: normal; - font-weight: 700; - line-height: 20px; - letter-spacing: 0px; - margin-bottom: 5px; +.invocation-id { + font-family: 'Google Sans Mono', 'Roboto Mono', monospace; + padding: 2px 6px; + border-radius: 4px; + color: var(--mat-sys-on-surface); } -.invocation-id { - font-family: 'Google Sans Mono', monospace; +.total-latency { + margin-left: auto; + background: transparent; + color: var(--mat-sys-on-surface); + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.2px; } .trace-row-left span, @@ -138,3 +112,21 @@ .trace-row-left .is-event-row { color: var(--trace-tree-trace-row-left-is-event-row-color); } + +.event-tooltip-container { + max-width: 800px; + max-height: 200px; + overflow: auto; + padding: 8px; + background: var(--mat-sys-surface-container-low, #202124); + color: var(--mat-sys-on-surface, #e8eaed); + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.5); + border: 1px solid var(--mat-sys-outline-variant, rgba(255, 255, 255, 0.1)); + + ::ng-deep app-content-bubble { + max-height: 160px; + overflow-y: auto; + display: block; + } +} diff --git a/src/app/components/trace-tab/trace-tree/trace-tree.component.spec.ts b/src/app/components/trace-tab/trace-tree/trace-tree.component.spec.ts index b8a77309..b54380c5 100644 --- a/src/app/components/trace-tab/trace-tree/trace-tree.component.spec.ts +++ b/src/app/components/trace-tab/trace-tree/trace-tree.component.spec.ts @@ -28,7 +28,6 @@ describe('TraceTreeComponent', () => { const traceService = { ...jasmine.createSpyObj([ 'selectedRow', - 'setHoveredMessages', ]), selectedTraceRow$: of(undefined), eventData$: of(new Map()), diff --git a/src/app/components/trace-tab/trace-tree/trace-tree.component.ts b/src/app/components/trace-tab/trace-tree/trace-tree.component.ts index ce8e7bde..14336955 100644 --- a/src/app/components/trace-tab/trace-tree/trace-tree.component.ts +++ b/src/app/components/trace-tab/trace-tree/trace-tree.component.ts @@ -14,25 +14,48 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import {ChangeDetectionStrategy, Component, inject, Input} from '@angular/core'; +import {ChangeDetectionStrategy, Component, inject, Input, OnChanges, OnInit, SimpleChanges} from '@angular/core'; +import {MatButtonModule} from '@angular/material/button'; +import {MatIconModule} from '@angular/material/icon'; +import {MatTooltipModule} from '@angular/material/tooltip'; import {Span} from '../../../core/models/Trace'; import {TRACE_SERVICE} from '../../../core/services/interfaces/trace'; +import {UiEvent} from '../../../core/models/UiEvent'; +import {HtmlTooltipDirective} from '../../../directives/html-tooltip.directive'; +import {EventContentComponent} from '../../event-content/event-content.component'; @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-trace-tree', templateUrl: './trace-tree.component.html', styleUrl: './trace-tree.component.scss', + imports: [MatButtonModule, MatIconModule, MatTooltipModule, HtmlTooltipDirective, EventContentComponent] }) -export class TraceTreeComponent { +export class TraceTreeComponent implements OnInit, OnChanges { @Input() spans: any[] = []; @Input() invocationId: string = ''; + @Input() uiEvents: UiEvent[] = []; + @Input() shouldShowEvent?: (uiEvent: UiEvent) => boolean; + tree: Span[] = []; - eventData: Map|undefined; baseStartTimeMs = 0; totalDurationMs = 1; + rootLatencyNanos = 0; flatTree: {span: Span; level: number}[] = []; + + shouldShowNode(node: any): boolean { + const uiEvent = this.getUiEvent(node); + if (!uiEvent) { + return true; + } + + if (this.shouldShowEvent) { + return this.shouldShowEvent(uiEvent); + } + + return true; + } traceLabelIconMap = new Map([ ['Invocation', 'start'], // TODO: Remove agent_run mapping once all ADKs span names follow OTLP GenAI semconv. @@ -47,15 +70,67 @@ export class TraceTreeComponent { constructor() {} + selectRootSpan() { + if (this.tree && this.tree.length > 0) { + if (this.selectedRow && this.selectedRow.span_id === this.tree[0].span_id) { + return; + } + this.traceService.selectedRow(this.tree[0]); + } + } + + isRootSpanSelected() { + if (!this.selectedRow || !this.tree || this.tree.length === 0) return false; + return String(this.selectedRow.span_id) === String(this.tree[0].span_id); + } + + + ngOnInit(): void { + this.rebuildTree(); + this.traceService.selectedTraceRow$.subscribe(span => { + this.selectedRow = span; + if (span) { + setTimeout(() => { + const element = document.getElementById('trace-node-' + span.span_id); + if (element) { + element.scrollIntoView({behavior: 'smooth', block: 'nearest'}); + } + }, 50); + } + }); + } + + ngOnChanges(changes: SimpleChanges): void { + if (changes['spans'] && !changes['spans'].isFirstChange()) { + this.rebuildTree(); + } + } + + rebuildTree() { + if (!this.spans || this.spans.length === 0) { + this.tree = []; + this.flatTree = []; + this.rootLatencyNanos = 0; + return; + } this.tree = this.buildSpanTree(this.spans); - this.flatTree = this.flattenTree(this.tree); + this.flatTree = []; + this.tree.forEach(root => { + if (root.children) { + this.flatTree.push(...this.flattenTree(root.children, 0)); + } + }); + const times = this.getGlobalTimes(this.spans); this.baseStartTimeMs = times.start; this.totalDurationMs = times.duration; - this.traceService.selectedTraceRow$.subscribe( - span => this.selectedRow = span); - this.traceService.eventData$.subscribe(e => this.eventData = e); + + if (this.tree && this.tree.length > 0) { + this.rootLatencyNanos = this.tree[0].end_time - this.tree[0].start_time; + } else { + this.rootLatencyNanos = 0; + } } @@ -88,6 +163,18 @@ export class TraceTreeComponent { return nanos / 1_000_000; } + formatDuration(nanos: number): string { + if (nanos === 0) return '0us'; + if (nanos < 1000) return `${nanos}ns`; + if (nanos < 1_000_000) return `${(nanos / 1000).toFixed(2)}us`; + if (nanos < 1_000_000_000) return `${(nanos / 1_000_000).toFixed(2)}ms`; + if (nanos < 60_000_000_000) return `${(nanos / 1_000_000_000).toFixed(2)}s`; + + const minutes = Math.floor(nanos / 60_000_000_000); + const seconds = ((nanos % 60_000_000_000) / 1_000_000_000).toFixed(2); + return `${minutes}m ${seconds}s`; + } + getRelativeStart(span: Span): number { return ((this.toMs(span.start_time) - this.baseStartTimeMs) / this.totalDurationMs) * @@ -118,22 +205,30 @@ export class TraceTreeComponent { return 'start'; } + formatSpanName(name: string): string { + if (name.startsWith('invoke_agent ')) { + return name.substring('invoke_agent '.length); + } + if (name.startsWith('execute_tool ')) { + return name.substring('execute_tool '.length); + } + return name; + } + getArray(n: number): number[] { return Array.from({length: n}); } selectRow(node: any) { if (this.selectedRow && this.selectedRow.span_id == node.span.span_id) { - this.traceService.selectedRow(undefined); - this.traceService.setHoveredMessages(undefined, this.invocationId) return; } this.traceService.selectedRow(node.span); - this.traceService.setHoveredMessages(node.span, this.invocationId) } rowSelected(node: any) { - return this.selectedRow == node.span + if (!this.selectedRow || !node?.span) return false; + return String(this.selectedRow.span_id) === String(node.span.span_id); } isEventRow(node: any) { @@ -141,20 +236,23 @@ export class TraceTreeComponent { return false; } const eventId = node?.span.attributes['gcp.vertex.agent.event_id']; - if (eventId && this.eventData && this.eventData.has(eventId)) { - return true; + if (eventId && this.uiEvents && this.uiEvents.length > 0) { + return this.uiEvents.some(e => e.event?.id === eventId); } return false; } - onHover(n: any) { - this.traceService.setHoveredMessages(n.span, this.invocationId) + getEventId(node: any): string { + return node?.span?.attributes?.['gcp.vertex.agent.event_id'] ?? ''; } - onHoverOut() { - this.traceService.setHoveredMessages(undefined, this.invocationId); - if (this.selectedRow) { - this.traceService.setHoveredMessages(this.selectedRow, this.invocationId); + getUiEvent(node: any): UiEvent | null { + const eventId = this.getEventId(node); + if (eventId && this.uiEvents && this.uiEvents.length > 0) { + return this.uiEvents.find(e => e.event?.id === eventId) || null; } + return null; } + + } diff --git a/src/app/components/view-image-dialog/view-image-dialog.component.scss b/src/app/components/view-image-dialog/view-image-dialog.component.scss index 31d12b5e..3731febc 100644 --- a/src/app/components/view-image-dialog/view-image-dialog.component.scss +++ b/src/app/components/view-image-dialog/view-image-dialog.component.scss @@ -1,6 +1,5 @@ :host { display: block; - background-color: var(--mdc-dialog-container-color); padding: 16px; } @@ -8,7 +7,6 @@ position: absolute; top: 5px; right: 10px; - background: none; border: none; cursor: pointer; padding: 8px; @@ -18,11 +16,9 @@ display: flex; align-items: center; justify-content: center; - z-index: 10; /* Ensure button is clickable */ margin-bottom: 15px; &:hover { - background-color: var(--builder-tool-item-hover-background-color); } svg { diff --git a/src/app/components/view-image-dialog/view-image-dialog.component.ts b/src/app/components/view-image-dialog/view-image-dialog.component.ts index 2074b468..d6e088ca 100644 --- a/src/app/components/view-image-dialog/view-image-dialog.component.ts +++ b/src/app/components/view-image-dialog/view-image-dialog.component.ts @@ -25,7 +25,7 @@ export interface ViewImageDialogData { } @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, selector: 'app-view-image-dialog', templateUrl: './view-image-dialog.component.html', styleUrls: ['./view-image-dialog.component.scss'], diff --git a/src/app/components/workflow-graph-tooltip/workflow-graph-tooltip.component.html b/src/app/components/workflow-graph-tooltip/workflow-graph-tooltip.component.html new file mode 100644 index 00000000..8016bd1b --- /dev/null +++ b/src/app/components/workflow-graph-tooltip/workflow-graph-tooltip.component.html @@ -0,0 +1,99 @@ + + +
+
+ Workflow Graph + @if (isPinned) { + (Pinned - Click X to close) + } @else { + (Click to pin) + } + +
+ @if (breadcrumbs().length > 1) { + + } +
+ + +
+ + + + +
+ + {{ getNodeTypeIcon(ctx.node.data().type) }} + + {{ ctx.node.data().name }} + @if (ctx.node.data().hasNestedStructure) { + zoom_in + } + + {{ getStatusIcon(ctx.node.data().status) }} + +
+
{{ getNodeTypeLabel(ctx.node.data().type) }}
+
+ {{ getStatusLabel(ctx.node.data().status) }} +
+ @if (ctx.node.data().retryCount && ctx.node.data().retryCount > 0) { +
Retry: {{ ctx.node.data().retryCount }}
+ } +
+
+ + + + + + +
+
+
diff --git a/src/app/components/workflow-graph-tooltip/workflow-graph-tooltip.component.scss b/src/app/components/workflow-graph-tooltip/workflow-graph-tooltip.component.scss new file mode 100644 index 00000000..8fc7752f --- /dev/null +++ b/src/app/components/workflow-graph-tooltip/workflow-graph-tooltip.component.scss @@ -0,0 +1,188 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +.workflow-graph-tooltip { + width: 500px; + height: 400px; + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); +} + +.tooltip-header { + font-size: 14px; + font-weight: 500; + color: var(--mdc-dialog-supporting-text-color); + margin-bottom: 8px; + padding-bottom: 8px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); + display: flex; + align-items: center; + gap: 8px; +} + +.pinned-hint { + font-size: 12px; + font-weight: 400; + opacity: 0.7; + font-style: italic; + flex: 1; +} + +.close-button { + width: 24px; + height: 24px; + line-height: 24px; + margin-left: auto; + + mat-icon { + font-size: 18px; + width: 18px; + height: 18px; + line-height: 18px; + } +} + +.breadcrumb-nav { + display: flex; + align-items: center; + margin-bottom: 8px; + font-size: 12px; + color: var(--mdc-dialog-supporting-text-color); +} + +.breadcrumb-item { + cursor: pointer; + padding: 3px 6px; + border-radius: 3px; + transition: background-color 0.2s; + + &:hover:not(.active) { + } + + &.active { + font-weight: 500; + cursor: default; + } +} + +.breadcrumb-separator { + font-size: 14px; + width: 14px; + height: 14px; + opacity: 0.5; + margin: 0 2px; +} + +.vflow-container { + flex: 1; + min-height: 0; + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 4px; + overflow: hidden; + position: relative; + + vflow { + width: 100%; + height: 100%; + display: block; + } +} + +.workflow-node { + border: 2px solid; + border-radius: 6px; + padding: 8px 12px; + min-width: 160px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3); + transition: all 0.2s; + + &.expandable { + cursor: pointer; + + &:hover { + box-shadow: 0 4px 12px rgba(138, 180, 248, 0.3); + transform: scale(1.02); + } + } +} + +.node-header { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 4px; +} + +.node-type-icon { + font-size: 16px; + width: 16px; + height: 16px; + color: rgba(138, 180, 248, 0.9); +} + +.status-icon { + font-size: 16px; + width: 16px; + height: 16px; + margin-left: auto; +} + +.node-label { + font-weight: 500; + font-size: 13px; + color: var(--mdc-dialog-supporting-text-color); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + flex: 1; +} + +.node-type { + font-size: 10px; + color: rgba(138, 180, 248, 0.8); + font-weight: 500; + text-transform: uppercase; + letter-spacing: 0.5px; + margin-top: 2px; +} + +.node-status { + font-size: 11px; + font-weight: 600; + margin-top: 2px; +} + +.node-retry { + font-size: 10px; + color: var(--mdc-dialog-supporting-text-color); + opacity: 0.7; + margin-top: 2px; +} + +// Active edge animation +:host ::ng-deep .active-edge { + animation: dash 1.5s linear infinite; + stroke-dasharray: 8 4; +} + +@keyframes dash { + to { + stroke-dashoffset: -12; + } +} diff --git a/src/app/components/workflow-graph-tooltip/workflow-graph-tooltip.component.ts b/src/app/components/workflow-graph-tooltip/workflow-graph-tooltip.component.ts new file mode 100644 index 00000000..d2ad70e8 --- /dev/null +++ b/src/app/components/workflow-graph-tooltip/workflow-graph-tooltip.component.ts @@ -0,0 +1,482 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {Component, Input, OnInit, signal} from '@angular/core'; +import {CommonModule} from '@angular/common'; +import {MatIconModule} from '@angular/material/icon'; +import {MatButtonModule} from '@angular/material/button'; +import {Vflow, Edge, HtmlTemplateDynamicNode, ConnectionSettings} from 'ngx-vflow'; +import {NodeState, NodeStatus} from '../../core/models/types'; +import {calculateGraphLayout, getNodeName, getNodeTypeIcon, getNodeTypeLabel} from '../../utils/graph-layout.utils'; +import {buildNavigationStackFromPath, findNodeInLevel, getCurrentPath, getNodesAtLevel, hasNestedStructure, NavigationStackItem, DEFAULT_LAYOUT_CONFIG} from '../../utils/graph-navigation.utils'; + +interface WorkflowNodeData { + name: string; + type: string; + status: NodeStatus; + input?: any; + triggeredBy?: string; + retryCount?: number; + runId?: string; + hasNestedStructure?: boolean; + nodeData?: any; +} + +@Component({ + selector: 'app-workflow-graph-tooltip', + templateUrl: './workflow-graph-tooltip.component.html', + styleUrls: ['./workflow-graph-tooltip.component.scss'], + standalone: true, + imports: [ + CommonModule, + MatIconModule, + MatButtonModule, + Vflow, + ], +}) +export class WorkflowGraphTooltipComponent implements OnInit { + @Input() nodes: {[key: string]: NodeState} | null = null; + @Input() agentGraphData: any = null; + @Input() nodePath: string | null = null; + @Input() allNodes: {[path: string]: {[nodeName: string]: NodeState}} | null = null; + @Input() isPinned: boolean = false; + @Input() onClose?: () => void; + + public graphNodes = signal[]>([]); + public graphEdges = signal([]); + public NodeStatus = NodeStatus; + public connection: ConnectionSettings = { + mode: 'loose', + }; + + // Navigation state + private fullAgentData: any = null; + private navigationStack: NavigationStackItem[] = []; + public breadcrumbs = signal([]); + + close() { + if (this.onClose) { + this.onClose(); + } + } + + ngOnInit(): void { + this.buildGraph(); + } + + private buildGraph(): void { + if (this.agentGraphData?.root_agent) { + this.fullAgentData = this.agentGraphData.root_agent; + this.navigationStack = [{name: this.agentGraphData.root_agent.name, data: this.agentGraphData.root_agent}]; + + // Navigate to the level specified by nodePath + if (this.nodePath) { + this.navigateToNodePath(this.nodePath); + } + + this.updateBreadcrumbs(); + const currentLevel = this.navigationStack[this.navigationStack.length - 1].data; + this.buildGraphFromStructure(currentLevel); + } else { + this.buildGraphFromStateOnly(); + } + } + + private buildGraphFromStructure(agentData: any): void { + const nodes: HtmlTemplateDynamicNode[] = []; + const edges: Edge[] = []; + + // Handle LlmAgent/Mesh with nodes field + if (agentData.nodes && Array.isArray(agentData.nodes)) { + this.buildMeshGraph(agentData.nodes, nodes, edges); + } + // Handle WorkflowAgent/SingleLlmAgent with graph field + else if (agentData.graph && agentData.graph.nodes) { + // Calculate layout using utility function + const layout = calculateGraphLayout( + agentData.graph.nodes, + agentData.graph.edges || [], + DEFAULT_LAYOUT_CONFIG + ); + + // Create nodes with calculated positions + agentData.graph.nodes.forEach((node: any, index: number) => { + const nodeName = getNodeName(node, `node_${index}`); + const nodeState = this.nodes ? this.nodes[nodeName] : null; + const nodeType = node.type || 'agent'; + const position = layout.positions.get(nodeName) || {x: DEFAULT_LAYOUT_CONFIG.startX, y: DEFAULT_LAYOUT_CONFIG.startY}; + const hasNested = hasNestedStructure(node); + + // Get status - either direct state or RUNNING if children are executing + const status = this.getNodeStatusAtLevel(nodeName, node); + + nodes.push({ + id: nodeName, + type: 'html-template', + point: signal({x: position.x, y: position.y}), + width: signal(180), + height: signal(80), + data: signal({ + name: nodeName, + type: nodeType, + status: status, + input: nodeState?.input, + triggeredBy: nodeState?.triggered_by, + retryCount: nodeState?.retry_count, + runId: nodeState?.run_id, + hasNestedStructure: hasNested, + nodeData: node, + }), + }); + }); + + // Add edges from graph.edges + if (agentData.graph.edges) { + agentData.graph.edges.forEach((edge: any, index: number) => { + const fromName = getNodeName(edge.from_node); + const toName = getNodeName(edge.to_node); + + if (fromName && toName) { + // Check if source node is RUNNING + const fromNodeStatus = this.getNodeStatusAtLevel(fromName, edge.from_node); + const toNodeStatus = this.getNodeStatusAtLevel(toName, edge.to_node); + + // Highlight edge if source is running or completed and target is running/pending + const isActive = fromNodeStatus === NodeStatus.RUNNING || + (fromNodeStatus === NodeStatus.COMPLETED && + (toNodeStatus === NodeStatus.RUNNING || toNodeStatus === NodeStatus.PENDING)); + + edges.push({ + id: `${fromName}_to_${toName}_${index}`, + source: fromName, + target: toName, + type: 'template', + floating: true, + data: { isActive }, + markers: { + end: { + type: 'arrow-closed', + width: 15, + height: 15, + color: isActive ? '#42A5F5' : 'rgba(138, 180, 248, 0.8)', + }, + }, + }); + } + }); + } + } + + this.graphNodes.set(nodes); + this.graphEdges.set(edges); + } + + private buildMeshGraph( + meshNodes: any[], + nodes: HtmlTemplateDynamicNode[], + edges: Edge[] + ): void { + // For LlmAgent/Mesh: nodes array contains coordinator + sub-agents + // Layout: coordinator at top, sub-agents in row below + const coordinatorIndex = meshNodes.findIndex(n => + (n.name === meshNodes[0]?.name) || n.type === 'coordinator' + ); + + const coordinator = coordinatorIndex >= 0 ? meshNodes[coordinatorIndex] : null; + const subAgents = meshNodes.filter((_, i) => i !== coordinatorIndex); + + const startY = 100; + const ySpacing = 200; + const xSpacing = 300; + + // Calculate center X based on number of sub-agents + const totalWidth = (subAgents.length - 1) * xSpacing; + const startX = 400 - totalWidth / 2; + + // Add coordinator node at top center + if (coordinator) { + const hasNested = hasNestedStructure(coordinator); + const coordinatorName = getNodeName(coordinator); + const status = this.getNodeStatusAtLevel(coordinatorName, coordinator); + + nodes.push({ + id: coordinatorName, + type: 'html-template', + point: signal({x: 400, y: startY}), + width: signal(180), + height: signal(80), + data: signal({ + name: coordinatorName, + type: 'agent', + status: status, + hasNestedStructure: hasNested, + nodeData: coordinator, + }), + }); + } + + // Add sub-agent nodes in a row below coordinator + subAgents.forEach((node: any, index: number) => { + const x = startX + (index * xSpacing); + const y = startY + ySpacing; + const hasNested = hasNestedStructure(node); + const nodeName = getNodeName(node); + const status = this.getNodeStatusAtLevel(nodeName, node); + + nodes.push({ + id: nodeName, + type: 'html-template', + point: signal({x, y}), + width: signal(180), + height: signal(80), + data: signal({ + name: nodeName, + type: 'agent', + status: status, + hasNestedStructure: hasNested, + nodeData: node, + }), + }); + + // Add edge: coordinator -> sub-agent + if (coordinator) { + const coordinatorName = getNodeName(coordinator); + const coordinatorStatus = this.getNodeStatusAtLevel(coordinatorName, coordinator); + const isActive = coordinatorStatus === NodeStatus.RUNNING || + (coordinatorStatus === NodeStatus.COMPLETED && + (status === NodeStatus.RUNNING || status === NodeStatus.PENDING)); + + edges.push({ + id: `${coordinatorName}_to_${nodeName}`, + source: coordinatorName, + target: nodeName, + type: 'template', + floating: true, + data: { isActive }, + markers: { + end: { + type: 'arrow-closed', + width: 15, + height: 15, + color: isActive ? '#42A5F5' : 'rgba(138, 180, 248, 0.8)', + }, + }, + }); + } + }); + } + + private buildGraphFromStateOnly(): void { + const nodes: HtmlTemplateDynamicNode[] = []; + const edges: Edge[] = []; + const ySpacing = 120; + const xPosition = 200; + const startY = 50; + + if (!this.nodes) { + this.graphNodes.set(nodes); + this.graphEdges.set(edges); + return; + } + + const nodeNames = Object.keys(this.nodes); + + nodeNames.forEach((nodeName: string, index: number) => { + const nodeState = this.nodes![nodeName]; + + nodes.push({ + id: nodeName, + type: 'html-template', + point: signal({x: xPosition, y: startY + (index * ySpacing)}), + width: signal(180), + height: signal(80), + data: signal({ + name: nodeName, + type: nodeName === '__START__' ? 'start' : 'agent', + status: nodeState.status, + input: nodeState.input, + triggeredBy: nodeState.triggered_by, + retryCount: nodeState.retry_count, + runId: nodeState.run_id, + }), + }); + }); + + // Build edges from triggered_by relationships + nodeNames.forEach((nodeName) => { + const nodeState = this.nodes![nodeName]; + if (nodeState.triggered_by && nodeNames.includes(nodeState.triggered_by)) { + // Check if source node is RUNNING to highlight this edge + const fromNodeState = this.nodes![nodeState.triggered_by]; + const isActive = fromNodeState?.status === NodeStatus.RUNNING; + + edges.push({ + id: `${nodeState.triggered_by}_to_${nodeName}`, + source: nodeState.triggered_by, + target: nodeName, + type: 'template', + floating: true, + data: { isActive }, + markers: { + end: { + type: 'arrow-closed', + width: 15, + height: 15, + color: isActive ? '#42A5F5' : 'rgba(138, 180, 248, 0.8)', + }, + }, + }); + } + }); + + this.graphNodes.set(nodes); + this.graphEdges.set(edges); + } + + getStatusColor(status: NodeStatus): string { + switch (status) { + case NodeStatus.INACTIVE: + return '#757575'; + case NodeStatus.PENDING: + return '#FFA726'; + case NodeStatus.RUNNING: + return '#42A5F5'; + case NodeStatus.COMPLETED: + return '#66BB6A'; + case NodeStatus.INTERRUPTED: + return '#FFCA28'; + case NodeStatus.FAILED: + return '#EF5350'; + default: + return '#757575'; + } + } + + getStatusLabel(status: NodeStatus): string { + switch (status) { + case NodeStatus.INACTIVE: + return 'INACTIVE'; + case NodeStatus.PENDING: + return 'PENDING'; + case NodeStatus.RUNNING: + return 'RUNNING'; + case NodeStatus.COMPLETED: + return 'COMPLETED'; + case NodeStatus.INTERRUPTED: + return 'INTERRUPTED'; + case NodeStatus.FAILED: + return 'FAILED'; + default: + return 'UNKNOWN'; + } + } + + getStatusIcon(status: NodeStatus): string { + switch (status) { + case NodeStatus.INACTIVE: + return 'radio_button_unchecked'; + case NodeStatus.PENDING: + return 'schedule'; + case NodeStatus.RUNNING: + return 'play_circle'; + case NodeStatus.COMPLETED: + return 'check_circle'; + case NodeStatus.INTERRUPTED: + return 'pause_circle'; + case NodeStatus.FAILED: + return 'error'; + default: + return 'help'; + } + } + + private updateBreadcrumbs(): void { + this.breadcrumbs.set(this.navigationStack.map(item => item.name)); + } + + navigateIntoNode(nodeName: string): void { + const currentData = this.navigationStack[this.navigationStack.length - 1].data; + const nodeData = findNodeInLevel(currentData, nodeName); + + if (nodeData && hasNestedStructure(nodeData)) { + this.navigationStack.push({name: nodeName, data: nodeData}); + this.updateBreadcrumbs(); + this.buildGraphFromStructure(nodeData); + } + } + + navigateToLevel(index: number): void { + if (index >= 0 && index < this.navigationStack.length) { + this.navigationStack = this.navigationStack.slice(0, index + 1); + this.updateBreadcrumbs(); + const currentData = this.navigationStack[this.navigationStack.length - 1].data; + this.buildGraphFromStructure(currentData); + } + } + + private navigateToNodePath(nodePath: string): void { + this.navigationStack = buildNavigationStackFromPath(this.agentGraphData.root_agent, nodePath); + } + + private getNodeStatusAtLevel(nodeName: string, nodeData: any): NodeStatus { + // Get the current navigation path + const currentPath = getCurrentPath(this.navigationStack); + + // Only use direct state if we're at the execution level + if (this.nodePath && currentPath === this.nodePath) { + const directState = this.nodes ? this.nodes[nodeName] : null; + if (directState) { + return directState.status; + } + } + + // If we're viewing a higher level than where execution is happening, + // check if this node is in the execution path (should show as RUNNING) + if (this.nodePath && hasNestedStructure(nodeData) && this.isInExecutionPath(nodeName, currentPath)) { + return NodeStatus.RUNNING; + } + + // Check allNodes for historical state at the current path level + // allNodes is now organized by path: { "path1": { "node1": state }, "path2": { ... } } + if (this.allNodes && this.allNodes[currentPath] && this.allNodes[currentPath][nodeName]) { + return this.allNodes[currentPath][nodeName].status; + } + + return NodeStatus.INACTIVE; + } + + private isInExecutionPath(nodeName: string, currentPath: string): boolean { + // If nodePath is not available, we can't determine + if (!this.nodePath) { + return false; + } + + // Check if nodePath starts with currentPath (we're viewing a parent level) + // and if nodeName is the next segment in the execution path + if (this.nodePath.startsWith(currentPath + '/')) { + const remainingPath = this.nodePath.substring(currentPath.length + 1); + const nextSegment = remainingPath.split('/')[0]; + return nodeName === nextSegment; + } + + return false; + } + + // Expose utility functions for use in template + getNodeTypeIcon = getNodeTypeIcon; + getNodeTypeLabel = getNodeTypeLabel; +} diff --git a/src/app/core/models/UiEvent.ts b/src/app/core/models/UiEvent.ts new file mode 100644 index 00000000..76732f4d --- /dev/null +++ b/src/app/core/models/UiEvent.ts @@ -0,0 +1,72 @@ +import { ExecutableCode, CodeExecutionResult, FunctionCall, FunctionResponse, Event } from './types'; +import { MediaType } from '../../components/artifact-tab/artifact-tab.component'; + +export class UiEvent { + role!: 'user' | 'bot' | string; + text?: string; + thought?: boolean; + isLoading?: boolean; + isEditing?: boolean; + evalStatus?: number; + failedMetric?: boolean; + attachments?: { file: File; url: string }[]; + renderedContent?: any; + a2uiData?: any; + executableCode?: ExecutableCode; + codeExecutionResult?: CodeExecutionResult; + event!: Event; + inlineData?: { + mediaType?: MediaType | string; + data: string; + name?: string; + mimeType: string; + displayName?: string; + }; + functionCalls?: FunctionCall[]; + functionResponses?: FunctionResponse[]; + actualInvocationToolUses?: any; + expectedInvocationToolUses?: any; + actualFinalResponse?: string; + expectedFinalResponse?: string; + evalScore?: number; + evalThreshold?: number; + invocationIndex?: number; + finalResponsePartIndex?: number; + error?: { + errorCode?: string; + errorMessage?: string; + }; + + constructor(init?: Partial) { + Object.assign(this, init); + + // clean up empty objects in event.actions + if (this.event?.actions) { + for (const [key, value] of Object.entries(this.event.actions)) { + if (value !== null && typeof value === 'object' && Object.keys(value).length === 0) { + delete (this.event.actions as any)[key]; + } + } + } + } + + get stateDelta(): any { + return this.event?.actions?.stateDelta; + } + + get artifactDelta(): any { + return this.event?.actions?.artifactDelta; + } + + get route(): any { + return this.event?.actions?.route; + } + + get nodePath(): string | null { + return this.event?.nodeInfo?.path || null; + } + + get author(): string { + return this.event?.author ?? 'root_agent'; + } +} diff --git a/src/app/core/models/types.ts b/src/app/core/models/types.ts index 411fa074..ebf4e964 100644 --- a/src/app/core/models/types.ts +++ b/src/app/core/models/types.ts @@ -27,6 +27,7 @@ export declare interface FunctionCall { id?: string; name: string; args: {[key: string]: any}; + needsResponse?: boolean; } export declare interface FunctionResponse { @@ -78,6 +79,31 @@ export declare interface LlmResponse { longRunningToolIds?: string[]; } +export enum NodeStatus { + INACTIVE = 0, + PENDING = 1, + RUNNING = 2, + COMPLETED = 3, + INTERRUPTED = 4, + FAILED = 5 +} + +export declare interface NodeState { + status: NodeStatus; + input?: any; + triggered_by?: string; + retry_count?: number; + interrupts?: string[]; + resume_inputs?: {[key: string]: any}; + run_id?: string; + parent_run_id?: string; + source_node_name?: string; +} + +export declare interface AgentState { + nodes?: {[key: string]: NodeState}; +} + export declare interface EventActions { message?: string; artifactDelta?: any; @@ -85,16 +111,27 @@ export declare interface EventActions { functionCall?: FunctionCall; functionResponse?: FunctionResponse; finishReason?: string; + agentState?: AgentState; + endOfAgent?: boolean; + route?: any; } export declare interface Event extends LlmResponse { - id?: string; + id: string; author?: string invocationId?: string; actions?: EventActions; longRunningToolIds?: string[]; branch?: string; timestamp?: number; + nodeInfo?: { path?: string;[key: string]: any; }; + data?: any; + output?: { result?: any; }; + inputTranscription?: { text: string; }; + outputTranscription?: { text: string; }; + usageMetadata?: any; + interrupted?: boolean; + turnComplete?: boolean; } export interface ComputerUsePayload { diff --git a/src/app/core/services/agent.service.ts b/src/app/core/services/agent.service.ts index d0a3cd10..3dfe84fb 100644 --- a/src/app/core/services/agent.service.ts +++ b/src/app/core/services/agent.service.ts @@ -15,13 +15,13 @@ * limitations under the License. */ -import {HttpClient} from '@angular/common/http'; -import {Injectable, NgZone} from '@angular/core'; -import {BehaviorSubject, Observable, of} from 'rxjs'; -import {URLUtil} from '../../../utils/url-util'; -import {AgentRunRequest} from '../models/AgentRunRequest'; -import {LlmResponse} from '../models/types'; -import {AgentService as AgentServiceInterface} from './interfaces/agent'; +import { HttpClient } from '@angular/common/http'; +import { Injectable, NgZone } from '@angular/core'; +import { BehaviorSubject, Observable, of } from 'rxjs'; +import { URLUtil } from '../../../utils/url-util'; +import { AgentRunRequest } from '../models/AgentRunRequest'; +import { LlmResponse } from '../models/types'; +import { AgentService as AgentServiceInterface } from './interfaces/agent'; @Injectable({ providedIn: 'root', @@ -35,7 +35,7 @@ export class AgentService implements AgentServiceInterface { constructor( private http: HttpClient, private zone: NgZone, - ) {} + ) { } getApp(): Observable { return this.currentApp; @@ -69,35 +69,35 @@ export class AgentService implements AgentServiceInterface { const read = () => { reader?.read() - .then(({done, value}) => { - this.isLoading.next(true); - if (done) { - this.isLoading.next(false); - return observer.complete(); + .then(({ done, value }) => { + this.isLoading.next(true); + if (done) { + this.isLoading.next(false); + return observer.complete(); + } + const chunk = decoder.decode(value, { stream: true }); + lastData += chunk; + try { + const lines = lastData.split(/\r?\n/).filter( + (line) => line.startsWith('data:')); + lines.forEach((line) => { + const data = line.replace(/^data:\s*/, ''); + const llmResponse = JSON.parse(data) as LlmResponse; + self.zone.run(() => observer.next(llmResponse)); + }); + lastData = ''; + } catch (e) { + // the data is not a valid json, it could be an incomplete + // chunk. we ignore it and wait for the next chunk. + if (e instanceof SyntaxError) { + read(); } - const chunk = decoder.decode(value, {stream: true}); - lastData += chunk; - try { - const lines = lastData.split(/\r?\n/).filter( - (line) => line.startsWith('data:')); - lines.forEach((line) => { - const data = line.replace(/^data:\s*/, ''); - const llmResponse = JSON.parse(data) as LlmResponse; - self.zone.run(() => observer.next(llmResponse)); - }); - lastData = ''; - } catch (e) { - // the data is not a valid json, it could be an incomplete - // chunk. we ignore it and wait for the next chunk. - if (e instanceof SyntaxError) { - read(); - } - } - read(); // Read the next chunk - }) - .catch((err) => { - self.zone.run(() => observer.error(err)); - }); + } + read(); // Read the next chunk + }) + .catch((err) => { + self.zone.run(() => observer.error(err)); + }); }; read(); @@ -136,7 +136,7 @@ export class AgentService implements AgentServiceInterface { getAgentBuilder(agentName: string) { if (this.apiServerDomain != undefined) { - const url = + const url = this.apiServerDomain + `/builder/app/${agentName}?ts=${Date.now()}` return this.http.get(url, { responseType: 'text' @@ -147,7 +147,7 @@ export class AgentService implements AgentServiceInterface { getAgentBuilderTmp(agentName: string) { if (this.apiServerDomain != undefined) { - const url = + const url = this.apiServerDomain + `/builder/app/${agentName}?ts=${Date.now()}&tmp=true` return this.http.get(url, { responseType: 'text' @@ -158,7 +158,7 @@ export class AgentService implements AgentServiceInterface { getSubAgentBuilder(appName: string, relativePath: string) { if (this.apiServerDomain != undefined) { - let url = + let url = this.apiServerDomain + `/builder/app/${appName}?ts=${Date.now()}&file_path=${relativePath}&tmp=true` return this.http.get(url, { responseType: 'text' @@ -169,7 +169,7 @@ export class AgentService implements AgentServiceInterface { agentChangeCancel(appName: string) { if (this.apiServerDomain != undefined) { - let url = + let url = this.apiServerDomain + `/builder/app/${appName}/cancel` return this.http.post(url, {}); } @@ -183,4 +183,25 @@ export class AgentService implements AgentServiceInterface { } return new Observable<''>(); } + + getAppGraphImage(appName: string, darkMode: boolean, node?: string): Observable { + if (this.apiServerDomain != undefined) { + const url = this.apiServerDomain + `/dev/build_graph_image/${appName}`; + const params: any = { dark_mode: darkMode }; + if (node) { + params.node = node; + } + return this.http.get(url, { params }); + } + return new Observable(); + } + + getAppGraphDot(appName: string, darkMode: boolean): Observable<{ dotSrc?: string }> { + if (this.apiServerDomain != undefined) { + const url = this.apiServerDomain + `/dev/${appName}/graph`; + const params = { dark_mode: darkMode }; + return this.http.get<{ dotSrc?: string }>(url, { params }); + } + return new Observable<{ dotSrc?: string }>(); + } } diff --git a/src/app/core/services/audio-recording.service.ts b/src/app/core/services/audio-recording.service.ts index 06833f79..e02ef0e3 100644 --- a/src/app/core/services/audio-recording.service.ts +++ b/src/app/core/services/audio-recording.service.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import {inject, Injectable} from '@angular/core'; +import {inject, Injectable, signal, WritableSignal} from '@angular/core'; import {AUDIO_WORKLET_MODULE_PATH, AudioRecordingService as AudioRecordingServiceInterface} from './interfaces/audio-recording'; @@ -29,6 +29,8 @@ export class AudioRecordingService implements AudioRecordingServiceInterface { private audioContext!: AudioContext; private source!: MediaStreamAudioSourceNode; private audioBuffer: Uint8Array[] = []; + volumeLevel: WritableSignal = signal(0); + private lastVolumeUpdate: number = 0; async startRecording() { try { @@ -46,6 +48,19 @@ export class AudioRecordingService implements AudioRecordingServiceInterface { workletNode.port.onmessage = (event) => { const audioData = event.data; + + const now = Date.now(); + if (now - this.lastVolumeUpdate > 100) { + let sumSquares = 0.0; + for (let i = 0; i < audioData.length; i++) { + sumSquares += audioData[i] * audioData[i]; + } + const rms = Math.sqrt(sumSquares / audioData.length); + const volume = Math.min(1, Math.max(0, rms * 15)); + this.volumeLevel.set(volume); + this.lastVolumeUpdate = now; + } + const pcmBlob = this.float32ToPCM(audioData); this.audioBuffer.push(pcmBlob); }; @@ -67,6 +82,7 @@ export class AudioRecordingService implements AudioRecordingServiceInterface { if (this.stream) { this.stream.getTracks().forEach((track) => track.stop()); } + this.volumeLevel.set(0); } getCombinedAudioBuffer(): Uint8Array|void { diff --git a/src/app/core/services/feature-flag.service.spec.ts b/src/app/core/services/feature-flag.service.spec.ts index 2ed802ea..69309d24 100644 --- a/src/app/core/services/feature-flag.service.spec.ts +++ b/src/app/core/services/feature-flag.service.spec.ts @@ -58,12 +58,12 @@ describe('FeatureFlagService', () => { expect(isEnabled).toBeTrue(); }); - it('should return false if \'import_session\' query param is not \'true\'', + it('should return true (always enabled in this implementation)', async () => { activatedRoute.queryParams.next({}); const isEnabled = await firstValueFrom(service.isImportSessionEnabled()); - expect(isEnabled).toBeFalse(); + expect(isEnabled).toBeTrue(); }); }); diff --git a/src/app/core/services/feature-flag.service.ts b/src/app/core/services/feature-flag.service.ts index 3bb04db4..cf41ebeb 100644 --- a/src/app/core/services/feature-flag.service.ts +++ b/src/app/core/services/feature-flag.service.ts @@ -15,11 +15,11 @@ * limitations under the License. */ -import {inject, Injectable} from '@angular/core'; -import {ActivatedRoute} from '@angular/router'; -import {Observable, of, pipe} from 'rxjs'; -import {map} from 'rxjs/operators'; -import {A2A_CARD, EDIT_FUNCTION_ARGS, FeatureFlagService as FeatureFlagServiceInterface, IMPORT_SESSION, SESSION_URL} from './interfaces/feature-flag'; +import { inject, Injectable } from '@angular/core'; +import { ActivatedRoute } from '@angular/router'; +import { Observable, of, pipe } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { A2A_CARD, EDIT_FUNCTION_ARGS, FeatureFlagService as FeatureFlagServiceInterface, IMPORT_SESSION, SESSION_URL } from './interfaces/feature-flag'; @Injectable({ providedIn: 'root' @@ -27,17 +27,15 @@ import {A2A_CARD, EDIT_FUNCTION_ARGS, FeatureFlagService as FeatureFlagServiceIn export class FeatureFlagService implements FeatureFlagServiceInterface { private route = inject(ActivatedRoute); - constructor() {} + constructor() { } isImportSessionEnabled(): Observable { - return this.route.queryParams.pipe( - map((params) => params[IMPORT_SESSION] === 'true'), - ); + return of(true); } isEditFunctionArgsEnabled(): Observable { return this.route.queryParams.pipe( - map((params) => params[EDIT_FUNCTION_ARGS] === 'true'), + map((params) => params[EDIT_FUNCTION_ARGS] === 'true'), ); } @@ -47,7 +45,7 @@ export class FeatureFlagService implements FeatureFlagServiceInterface { isA2ACardEnabled(): Observable { return this.route.queryParams.pipe( - map((params) => params[A2A_CARD] === 'true'), + map((params) => params[A2A_CARD] === 'true'), ); } diff --git a/src/app/core/services/interfaces/agent.ts b/src/app/core/services/interfaces/agent.ts index 0c5dd710..ddd8d018 100644 --- a/src/app/core/services/interfaces/agent.ts +++ b/src/app/core/services/interfaces/agent.ts @@ -31,6 +31,9 @@ export abstract class AgentService { abstract getLoadingState(): BehaviorSubject; abstract runSse(req: AgentRunRequest): Observable; abstract listApps(): Observable; + abstract getAppInfo(name: string): Observable; + abstract getAppGraphImage(name: string, darkMode: boolean, node?: string): Observable; + abstract getAppGraphDot(name: string, darkMode: boolean): Observable<{dotSrc?: string}>; getAgentBuilderTmp(agentName: string): Observable { console.warn('unimplemented'); return of(''); @@ -55,8 +58,4 @@ export abstract class AgentService { console.warn('unimplemented'); return of(false); } - getAppInfo(appName: string): Observable { - console.warn('unimplemented'); - return of(''); - } } diff --git a/src/app/core/services/interfaces/audio-recording.ts b/src/app/core/services/interfaces/audio-recording.ts index 6a992451..00cee982 100644 --- a/src/app/core/services/interfaces/audio-recording.ts +++ b/src/app/core/services/interfaces/audio-recording.ts @@ -26,6 +26,7 @@ export const AUDIO_WORKLET_MODULE_PATH = * Service to provide methods to handle audio recording. */ export declare abstract class AudioRecordingService { + abstract volumeLevel: import('@angular/core').WritableSignal; abstract startRecording(): Promise; abstract stopRecording(): void; abstract getCombinedAudioBuffer(): Uint8Array|void; diff --git a/src/app/core/services/interfaces/session.ts b/src/app/core/services/interfaces/session.ts index 9ef00ad9..2fdbbc91 100644 --- a/src/app/core/services/interfaces/session.ts +++ b/src/app/core/services/interfaces/session.ts @@ -18,7 +18,7 @@ import {InjectionToken} from '@angular/core'; import {Observable} from 'rxjs'; -import {Session} from '../../models/Session'; +import {Session, SessionState} from '../../models/Session'; export const SESSION_SERVICE = new InjectionToken('SessionService'); @@ -31,7 +31,8 @@ export {type ListParams, type ListResponse} from './types'; * Service to provide methods to handle sessions. */ export declare abstract class SessionService { - abstract createSession(userId: string, appName: string): Observable; + abstract createSession(userId: string, appName: string, state?: SessionState): Observable; + abstract updateSession(userId: string, appName: string, sessionId: string, session: any): Observable; abstract listSessions( userId: string, appName: string, @@ -51,6 +52,7 @@ export declare abstract class SessionService { userId: string, appName: string, events: any[], + state?: SessionState, ): Observable; abstract canEdit(userId: string, session: Session): Observable; } diff --git a/src/app/core/services/interfaces/stream-chat.ts b/src/app/core/services/interfaces/stream-chat.ts index 4237e185..a3530942 100644 --- a/src/app/core/services/interfaces/stream-chat.ts +++ b/src/app/core/services/interfaces/stream-chat.ts @@ -38,6 +38,8 @@ export declare abstract class StreamChatService { videoContainer: ElementRef; }): Promise; abstract stopVideoChat(videoContainer: ElementRef): void; + abstract startVideoStreaming(videoContainer: ElementRef): Promise; + abstract stopVideoStreaming(videoContainer: ElementRef): void; abstract onStreamClose(): Observable; abstract closeStream(): void; } diff --git a/src/app/core/services/interfaces/string-to-color.ts b/src/app/core/services/interfaces/string-to-color.ts index 0049527f..22e388c8 100644 --- a/src/app/core/services/interfaces/string-to-color.ts +++ b/src/app/core/services/interfaces/string-to-color.ts @@ -28,5 +28,5 @@ export interface StringToColorService { /** * Converts a string to a color, e.g. 'my string' -> '#8c8526ff'. */ - stc(str: string): string; + stc(str: string, theme?: string): string; } diff --git a/src/app/core/services/interfaces/trace.ts b/src/app/core/services/interfaces/trace.ts index 75b3f263..59edb308 100644 --- a/src/app/core/services/interfaces/trace.ts +++ b/src/app/core/services/interfaces/trace.ts @@ -27,12 +27,10 @@ export const TRACE_SERVICE = new InjectionToken('TraceService'); export declare abstract class TraceService { abstract selectedTraceRow$: Observable; abstract eventData$: Observable | undefined>; - abstract hoveredMessageIndices$: Observable; abstract messages$: Observable; abstract selectedRow(span: Span | undefined): void; abstract setEventData(data: Map | undefined): void; abstract setMessages(messages: any[]): void; - abstract setHoveredMessages(span: Span | undefined, invocationId: string): void; abstract resetTraceService(): void; } diff --git a/src/app/core/services/session.service.spec.ts b/src/app/core/services/session.service.spec.ts index 2800d0b0..f4dc49e0 100644 --- a/src/app/core/services/session.service.spec.ts +++ b/src/app/core/services/session.service.spec.ts @@ -139,9 +139,8 @@ describe('SessionService', () => { API_SERVER_BASE_URL + SESSIONS_PATH, ); expect(req.request.method).toEqual(METHOD_POST); + // appName and userId are now in the URL, not the body expect(req.request.body).toEqual({ - appName: APP_NAME, - userId: USER_ID, events, }); req.flush({}); diff --git a/src/app/core/services/session.service.ts b/src/app/core/services/session.service.ts index e0631f0c..41bf8cfd 100644 --- a/src/app/core/services/session.service.ts +++ b/src/app/core/services/session.service.ts @@ -21,7 +21,7 @@ import {Observable, of} from 'rxjs'; import {map} from 'rxjs/operators'; import {URLUtil} from '../../../utils/url-util'; -import {Session} from '../models/Session'; +import {Session, SessionState} from '../models/Session'; import {ListResponse, SessionService as SessionServiceInterface} from './interfaces/session'; @@ -32,15 +32,25 @@ export class SessionService implements SessionServiceInterface { apiServerDomain = URLUtil.getApiServerBaseUrl(); constructor(private http: HttpClient) {} - createSession(userId: string, appName: string) { + createSession(userId: string, appName: string, state?: SessionState) { if (this.apiServerDomain != undefined) { const url = this.apiServerDomain + `/apps/${appName}/users/${userId}/sessions`; - return this.http.post(url, null); + const body: any = {}; + if (state) body.state = state; + else body.state = {}; + return this.http.post(url, state ? body : null); } return new Observable(); } + updateSession(userId: string, appName: string, sessionId: string, session: any) { + const url = this.apiServerDomain + + `/apps/${appName}/users/${userId}/sessions/${sessionId}`; + + return this.http.patch(url, session); + } + listSessions(userId: string, appName: string): Observable> { if (this.apiServerDomain != undefined) { @@ -74,16 +84,16 @@ export class SessionService implements SessionServiceInterface { return this.http.get(url); } - importSession(userId: string, appName: string, events: any[]) { + importSession(userId: string, appName: string, events: any[], state?: SessionState) { if (this.apiServerDomain != undefined) { const url = this.apiServerDomain + `/apps/${appName}/users/${userId}/sessions`; - return this.http.post(url, { - appName: appName, - userId: userId, - events: events, - }); + const body: {events: any[]; state?: SessionState} = {events}; + if (state) { + body.state = state; + } + return this.http.post(url, body); } return new Observable(); diff --git a/src/app/core/services/stream-chat.service.ts b/src/app/core/services/stream-chat.service.ts index e01aaf08..984fb56c 100644 --- a/src/app/core/services/stream-chat.service.ts +++ b/src/app/core/services/stream-chat.service.ts @@ -64,7 +64,7 @@ export class StreamChatService implements StreamChatServiceInterface { private async startAudioStreaming() { try { await this.audioRecordingService.startRecording(); - this.audioIntervalId = setInterval(() => this.sendBufferedAudio(), 250); + this.audioIntervalId = window.setInterval(() => this.sendBufferedAudio(), 250); } catch (error) { console.error('Error accessing microphone:', error); } @@ -115,10 +115,10 @@ export class StreamChatService implements StreamChatServiceInterface { this.webSocketService.closeConnection(); } - private async startVideoStreaming(videoContainer: ElementRef) { + async startVideoStreaming(videoContainer: ElementRef) { try { await this.videoService.startRecording(videoContainer); - this.videoIntervalId = setInterval( + this.videoIntervalId = window.setInterval( async () => await this.sendCapturedFrame(), 1000, ); @@ -140,7 +140,7 @@ export class StreamChatService implements StreamChatServiceInterface { this.webSocketService.sendMessage(request); } - private stopVideoStreaming(videoContainer: ElementRef) { + stopVideoStreaming(videoContainer: ElementRef) { clearInterval(this.videoIntervalId); this.videoIntervalId = undefined; this.videoService.stopRecording(videoContainer); diff --git a/src/app/core/services/string-to-color.service.ts b/src/app/core/services/string-to-color.service.ts index 3ce9f2e7..8afbe8f2 100644 --- a/src/app/core/services/string-to-color.service.ts +++ b/src/app/core/services/string-to-color.service.ts @@ -15,9 +15,8 @@ * limitations under the License. */ -import {Injectable, InjectionToken} from '@angular/core'; -import stc from 'string-to-color'; -import {StringToColorService as StringToColorServiceInterface} from './interfaces/string-to-color'; +import { Injectable, InjectionToken } from '@angular/core'; +import { StringToColorService as StringToColorServiceInterface } from './interfaces/string-to-color'; /** * Service to convert a string to a color. @@ -29,7 +28,46 @@ export class StringToColorServiceImpl implements StringToColorServiceInterface { /** * Converts a string to a color, e.g. 'my string' -> '#8c8526ff'. */ - stc(str: string): string { - return stc(str); + stc(str: string, theme?: string): string { + const hash = this.hashCode(str); + const h = Math.abs(hash % 360); + const s = 60 + Math.abs((hash >> 8) % 40); + + let l; + if (theme === 'dark') { + l = 15 + Math.abs((hash >> 16) % 30); + } else { + l = 40 + Math.abs((hash >> 16) % 30); + } + + return this.hslToHex(h, s, l); + } + + /** + * Simple hash function to generate a numeric hash from a string with good distribution + */ + private hashCode(str: string): number { + let hash = 0; + for (let i = 0, len = str.length; i < len; i++) { + let chr = str.charCodeAt(i); + hash = (hash << 5) - hash + chr; + hash = hash + i * 31; + hash |= 0; + } + return hash; + } + + /** + * Converts HSL values to a HEX color string + */ + private hslToHex(h: number, s: number, l: number): string { + l /= 100; + const a = s * Math.min(l, 1 - l) / 100; + const f = (n: number) => { + const k = (n + h / 30) % 12; + const color = l - a * Math.max(Math.min(k - 3, 9 - k, 1), -1); + return Math.round(255 * color).toString(16).padStart(2, '0'); + }; + return `#${f(0)}${f(8)}${f(4)}ff`; } } diff --git a/src/app/core/services/testing/mock-agent.service.ts b/src/app/core/services/testing/mock-agent.service.ts index 9e7951b7..cf4ae301 100644 --- a/src/app/core/services/testing/mock-agent.service.ts +++ b/src/app/core/services/testing/mock-agent.service.ts @@ -57,4 +57,12 @@ export class MockAgentService implements Partial { getAppInfoResponse = new ReplaySubject(1); getAppInfo = jasmine.createSpy('getAppInfo').and.returnValue(this.getAppInfoResponse); + + getAppGraphImageResponse = new ReplaySubject(1); + getAppGraphImage = jasmine.createSpy('getAppGraphImage') + .and.returnValue(this.getAppGraphImageResponse); + + getAppGraphDotResponse = new ReplaySubject<{dotSrc?: string}>(1); + getAppGraphDot = jasmine.createSpy('getAppGraphDot') + .and.returnValue(this.getAppGraphDotResponse); } diff --git a/src/app/core/services/testing/mock-audio-recording.service.ts b/src/app/core/services/testing/mock-audio-recording.service.ts index 85cd1ac9..4b17b88b 100644 --- a/src/app/core/services/testing/mock-audio-recording.service.ts +++ b/src/app/core/services/testing/mock-audio-recording.service.ts @@ -15,13 +15,14 @@ * limitations under the License. */ -import {Injectable} from '@angular/core'; +import {Injectable, signal} from '@angular/core'; import {AudioRecordingService} from '../audio-recording.service'; @Injectable() export class MockAudioRecordingService implements Partial { + volumeLevel = signal(0); startRecording = jasmine.createSpy('startRecording'); stopRecording = jasmine.createSpy('stopRecording'); getCombinedAudioBuffer = jasmine.createSpy('getCombinedAudioBuffer'); diff --git a/src/app/core/services/testing/mock-stream-chat.service.ts b/src/app/core/services/testing/mock-stream-chat.service.ts index 086152da..34aca607 100644 --- a/src/app/core/services/testing/mock-stream-chat.service.ts +++ b/src/app/core/services/testing/mock-stream-chat.service.ts @@ -26,6 +26,8 @@ export class MockStreamChatService implements Partial { stopAudioChat = jasmine.createSpy('stopAudioChat'); startVideoChat = jasmine.createSpy('startVideoChat'); stopVideoChat = jasmine.createSpy('stopVideoChat'); + startVideoStreaming = jasmine.createSpy('startVideoStreaming'); + stopVideoStreaming = jasmine.createSpy('stopVideoStreaming'); closeStream = jasmine.createSpy('closeStream'); onStreamCloseResponse = new ReplaySubject(1); onStreamClose = jasmine.createSpy('onStreamClose') diff --git a/src/app/core/services/theme.service.ts b/src/app/core/services/theme.service.ts index 8f3455d7..f9712ce0 100644 --- a/src/app/core/services/theme.service.ts +++ b/src/app/core/services/theme.service.ts @@ -59,6 +59,21 @@ export class ThemeService implements ThemeServiceInterface { // Save to localStorage window.localStorage.setItem(this.THEME_STORAGE_KEY, theme); + + // Update Prism theme + this.updatePrismTheme(theme); + } + + private updatePrismTheme(theme: Theme): void { + const linkId = 'prism-theme-style'; + let linkElement = document.getElementById(linkId) as HTMLLinkElement; + if (!linkElement) { + linkElement = document.createElement('link'); + linkElement.id = linkId; + linkElement.rel = 'stylesheet'; + document.head.appendChild(linkElement); + } + linkElement.href = theme === 'light' ? 'prism-light.css' : 'prism-dark.css'; } toggleTheme(): void { diff --git a/src/app/core/services/trace.service.spec.ts b/src/app/core/services/trace.service.spec.ts index 3bf50465..a8e55445 100644 --- a/src/app/core/services/trace.service.spec.ts +++ b/src/app/core/services/trace.service.spec.ts @@ -74,75 +74,6 @@ describe('TraceService', () => { }); }); - describe('setHoveredMessages', () => { - it('should set hovered indices to empty if span is undefined', async () => { - service.setHoveredMessages(undefined, 'inv1'); - const indices = await firstValueFrom(service.hoveredMessageIndices$); - expect(indices).toEqual([]); - }); - - it('should set hovered indices for span with specific event_id', - async () => { - const messages = [ - {role: 'user', eventId: 'e0'}, - {role: 'bot', eventId: 'e1'}, - {role: 'bot', eventId: 'e2'}, - ]; - const eventData = new Map([ - ['e1', {invocationId: 'inv1'}], - ['e2', {invocationId: 'inv1'}], - ]); - const span: Partial = { - attributes: {'gcp.vertex.agent.event_id': 'e1'}, - }; - service.setMessages(messages); - service.setEventData(eventData); - service.setHoveredMessages(span as Span, 'inv1'); - const indices = await firstValueFrom(service.hoveredMessageIndices$); - expect(indices).toEqual([1]); - }); - - it('should set hovered indices for span without event_id but matching invocationId', - async () => { - const messages = [ - {role: 'user', eventId: 'e0'}, - {role: 'bot', eventId: 'e1'}, - {role: 'bot', eventId: 'e2'}, - ]; - const eventData = new Map([ - ['e1', {invocationId: 'inv1'}], - ['e2', {invocationId: 'inv2'}], - ]); - const span: Partial = { - attributes: {}, - }; - service.setMessages(messages); - service.setEventData(eventData); - service.setHoveredMessages(span as Span, 'inv1'); - const indices = await firstValueFrom(service.hoveredMessageIndices$); - expect(indices).toEqual([1]); - }); - - it( - 'should safely handle messages with missing event data', async () => { - const messages = [ - {role: 'bot', eventId: 'e1'}, - {role: 'bot', eventId: 'e2'}, // e2 is missing in eventData - ]; - const eventData = new Map([ - ['e1', {invocationId: 'inv1'}], - ]); - const span: Partial = { - attributes: {'gcp.vertex.agent.event_id': 'e1'}, - }; - service.setMessages(messages); - service.setEventData(eventData); - service.setHoveredMessages(span as Span, 'inv1'); - const indices = await firstValueFrom(service.hoveredMessageIndices$); - expect(indices).toEqual([0]); - }); - }); - describe('resetTraceService', () => { it('should reset eventData to undefined', async () => { service.setEventData(new Map()); @@ -157,12 +88,5 @@ describe('TraceService', () => { const messages = await firstValueFrom(service.messages$); expect(messages).toEqual([]); }); - - it('should reset hoveredMessageIndices to empty array', async () => { - service.setHoveredMessages({} as Span, 'inv1'); - service.resetTraceService(); - const indices = await firstValueFrom(service.hoveredMessageIndices$); - expect(indices).toEqual([]); - }); }); }); diff --git a/src/app/core/services/trace.service.ts b/src/app/core/services/trace.service.ts index 2612a5c7..5c267bb1 100644 --- a/src/app/core/services/trace.service.ts +++ b/src/app/core/services/trace.service.ts @@ -32,9 +32,6 @@ export class TraceService implements TraceServiceInterface { new BehaviorSubject|undefined>(undefined); eventData$ = this.eventDataSource.asObservable(); - private hoveredMessageIndicesSource = new BehaviorSubject([]); - hoveredMessageIndices$ = this.hoveredMessageIndicesSource.asObservable(); - private messagesSource = new BehaviorSubject([]); messages$ = this.messagesSource.asObservable(); @@ -50,37 +47,10 @@ export class TraceService implements TraceServiceInterface { this.messagesSource.next(messages); } - setHoveredMessages(span: Span|undefined, invocationId: string) { - if (!span) { - this.hoveredMessageIndicesSource.next([]); - return; - } - - const attributes = span.attributes; - const hasEvent: boolean = - attributes && attributes['gcp.vertex.agent.event_id']; - const messageIndices = []; - for (const [index, msg] of this.messagesSource.value.entries()) { - if (msg.role === 'user') { - continue; - } - - if (this.eventDataSource.value?.get(msg.eventId)?.invocationId !== - invocationId) { - continue; - } - - if (!hasEvent || - attributes['gcp.vertex.agent.event_id'] === msg.eventId) { - messageIndices.push(index); - } - } - this.hoveredMessageIndicesSource.next(messageIndices); - } - resetTraceService() { + this.selectedTraceRowSource.next(undefined); this.eventDataSource.next(undefined); this.messagesSource.next([]); - this.hoveredMessageIndicesSource.next([]); + } } diff --git a/src/app/core/services/websocket.service.ts b/src/app/core/services/websocket.service.ts index 13a8a879..4aa984e3 100644 --- a/src/app/core/services/websocket.service.ts +++ b/src/app/core/services/websocket.service.ts @@ -51,7 +51,7 @@ export class WebSocketService implements WebSocketServiceInterface { this.socket$.subscribe( (message) => { - this.handleIncomingAudio(message), this.messages$.next(message); + this.handleIncomingEvent(message); }, (error) => { console.error('WebSocket error:', error); @@ -96,7 +96,7 @@ export class WebSocketService implements WebSocketServiceInterface { return btoa(binary); } - private handleIncomingAudio(message: any) { + private handleIncomingEvent(message: any) { const msg = JSON.parse(message) as Event; if ( msg['content'] && @@ -107,6 +107,8 @@ export class WebSocketService implements WebSocketServiceInterface { msg['content']['parts'][0]['inlineData']['data'], ); this.audioBuffer.push(pcmBytes); + } else { + this.messages$.next(message); } } diff --git a/src/app/directives/html-tooltip.directive.ts b/src/app/directives/html-tooltip.directive.ts index 66199fdc..473562f5 100644 --- a/src/app/directives/html-tooltip.directive.ts +++ b/src/app/directives/html-tooltip.directive.ts @@ -16,8 +16,8 @@ */ import {Overlay, OverlayRef} from '@angular/cdk/overlay'; -import {ComponentPortal} from '@angular/cdk/portal'; -import {Directive, ElementRef, HostListener, inject, Input, OnDestroy} from '@angular/core'; +import {ComponentPortal, TemplatePortal} from '@angular/cdk/portal'; +import {Directive, ElementRef, HostListener, inject, Input, OnDestroy, TemplateRef, ViewContainerRef} from '@angular/core'; import {JsonTooltipComponent} from '../components/json-tooltip/json-tooltip.component'; @Directive({ @@ -25,7 +25,8 @@ import {JsonTooltipComponent} from '../components/json-tooltip/json-tooltip.comp standalone: true, }) export class JsonTooltipDirective implements OnDestroy { - @Input('appJsonTooltip') json: string = ''; + @Input('appJsonTooltip') json: any = ''; + @Input('appJsonTooltipTitle') title: string = ''; private overlayRef: OverlayRef | null = null; private readonly overlay = inject(Overlay); @@ -37,23 +38,130 @@ export class JsonTooltipDirective implements OnDestroy { const positionStrategy = this.overlay.position() .flexibleConnectedTo(this.elementRef) - .withPositions([{ - originX: 'center', - originY: 'top', - overlayX: 'center', - overlayY: 'bottom', - offsetY: -8, - }]); + .withPositions([ + { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -8, + }, + { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: 8, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -8, + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + offsetY: -8, + } + ]) + .withViewportMargin(16) + .withPush(false); this.overlayRef = this.overlay.create({ positionStrategy, scrollStrategy: this.overlay.scrollStrategies.close(), panelClass: 'json-tooltip-panel', + maxWidth: '90vw', }); const tooltipPortal = new ComponentPortal(JsonTooltipComponent); const tooltipRef = this.overlayRef.attach(tooltipPortal); tooltipRef.instance.json = this.json; + tooltipRef.instance.title = this.title; + tooltipRef.changeDetectorRef.detectChanges(); + this.overlayRef.updatePosition(); + } + + @HostListener('mouseleave') + hide() { + if (this.overlayRef) { + this.overlayRef.dispose(); + this.overlayRef = null; + } + } + + ngOnDestroy() { + this.hide(); + } +} + + +@Directive({ + selector: '[appHtmlTooltip]', + standalone: true, +}) +export class HtmlTooltipDirective implements OnDestroy { + @Input('appHtmlTooltip') tooltipTemplate!: TemplateRef; + @Input('appHtmlTooltipContext') context: any = {}; + @Input('appHtmlTooltipDisabled') disabled: boolean = false; + + private overlayRef: OverlayRef | null = null; + private readonly overlay = inject(Overlay); + private readonly elementRef = inject(ElementRef); + private readonly viewContainerRef = inject(ViewContainerRef); + + @HostListener('mouseenter') + show() { + if (this.disabled || !this.tooltipTemplate) return; + + const positionStrategy = this.overlay.position() + .flexibleConnectedTo(this.elementRef) + .withPositions([ + { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -8, + }, + { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: 8, + }, + { + originX: 'start', + originY: 'top', + overlayX: 'start', + overlayY: 'bottom', + offsetY: -8, + }, + { + originX: 'end', + originY: 'top', + overlayX: 'end', + overlayY: 'bottom', + offsetY: -8, + } + ]) + .withViewportMargin(16) + .withPush(false); + + this.overlayRef = this.overlay.create({ + positionStrategy, + scrollStrategy: this.overlay.scrollStrategies.close(), + panelClass: 'html-tooltip-panel', + maxWidth: '90vw', + }); + + const portal = new TemplatePortal(this.tooltipTemplate, this.viewContainerRef, this.context); + this.overlayRef.attach(portal); } @HostListener('mouseleave') diff --git a/src/app/directives/resizable-drawer.directive.spec.ts b/src/app/directives/resizable-drawer.directive.spec.ts index 2b4fffc4..62f8cf48 100644 --- a/src/app/directives/resizable-drawer.directive.spec.ts +++ b/src/app/directives/resizable-drawer.directive.spec.ts @@ -24,14 +24,14 @@ import {ResizableDrawerDirective} from './resizable-drawer.directive'; // Directive constants const SIDE_DRAWER_WIDTH_VAR = '--side-drawer-width'; const INITIAL_WIDTH = 570; -const MIN_WIDTH = 310; +const MIN_WIDTH = 360; // Test constants const MOCKED_WINDOW_WIDTH = 2000; const MAX_WIDTH = MOCKED_WINDOW_WIDTH / 2; // 1000 @Component({ - changeDetection: ChangeDetectionStrategy.Eager, + changeDetection: ChangeDetectionStrategy.Default, template: `
Drawer
diff --git a/src/app/directives/resizable-drawer.directive.ts b/src/app/directives/resizable-drawer.directive.ts index 31689de0..a5ac3efd 100644 --- a/src/app/directives/resizable-drawer.directive.ts +++ b/src/app/directives/resizable-drawer.directive.ts @@ -25,7 +25,7 @@ interface ResizingEvent { @Directive({ selector: '[appResizableDrawer]', }) export class ResizableDrawerDirective implements AfterViewInit { - private readonly sideDrawerMinWidth = 310; + private readonly sideDrawerMinWidth = 360; private sideDrawerMaxWidth = window.innerWidth / 2; private resizeHandle: HTMLElement|null = null; diff --git a/src/app/directives/workflow-graph-tooltip.directive.ts b/src/app/directives/workflow-graph-tooltip.directive.ts new file mode 100644 index 00000000..7ff481a2 --- /dev/null +++ b/src/app/directives/workflow-graph-tooltip.directive.ts @@ -0,0 +1,163 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + Directive, + Input, + HostListener, + ComponentRef, + ViewContainerRef, + inject, + OnDestroy, +} from '@angular/core'; +import {Overlay, OverlayRef, OverlayPositionBuilder} from '@angular/cdk/overlay'; +import {ComponentPortal} from '@angular/cdk/portal'; +import {WorkflowGraphTooltipComponent} from '../components/workflow-graph-tooltip/workflow-graph-tooltip.component'; +import {NodeState} from '../core/models/types'; + +@Directive({ + selector: '[appWorkflowGraphTooltip]', + standalone: true, +}) +export class WorkflowGraphTooltipDirective implements OnDestroy { + @Input() appWorkflowGraphTooltip: {[key: string]: NodeState} | null = null; + @Input() agentGraphData: any = null; + @Input() nodePath: string | null = null; + @Input() allNodes: {[path: string]: {[nodeName: string]: NodeState}} | null = null; + + private overlay = inject(Overlay); + private overlayPositionBuilder = inject(OverlayPositionBuilder); + private viewContainerRef = inject(ViewContainerRef); + private overlayRef: OverlayRef | null = null; + private isPinned = false; + + @HostListener('click', ['$event']) + onClick(event: Event) { + event.stopPropagation(); + + if (!this.appWorkflowGraphTooltip || Object.keys(this.appWorkflowGraphTooltip).length === 0) { + return; + } + + // Toggle pinned state + if (this.isPinned) { + this.hide(); + } else { + this.showPinned(); + } + } + + @HostListener('mouseenter') + show() { + // Don't show hover tooltip if already pinned + if (this.isPinned) { + return; + } + + if (!this.appWorkflowGraphTooltip || Object.keys(this.appWorkflowGraphTooltip).length === 0) { + return; + } + + if (this.overlayRef) { + return; + } + + this.showTooltip(false); + } + + @HostListener('mouseleave') + hide() { + // Don't hide if pinned + if (this.isPinned) { + return; + } + + if (this.overlayRef) { + this.overlayRef.dispose(); + this.overlayRef = null; + } + } + + private showPinned() { + // If tooltip is already showing from hover, dispose it first + if (this.overlayRef) { + this.overlayRef.dispose(); + this.overlayRef = null; + } + + this.isPinned = true; + this.showTooltip(true); + } + + private showTooltip(pinned: boolean) { + if (this.overlayRef) { + return; + } + + const positionStrategy = this.overlayPositionBuilder + .flexibleConnectedTo(this.viewContainerRef.element) + .withPositions([ + { + originX: 'center', + originY: 'top', + overlayX: 'center', + overlayY: 'bottom', + offsetY: -8, + }, + { + originX: 'center', + originY: 'bottom', + overlayX: 'center', + overlayY: 'top', + offsetY: 8, + }, + ]); + + this.overlayRef = this.overlay.create({ + positionStrategy, + scrollStrategy: this.overlay.scrollStrategies.close(), + hasBackdrop: pinned, + backdropClass: pinned ? 'cdk-overlay-transparent-backdrop' : undefined, + }); + + if (pinned && this.overlayRef) { + this.overlayRef.backdropClick().subscribe(() => { + this.isPinned = false; + this.hide(); + }); + } + + const tooltipPortal = new ComponentPortal(WorkflowGraphTooltipComponent); + const componentRef: ComponentRef = + this.overlayRef.attach(tooltipPortal); + + componentRef.instance.nodes = this.appWorkflowGraphTooltip; + componentRef.instance.agentGraphData = this.agentGraphData; + componentRef.instance.nodePath = this.nodePath; + componentRef.instance.allNodes = this.allNodes; + componentRef.instance.isPinned = pinned; + componentRef.instance.onClose = () => { + this.isPinned = false; + this.hide(); + }; + } + + ngOnDestroy() { + this.isPinned = false; + this.hide(); + } +} diff --git a/src/app/utils/graph-layout.utils.ts b/src/app/utils/graph-layout.utils.ts new file mode 100644 index 00000000..c2af9dfc --- /dev/null +++ b/src/app/utils/graph-layout.utils.ts @@ -0,0 +1,222 @@ +/** + * @license + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface GraphEdge { + from_node?: any; + to_node?: any; +} + +export interface GraphNode { + name?: string; + agent?: { + name?: string; + }; +} + +export interface NodePosition { + x: number; + y: number; +} + +export interface LayoutConfig { + ySpacing?: number; + xSpacing?: number; + startX?: number; + startY?: number; +} + +export interface LayoutResult { + levels: Map; + nodesByLevel: Map; + positions: Map; +} + +/** + * Calculates graph layout with support for fan-out visualization. + * Uses BFS to assign nodes to levels, keeping nodes at minimum depth for better fan-out display. + */ +export function calculateGraphLayout( + nodes: GraphNode[], + edges: GraphEdge[], + config: LayoutConfig = {} +): LayoutResult { + const { + ySpacing = 200, + xSpacing = 350, + startX = 400, + startY = 100, + } = config; + + // Extract node names + const nodeNames = nodes.map((node) => + node.name || node.agent?.name || '' + ); + + // Build adjacency list and in-degree map + const adjacencyList: Map = new Map(); + const inDegreeMap: Map = new Map(); + + // Initialize + nodeNames.forEach((name: string) => { + adjacencyList.set(name, []); + inDegreeMap.set(name, 0); + }); + + // Build adjacency list and count in-degrees + edges.forEach((edge) => { + const fromName = edge.from_node?.name || edge.from_node?.agent?.name; + const toName = edge.to_node?.name || edge.to_node?.agent?.name; + + if (fromName && toName) { + adjacencyList.get(fromName)?.push(toName); + inDegreeMap.set(toName, (inDegreeMap.get(toName) || 0) + 1); + } + }); + + // Calculate levels using BFS - use minimum level for better fan-out visualization + // This handles cycles by ignoring back edges + const levels: Map = new Map(); + const queue: string[] = []; + const inDegree = new Map(inDegreeMap); // Copy for processing + const visited = new Set(); + + // Start with nodes that have no incoming edges + nodeNames.forEach((name: string) => { + if (inDegree.get(name) === 0) { + queue.push(name); + levels.set(name, 0); + visited.add(name); + } + }); + + // Process queue + while (queue.length > 0) { + const current = queue.shift()!; + const currentLevel = levels.get(current) || 0; + + adjacencyList.get(current)?.forEach((neighbor) => { + // Ignore back edges (edges to already visited nodes at same or earlier level) + const existingLevel = levels.get(neighbor); + if (existingLevel !== undefined && existingLevel <= currentLevel) { + return; // Skip back edge + } + + // Set level based on first parent (minimum level) + const newLevel = currentLevel + 1; + if (existingLevel === undefined) { + levels.set(neighbor, newLevel); + } + + // Decrement in-degree + const currentInDegree = inDegree.get(neighbor) || 0; + inDegree.set(neighbor, currentInDegree - 1); + + // Add to queue when all parents processed + if (inDegree.get(neighbor) === 0 && !visited.has(neighbor)) { + queue.push(neighbor); + visited.add(neighbor); + } + }); + } + + // Handle unreached nodes (due to cycles) - add them sequentially + let maxLevel = Math.max(...Array.from(levels.values()), 0); + nodeNames.forEach((name: string) => { + if (!levels.has(name)) { + levels.set(name, maxLevel + 1); + maxLevel++; + } + }); + + // Group nodes by level + const nodesByLevel: Map = new Map(); + levels.forEach((level, nodeName) => { + if (!nodesByLevel.has(level)) { + nodesByLevel.set(level, []); + } + nodesByLevel.get(level)?.push(nodeName); + }); + + // Calculate positions for each node + const positions: Map = new Map(); + nodes.forEach((node) => { + const nodeName = node.name || node.agent?.name || ''; + const level = levels.get(nodeName) || 0; + const nodesAtLevel = nodesByLevel.get(level) || []; + const indexInLevel = nodesAtLevel.indexOf(nodeName); + + // Center nodes horizontally if there are multiple at the same level + const totalNodesAtLevel = nodesAtLevel.length; + const xOffset = (indexInLevel - (totalNodesAtLevel - 1) / 2) * xSpacing; + + positions.set(nodeName, { + x: startX + xOffset, + y: startY + (level * ySpacing), + }); + }); + + return { + levels, + nodesByLevel, + positions, + }; +} + +/** + * Extracts the name from a graph node or edge node reference + */ +export function getNodeName(node: any, fallback: string = ''): string { + return node?.name || node?.agent?.name || fallback; +} + +/** + * Returns the Material icon name for a given node type + */ +export function getNodeTypeIcon(nodeType: string): string { + switch (nodeType) { + case 'start': + return 'play_arrow'; + case 'function': + return 'code'; + case 'tool': + return 'build'; + case 'join': + return 'merge'; + case 'agent': + default: + return 'smart_toy'; + } +} + +/** + * Returns the display label for a given node type + */ +export function getNodeTypeLabel(nodeType: string): string { + switch (nodeType) { + case 'start': + return 'Start'; + case 'function': + return 'Function'; + case 'tool': + return 'Tool'; + case 'join': + return 'Join'; + case 'agent': + default: + return 'Agent'; + } +} diff --git a/src/app/utils/graph-navigation.utils.ts b/src/app/utils/graph-navigation.utils.ts new file mode 100644 index 00000000..56b07ea8 --- /dev/null +++ b/src/app/utils/graph-navigation.utils.ts @@ -0,0 +1,113 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import {getNodeName} from './graph-layout.utils'; + +export interface NavigationStackItem { + name: string; + data: any; +} + +/** + * Default layout configuration for graph visualization + */ +export const DEFAULT_LAYOUT_CONFIG = { + ySpacing: 200, + xSpacing: 350, + startX: 400, + startY: 100, +}; + +/** + * Get current path from navigation stack + */ +export function getCurrentPath(navigationStack: NavigationStackItem[]): string { + return navigationStack.map(item => item.name).join('/'); +} + +/** + * Check if a node has nested structure (sub-workflows or agents) + */ +export function hasNestedStructure(nodeData: any): boolean { + return !!(nodeData.graph || nodeData.nodes || (nodeData.sub_agents && nodeData.sub_agents.length > 0)); +} + +/** + * Get nodes at a specific level (works for both graph.nodes and nodes fields) + */ +export function getNodesAtLevel(agentData: any): any[] { + if (agentData.graph?.nodes) { + return agentData.graph.nodes; + } else if (agentData.nodes) { + return agentData.nodes; + } + return []; +} + +/** + * Find node data by name in the current level + */ +export function findNodeInLevel(currentData: any, nodeName: string): any | null { + // Search in nodes array + if (currentData.nodes) { + const found = currentData.nodes.find((n: any) => n.name === nodeName); + if (found) return found; + } + // Search in graph.nodes array + if (currentData.graph?.nodes) { + const found = currentData.graph.nodes.find((n: any) => n.name === nodeName); + if (found) return found; + } + // Search in sub_agents array + if (currentData.sub_agents) { + const found = currentData.sub_agents.find((n: any) => n.name === nodeName); + if (found) return found; + } + return null; +} + +/** + * Navigate to a specific path (e.g., "order_processing_pipeline/validation_stage") + * Returns the navigation stack to reach that path + */ +export function buildNavigationStackFromPath( + rootAgent: any, + nodePath: string +): NavigationStackItem[] { + const pathParts = nodePath.split('/'); + const stack: NavigationStackItem[] = [{name: rootAgent.name, data: rootAgent}]; + + let currentData = rootAgent; + + // Navigate through each level (skip first part as it's root) + for (let i = 1; i < pathParts.length; i++) { + const targetName = pathParts[i]; + const nodes = getNodesAtLevel(currentData); + + // Find the node with this name + const targetNode = nodes.find(node => getNodeName(node) === targetName); + + if (targetNode) { + stack.push({name: targetName, data: targetNode}); + currentData = targetNode; + } else { + console.warn(`Could not find node '${targetName}' in path '${nodePath}'`); + break; + } + } + + return stack; +} diff --git a/src/app/utils/svg-interaction.utils.ts b/src/app/utils/svg-interaction.utils.ts new file mode 100644 index 00000000..3edb4aed --- /dev/null +++ b/src/app/utils/svg-interaction.utils.ts @@ -0,0 +1,84 @@ +/** + * Copyright 2026 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Adds hover effects to SVG nodes rendered by Graphviz + * @param containerSelector - CSS selector for the SVG container element + * @param onNodeClick - Optional callback when a node is clicked, receives node name + * @param expandableNodes - Optional set of node names that are expandable/nested + */ +export function addSvgNodeHoverEffects( + containerOrSelector: string | HTMLElement, + onNodeClick?: (nodeName: string, event: MouseEvent) => void, + expandableNodes?: Set +): void { + const svgContainer = typeof containerOrSelector === 'string' + ? document.querySelector(containerOrSelector) + : containerOrSelector; + if (!svgContainer) return; + + // Find all node groups in the SVG (Graphviz creates for each node) + const nodeElements = svgContainer.querySelectorAll('g.node'); + + nodeElements.forEach((nodeElement: Element) => { + const htmlElement = nodeElement as HTMLElement; + + // Skip legend, START, END nodes, and unvisited nodes + const titleElement = nodeElement.querySelector('title'); + const nodeName = titleElement?.textContent || ''; + if (nodeName === '__LEGEND__' || nodeName === '__START__' || nodeName === '__END__' || htmlElement.classList.contains('unvisited-node')) { + return; + } + + // Only add hover effects to expandable nodes if expandableNodes set is provided + if (expandableNodes && !expandableNodes.has(nodeName)) { + return; + } + + // Add cursor pointer + htmlElement.style.cursor = 'pointer'; + + // Add hover effect + htmlElement.addEventListener('mouseenter', () => { + // Find the shape element (ellipse, polygon, path, rect) inside the node + const shape = nodeElement.querySelector('ellipse, polygon, path, rect'); + if (shape) { + (shape as SVGElement).style.stroke = '#42A5F5'; + (shape as SVGElement).style.strokeWidth = '3'; + } + }); + + htmlElement.addEventListener('mouseleave', () => { + // Reset on mouse leave + const shape = nodeElement.querySelector('ellipse, polygon, path, rect'); + if (shape) { + (shape as SVGElement).style.stroke = ''; + (shape as SVGElement).style.strokeWidth = ''; + } + }); + + // Add click handler if callback provided + if (onNodeClick) { + htmlElement.addEventListener('click', (e: MouseEvent) => { + const titleElement = nodeElement.querySelector('title'); + const nodeName = titleElement?.textContent || ''; + if (nodeName) { + onNodeClick(nodeName, e); + } + }); + } + }); +} diff --git a/src/assets/config/runtime-config.json b/src/assets/config/runtime-config.json index c8f49d88..c6732406 100644 --- a/src/assets/config/runtime-config.json +++ b/src/assets/config/runtime-config.json @@ -1,3 +1,3 @@ { - "backendUrl": "" + "backendUrl": "http://localhost:8000" } \ No newline at end of file diff --git a/src/styles.scss b/src/styles.scss index afd5199b..133063ba 100644 --- a/src/styles.scss +++ b/src/styles.scss @@ -14,32 +14,42 @@ * limitations under the License. */ -/* You can add global styles to this file, and also import other style files */ @use '@angular/material' as mat; -@use '../theme-colors.scss' as theme; // Apply dark theme by default html { - @include mat.theme(theme.$dark-theme); + @include mat.theme(( + color: (theme-type: dark), + typography: (brand-family: 'Google Sans', plain-family: 'Google Sans'), + density: (scale: 0), + )); + color-scheme: dark; } // Light theme override html.light-theme { - @include mat.theme(theme.$light-theme); + @include mat.theme(( + color: (theme-type: light), + typography: (brand-family: 'Google Sans', plain-family: 'Google Sans'), + density: (scale: 0), + )); + color-scheme: light; } // Dark theme explicit class (for consistency) html.dark-theme { - @include mat.theme(theme.$dark-theme); -} - -html { - font-family: 'Google Sans', 'Helvetica Neue', sans-serif !important; + @include mat.theme(( + color: (theme-type: dark), + typography: (brand-family: 'Google Sans', plain-family: 'Google Sans'), + density: (scale: 0), + )); + color-scheme: dark; } body { height: 100vh; margin: 0; + font-family: Roboto, 'Helvetica Neue', sans-serif; } markdown p { @@ -47,935 +57,66 @@ markdown p { margin-block-end: 0.5em; } -// Ensure menus and overlays appear above all other content -.cdk-overlay-container { - z-index: 9999 !important; -} - -.mat-mdc-menu-panel { - z-index: 10000 !important; -} - -// Menu styling for both themes -.mat-mdc-menu-panel, -.mat-mdc-menu-panel .mat-mdc-menu-content { - background-color: var(--mdc-dialog-container-color) !important; -} - -.mat-mdc-menu-item, -.mat-mdc-menu-item .mdc-list-item__primary-text { - color: var(--mdc-dialog-supporting-text-color) !important; -} - -.mat-mdc-menu-item:hover, -.mat-mdc-menu-item:focus { - background-color: var(--builder-tool-item-hover-background-color) !important; -} - -.mat-mdc-menu-item .mat-icon { - color: var(--mdc-dialog-supporting-text-color) !important; -} - -// Snackbar (notification/warning) styling for both themes -.mat-mdc-snack-bar-container { - --mdc-snackbar-container-color: var(--mdc-dialog-container-color) !important; - --mdc-snackbar-supporting-text-color: var(--mdc-dialog-supporting-text-color) !important; - --mat-snack-bar-button-color: var(--builder-text-link-color) !important; -} - -.mdc-snackbar__surface { - background-color: var(--mdc-dialog-container-color) !important; -} - -.mdc-snackbar__label, -.mat-mdc-snack-bar-label { - color: var(--mdc-dialog-supporting-text-color) !important; -} - -.mat-mdc-snack-bar-action { - color: var(--builder-text-link-color) !important; -} - -// Dark theme custom properties -html.dark-theme { - --mat-sys-primary: black; - --mdc-checkbox-selected-icon-color: white; - --mat-sys-background: #131314; - // tab variable overrides - --mat-tab-header-active-label-text-color: #8ab4f8; - --mat-tab-header-active-hover-label-text-color: #8ab4f8; - --mat-tab-header-active-focus-label-text-color: #8ab4f8; - --mat-tab-header-label-text-weight: 500; - --mdc-text-button-label-text-color: #89b4f8; - - // Select dropdown styling for dark theme - --mat-select-trigger-text-color: #8ab4f8; - --mat-select-panel-background-color: #2b2b2f; - --mat-option-label-text-color: #e8eaed; - --mat-option-hover-state-layer-color: rgba(255, 255, 255, 0.08); - --mat-option-focus-state-layer-color: rgba(255, 255, 255, 0.08); - --mat-option-selected-state-layer-color: rgba(138, 180, 248, 0.24); - - // Form field styling for dark theme - --mat-form-field-container-text-color: white; - --mdc-filled-text-field-input-text-color: white; - --mdc-filled-text-field-label-text-color: #9aa0a6; - --mdc-filled-text-field-container-color: #303030; - --mdc-outlined-text-field-input-text-color: white; - --mdc-outlined-text-field-label-text-color: #9aa0a6; - --mat-form-field-state-layer-color: white; - - // Dialog text for dark theme - --mdc-dialog-supporting-text-color: #e8eaed; - --mat-dialog-content-text-color: #e8eaed; - - // Expansion panel text color for dark theme - --mat-expansion-container-text-color: #e8eaed; - --mat-expansion-header-text-color: #e8eaed; - --adk-web-text-color-light-gray: #c4c7c5; -} - -// Light theme custom properties -html.light-theme { - --mat-sys-primary: #9AA0A6; - --mdc-checkbox-selected-icon-color: #305f9d; - --mat-sys-background: #ffffff; - // tab variable overrides - --mat-tab-header-active-label-text-color: #305f9d; - --mat-tab-header-active-hover-label-text-color: #305f9d; - --mat-tab-header-active-focus-label-text-color: #305f9d; - --mat-tab-header-label-text-weight: 500; - --mdc-text-button-label-text-color: #305f9d; - - // Fix select dropdown visibility - --mat-select-trigger-text-color: #202124; - --mat-select-panel-background-color: #ffffff; - --mat-option-label-text-color: #202124; - --mat-option-hover-state-layer-color: rgba(0, 0, 0, 0.04); - --mat-option-focus-state-layer-color: rgba(0, 0, 0, 0.04); - --mat-option-selected-state-layer-color: rgba(48, 95, 157, 0.12); - - // Fix form field visibility - --mat-form-field-container-text-color: #202124; - --mdc-filled-text-field-input-text-color: #202124; - --mdc-filled-text-field-label-text-color: #5f5e5e; - --mdc-filled-text-field-container-color: #f3f0f0; - --mdc-outlined-text-field-input-text-color: #202124; - --mdc-outlined-text-field-label-text-color: #5f5e5e; - --mat-form-field-state-layer-color: #202124; - - // Fix dialog text visibility - --mdc-dialog-supporting-text-color: #202124; - --mat-dialog-content-text-color: #202124; - - // Expansion panel text color for light theme - --mat-expansion-container-text-color: #202124; - --mat-expansion-header-text-color: #202124; - --adk-web-text-color-light-gray: #c4c7c5; -} - -// Dark theme dialog overrides -html.dark-theme { - // Custom property for dialog subhead font - --mdc-dialog-subhead-font-family: 'Google Sans'; - --mdc-dialog-subhead-font-style: normal; - --mdc-dialog-subhead-font-weight: 400; - --mdc-dialog-subhead-font-size: 24px; - --mdc-dialog-subhead-line-height: 32px; - --mdc-dialog-subhead-color: #e3e3e3; - - @include mat.dialog-overrides( - ( - container-color: #2b2b2f, - subhead-color: white, - ) - ); -} - -// Light theme dialog overrides -html.light-theme { - // Custom property for dialog subhead font - --mdc-dialog-subhead-font-family: 'Google Sans'; - --mdc-dialog-subhead-font-style: normal; - --mdc-dialog-subhead-font-weight: 400; - --mdc-dialog-subhead-font-size: 24px; - --mdc-dialog-subhead-line-height: 32px; - --mdc-dialog-subhead-color: #202124; - - @include mat.dialog-overrides( - ( - container-color: #ffffff, - subhead-color: #202124, - ) - ); -} - -.mat-mdc-dialog-container .mat-mdc-dialog-title.mdc-dialog__title { - font-family: var(--mdc-dialog-subhead-font-family); - font-style: var(--mdc-dialog-subhead-font-style); - font-weight: var(--mdc-dialog-subhead-font-weight); - font-size: var(--mdc-dialog-subhead-font-size); - line-height: var(--mdc-dialog-subhead-line-height); - color: var(--mdc-dialog-subhead-color); -} - -// Dark theme progress spinner and custom properties -html.dark-theme { - --chat-panel-function-event-button-background-color: white; - --chat-panel-function-event-button-highlight-background-color: rgb( - 15, - 82, - 35 - ); - --chat-panel-function-event-button-highlight-border-color: rgb(15, 82, 35); - --chat-panel-function-event-button-highlight-color: white; - --long-running-response-input-text-color: #000; - --long-running-response-input-caret-color: #000; - --long-running-response-input-placeholder-color: rgba(0, 0, 0, 0.5); - --long-running-response-icon-color: #000; - --long-running-response-send-button-color: #000; - --chat-panel-user-message-message-card-background-color: #004a77; - --chat-panel-user-message-message-card-color: white; - --chat-panel-bot-message-message-card-background-color: #303030; - --chat-panel-bot-message-message-card-color: white; - --chat-panel-bot-message-focus-within-message-card-background-color: #131314; - --chat-panel-bot-message-focus-within-message-card-border-color: #8ab4f8; - --chat-panel-message-textarea-background-color: #303030; - --chat-panel-message-textarea-focus-background-color: #131314; - --chat-panel-eval-compare-container-background-color: #484848; - --chat-panel-actual-result-border-right-color: #8a8686; - --chat-panel-eval-response-header-border-bottom-color: #8a8686; - --chat-panel-header-expected-color: #44c265; - --chat-panel-header-actual-color: #ff8983; - --chat-panel-eval-pass-color: #44c265; - --chat-panel-eval-fail-color: #ff8983; - --chat-panel-input-field-textarea-color: white; - --chat-panel-input-field-textarea-placeholder-color: #8e918f; - --chat-panel-input-field-textarea-caret-color: white; - --chat-panel-input-field-button-color: white; - --chat-panel-input-field-button-background-color: rgb(51, 53, 55); - --chat-panel-mat-mdc-mini-fab-background-color: white; - --chat-panel-mat-mdc-mini-fab-mat-icon-color: black; - --chat-panel-input-field-mat-mdc-text-field-wrapper-border-color: #8e918f; - --chat-panel-delete-button-background-color: rgba(0, 0, 0, 0.7); - --chat-panel-delete-button-color: white; - --chat-panel-file-container-background-color: #1e1e1e; - --chat-panel-thought-chip-background-color: #8ab4f8; - --chat-panel-link-style-button-color: #007bff; - --artifact-tab-download-button-background-color: #8ab4f8; - --artifact-tab-white-separator-border-top-color: white; - --artifact-tab-version-select-container-background-color: #212123; - --artifact-tab-link-style-button-color: #007bff; - --artifact-tab-link-style-button-hover-color: #0056b3; - --artifact-tab-link-style-button-focus-outline-color: #007bff; - --artifact-tab-link-style-button-active-color: #004085; - --artifact-tab-link-style-button-disabled-color: #6c757d; - --audio-player-container-background-color: #f0f0f0; - --audio-player-container-box-shadow-color: rgba(0, 0, 0, 0.1); - --audio-player-custom-controls-button-background-color: #007bff; - --audio-player-custom-controls-button-color: white; - --audio-player-custom-controls-button-hover-background-color: #0056b3; - --chat-drawer-container-background-color: #131314; - --chat-event-container-color: white; - --chat-card-background-color: #131314; - --chat-function-event-button-background-color: white; - --chat-function-event-button-highlight-background-color: rgb(15, 82, 35); - --chat-function-event-button-highlight-border-color: rgb(15, 82, 35); - --chat-function-event-button-highlight-color: white; - --chat-user-message-message-card-background-color: #004a77; - --chat-user-message-message-card-color: white; - --chat-bot-message-message-card-background-color: #303030; - --chat-bot-message-message-card-color: white; - --chat-bot-message-focus-within-message-card-background-color: #131314; - --chat-bot-message-focus-within-message-card-border-color: #8ab4f8; - --chat-message-textarea-background-color: #303030; - --chat-message-textarea-focus-background-color: #131314; - --chat-eval-compare-container-background-color: #484848; - --chat-actual-result-border-right-color: #8a8686; - --chat-eval-response-header-border-bottom-color: #8a8686; - --chat-header-expected-color: #44c265; - --chat-header-actual-color: #ff8983; - --chat-eval-pass-color: #44c265; - --chat-eval-fail-color: #ff8983; - --chat-side-drawer-background-color: #1b1b1b; - --chat-side-drawer-color: white; - --chat-file-item-background-color: #eee; - --chat-empty-state-container-color: #eee; - --chat-warning-color: #ffc185; - --chat-error-color: #ff4545; - --chat-mat-mdc-unelevated-button-color: #202124; - --chat-mat-mdc-unelevated-button-background-color: #8ab4f8; - --chat-mdc-linear-progress-buffer-dots-background-color: white; - --chat-mat-mdc-text-field-wrapper-border-color: #8e918f; - --chat-segment-key-color: lightgray; - --chat-bottom-resize-handler-background-color: #5f6368; - --chat-readonly-badge-background-color: #ff8983; - --chat-readonly-badge-color: #202124; - --chat-trace-detail-container-background-color: #1b1b1b; - --chat-toolbar-background-color: #1b1b1b; - --chat-toolbar-edit-mode-background-color: #44c2651a; - --chat-toolbar-session-text-color: #fdfdfd; - --chat-toolbar-session-id-color: #9aa0a6; - --chat-toolbar-icon-color: #c4c7c5; - --chat-toolbar-new-session-color: #9aa0a6; - --chat-toolbar-sse-toggle-label-text-color: #e8eaed; - --chat-toolbar-sse-toggle-unselected-track-color: #5f6368; - --chat-toolbar-sse-toggle-unselected-handle-color: #9aa0a6; - --chat-toolbar-sse-toggle-selected-track-color: #8ab4f9; - --chat-toolbar-sse-toggle-selected-handle-color: #1b73e8; - --chat-toolbar-sse-toggle-track-outline-color: #1b73e8; - --chat-mat-drawer-border-right-color: #444746; - --edit-json-dialog-container-box-shadow-color: rgba(0, 0, 0, 0.4); - --eval-tab-eval-set-actions-color: #9aa0a6; - --eval-tab-empty-eval-info-background-color: #202124; - --eval-tab-empty-eval-info-box-shadow-color1: rgba(0, 0, 0, 0.15); - --eval-tab-empty-eval-info-box-shadow-color2: rgba(0, 0, 0, 0.3); - --eval-tab-info-title-color: #e8eaed; - --eval-tab-info-detail-color: #e8eaed; - --eval-tab-info-create-color: #8ab4f8; - --eval-tab-selected-eval-case-color: #8ab4f8; - --eval-tab-save-session-btn-background-color1: rgba(138, 180, 248, 0.24); - --eval-tab-save-session-btn-background-color2: #202124; - --eval-tab-save-session-btn-text-color: #d2e3fc; - --eval-tab-run-eval-btn-border-color: #5f6368; - --eval-tab-run-eval-btn-color: #8ab4f8; - --eval-tab-run-eval-btn-hover-background-color: #202124; - --eval-tab-result-btn-border-color: #5f6368; - --eval-tab-result-btn-hover-background-color: #202124; - --eval-tab-result-btn-pass-color: #44c265; - --eval-tab-result-btn-fail-color: #ff8983; - --eval-tab-status-card-background-color: #2d2d2d; - --eval-tab-status-card-timestamp-color: #e0e0e0; - --eval-tab-status-card-metric-color: #bbb; - --eval-tab-status-card-failed-color: #ff6b6b; - --eval-tab-status-card-separator-color: #666; - --eval-tab-status-card-passed-color: #63e6be; - --eval-tab-status-card-action-mat-icon-color: #bdbdbd; - --eval-tab-status-card-icon-color: #bdbdbd; - --run-eval-config-dialog-container-box-shadow-color: rgba(0, 0, 0, 0.4); - --run-eval-config-dialog-threshold-slider-active-track-color: #4285f4; - --run-eval-config-dialog-threshold-slider-inactive-track-color: #616161; - --run-eval-config-dialog-threshold-slider-handle-color: #4285f4; - --run-eval-config-dialog-threshold-slider-ripple-color: #4285f4; - --run-eval-config-dialog-mdc-slider-thumb-background-color: black; - --event-tab-events-wrapper-color: #9aa0a6; - --event-tab-event-index-color: #80868b; - --event-tab-event-list-active-indicator-color: orange; - --event-tab-event-list-list-item-container-color: #2b2b2f; - --event-tab-mdc-list-item-border-color: #5f6368; - --event-tab-mdc-list-item-hover-background-color: #1c1b1c; - --trace-chart-trace-label-color: #e3e3e3; - --trace-chart-trace-bar-background-color: #2f4d65; - --trace-chart-trace-bar-color: #8dabbf; - --trace-chart-trace-duration-color: #888; - --trace-chart-vertical-line-background-color: #ccc; - --trace-chart-horizontal-line-background-color: #ccc; - --session-tab-session-wrapper-color: #9aa0a6; - --session-tab-session-item-background-color: #303030; - --session-tab-session-item-hover-background-color: #141414; - --session-tab-session-item-current-background-color: #004a77; - --session-tab-session-id-color: #e8eaed; - --session-tab-session-date-color: #9aa0a6; - --side-panel-button-filled-container-color: #89b4f8; - --side-panel-button-filled-label-text-color: black; - --side-panel-mat-icon-color: #bdc1c6; - --side-panel-resize-handler-background-color: #5f6368; - --side-panel-details-panel-container-background-color: #242424; - --side-panel-details-content-color: white; - --side-panel-powered-by-adk-color: grey; - --side-panel-app-select-container-background-color: #212123; - --side-panel-select-placeholder-text-color: #8ab4f8; - --side-panel-select-enabled-trigger-text-color: #8ab4f8; - --side-panel-select-enabled-arrow-color: #8ab4f8; - --side-panel-app-name-option-color: #9aa0a6; - --trace-tab-trace-title-color: #9aa0a6; - --trace-tab-trace-label-color: #e3e3e3; - --trace-tab-trace-bar-background-color: #2f4d65; - --trace-tab-trace-bar-color: #8dabbf; - --trace-tab-trace-duration-color: #888; - --trace-tab-vertical-line-background-color: #ccc; - --trace-tab-horizontal-line-background-color: #ccc; - --trace-tab-trace-item-container-background-color: #333537; - --trace-tab-trace-item-header-focus-state-layer-color: rgba(138, 180, 248, 0.12); - --trace-tab-trace-item-header-description-color: #8e918f; - --trace-tab-mat-expansion-panel-header-focus-background-color: #444746; - --trace-tab-mat-expansion-panel-header-background-color: #444746; - --trace-tab-mat-expansion-panel-header-hover-background-color: #444746; - --trace-event-json-viewer-container-background-color: #1b1b1b; - --trace-tree-trace-label-color: #e3e3e3; - --trace-tree-trace-bar-background-color: #2f4d65; - --trace-tree-trace-bar-color: #8dabbf; - --trace-tree-short-trace-bar-duration-color: #8dabbf; - --trace-tree-trace-duration-color: #888; - --trace-tree-trace-row-hover-background-color: #3b3d3c; - --trace-tree-trace-row-selected-background-color: #3b3d3c; - --trace-tree-vertical-line-background-color: #ccc; - --trace-tree-horizontal-line-background-color: #ccc; - --trace-tree-invocation-id-container-color: #9aa0a6; - --trace-tree-trace-row-left-span-div-color: white; - --trace-tree-trace-row-left-is-event-row-color: #8ab4f8; - - // Builder mode custom properties - Dark theme - --builder-container-background-color: #131314; - --builder-panel-background-color: #202124; - --builder-tabs-background-color: #202124; - --builder-card-background-color: #303030; - --builder-secondary-background-color: #333537; - --builder-tertiary-background-color: #1b1b1b; - --builder-hover-background-color: #141414; - --builder-border-color: #444746; - --builder-text-primary-color: #e8eaed; - --builder-text-secondary-color: #9aa0a6; - --builder-text-tertiary-color: #c4c7c5; - --builder-text-muted-color: #5c5f5e; - --builder-text-link-color: #aecbfa; - --builder-breadcrumb-separator-color: #666; - --builder-form-field-background-color: #333537; - --builder-tool-chip-background-color: #303030; - --builder-tool-chip-hover-color: #3c4043; - --builder-callback-chip-background-color: #333537; - --builder-callback-chip-text-color: #f1f3f4; - --builder-callback-chip-type-color: #8f9aa6; - --builder-callback-chip-name-color: #f5f7f9; - --builder-expansion-background-color: #333537; - --builder-expansion-header-description-color: #8e918f; - --builder-expansion-hover-color: #444746; - --builder-menu-background-color: #303030; - --builder-menu-item-hover-color: #444746; - --builder-menu-divider-color: #444746; - --builder-button-primary-background-color: #8ab4f8; - --builder-button-primary-text-color: #202124; - --builder-button-primary-hover-color: #aecbfa; - --builder-button-secondary-text-color: #9aa0a6; - --builder-button-secondary-border-color: rgba(154, 160, 166, 0.3); - --builder-button-secondary-hover-background-color: rgba(154, 160, 166, 0.1); - --builder-button-secondary-hover-text-color: #e8eaed; - --builder-add-button-background-color: rgba(138, 180, 248, 0.24); - --builder-add-button-text-color: #d2e3fc; - --builder-icon-color: #f1f3f4; - --builder-assistant-panel-background-color: #2b2b2b; - --builder-assistant-panel-header-background-color: #292929; - --builder-assistant-panel-border-color: #3c3c3c; - --builder-assistant-input-background-color: #1a1a1a; - --builder-assistant-input-text-color: #e0e0e0; - --builder-assistant-input-placeholder-color: #808080; - --builder-assistant-user-message-background-color: #1a1a1a; - --builder-assistant-user-message-border-color: #404040; - --builder-assistant-user-message-text-color: #e3e3e3; - --builder-assistant-bot-message-text-color: #d4d4d4; - --builder-assistant-send-button-color: #888888; - --builder-assistant-send-button-hover-color: #b0b0b0; - --builder-assistant-send-button-disabled-color: #4a4a4a; - - // Canvas-specific custom properties - Dark theme - --builder-canvas-container-background: linear-gradient(135deg, #0f0f0f 0%, #1a1a1a 100%); - --builder-canvas-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - --builder-canvas-header-background: linear-gradient(90deg, #1e1e1e 0%, #2a2a2a 100%); - --builder-canvas-header-title-gradient: linear-gradient(45deg, #8ab4f8, #4285f4); - --builder-canvas-workspace-background: #131314; - --builder-canvas-instruction-background: rgba(19, 19, 20, 0.9); - --builder-canvas-instruction-border: rgba(138, 180, 248, 0.2); - --builder-canvas-node-background: rgba(85, 107, 116, 0.4); - --builder-canvas-node-border: #474747; - --builder-canvas-node-hover-border: #666; - --builder-canvas-node-chip-outline: rgba(255, 255, 255, 0.1); - --builder-canvas-node-badge-background: linear-gradient(135deg, rgba(0, 187, 234, 0.2), rgba(0, 78, 122, 0.4)); - --builder-canvas-group-background: #1c1c1c; - --builder-canvas-group-border: #3e3e3e; - --builder-canvas-handle-fill: rgba(0, 0, 0, 1); - --builder-canvas-reconnect-handle-fill: rgba(0, 187, 234, 0.15); - --builder-canvas-workflow-chip-background: rgba(0, 187, 234, 0.2); - --builder-canvas-workflow-chip-border: rgba(0, 187, 234, 0.4); - --builder-canvas-add-btn-background: radial-gradient(circle at 50% 50%, #1f2330 0%, #131314 100%); - --builder-canvas-add-btn-hover-background: radial-gradient(circle at 50% 50%, #222a3a 0%, #16181d 100%); - --builder-canvas-add-btn-shadow: 0 4px 12px rgba(0, 187, 234, 0.35); - --builder-canvas-empty-group-background: rgba(255, 255, 255, 0.02); - --builder-canvas-empty-group-border: rgba(0, 187, 234, 0.3); - --builder-canvas-empty-group-hover-background: rgba(255, 255, 255, 0.04); - --builder-canvas-empty-group-hover-border: rgba(0, 187, 234, 0.5); - --builder-canvas-empty-group-btn-background: rgba(0, 187, 234, 0.1); - --builder-canvas-empty-group-btn-hover-background: rgba(0, 187, 234, 0.2); - --builder-button-background-color: rgba(138, 180, 248, 0.1); - --builder-button-border-color: rgba(138, 180, 248, 0.3); - --builder-button-text-color: #8ab4f8; - --builder-button-hover-background-color: rgba(138, 180, 248, 0.2); - --builder-button-hover-border-color: #8ab4f8; - --builder-item-hover-color: rgba(138, 180, 248, 0.1); - --builder-chip-background-color: rgba(138, 180, 248, 0.2); - --builder-accent-color: #00bbea; - --builder-tool-item-background-color: rgba(255, 255, 255, 0.05); - --builder-tool-item-border-color: rgba(255, 255, 255, 0.1); - --builder-tool-item-hover-background-color: rgba(255, 255, 255, 0.1); - --mat-table-row-item-label-text-color: #fff; - --mat-table-header-headline-color: #fff; - - @include mat.progress-spinner-overrides( - ( - active-indicator-color: #a8c7fa, - size: 80, - ) - ); -} - -// Light theme progress spinner and custom properties -html.light-theme { - --mat-button-outlined-label-text-color: black; - --chat-panel-function-event-button-background-color: #f2f1ef; - --chat-panel-function-event-button-highlight-border-color: #0f5223; - --chat-panel-function-event-button-highlight-color: white; - --long-running-response-input-text-color: #202124; - --long-running-response-input-caret-color: #202124; - --long-running-response-input-placeholder-color: rgba(0, 0, 0, 0.5); - --long-running-response-icon-color: rgba(0, 0, 0, 0.7); - --long-running-response-send-button-color: #305f9d; - --chat-panel-user-message-message-card-background-color: #d5e3ff; - --chat-panel-user-message-message-card-color: #202124; - --chat-panel-bot-message-message-card-background-color: #f3f0f0; - --chat-panel-bot-message-message-card-color: #202124; - --chat-panel-bot-message-focus-within-message-card-background-color: #ffffff; - --chat-panel-bot-message-focus-within-message-card-border-color: #305f9d; - --chat-panel-message-textarea-background-color: #f3f0f0; - --chat-panel-message-textarea-focus-background-color: #ffffff; - --chat-panel-eval-compare-container-background-color: #e5e2e2; - --chat-panel-actual-result-border-right-color: #c8c6c6; - --chat-panel-eval-response-header-border-bottom-color: #c8c6c6; - --chat-panel-header-expected-color: #0f5223; - --chat-panel-header-actual-color: #ba1a1a; - --chat-panel-eval-pass-color: #0f5223; - --chat-panel-eval-fail-color: #ba1a1a; - --chat-panel-input-field-textarea-color: #202124; - --chat-panel-input-field-textarea-placeholder-color: #5f5e5e; - --chat-panel-input-field-textarea-caret-color: #202124; - --chat-panel-input-field-button-color: #202124; - --chat-panel-input-field-button-background-color: #e5e2e2; - --chat-panel-mat-mdc-mini-fab-background-color: #305f9d; - --chat-panel-mat-mdc-mini-fab-mat-icon-color: white; - --chat-panel-input-field-mat-mdc-text-field-wrapper-border-color: #adabab; - --chat-panel-delete-button-background-color: rgba(255, 255, 255, 0.9); - --chat-panel-delete-button-color: #202124; - --chat-panel-file-container-background-color: #f3f0f0; - --chat-panel-thought-chip-background-color: #305f9d; - --chat-panel-link-style-button-color: #305f9d; - --artifact-tab-download-button-background-color: #305f9d; - --artifact-tab-white-separator-border-top-color: #202124; - --artifact-tab-version-select-container-background-color: #f3f0f0; - --artifact-tab-link-style-button-color: #305f9d; - --artifact-tab-link-style-button-hover-color: #0f4784; - --artifact-tab-link-style-button-focus-outline-color: #305f9d; - --artifact-tab-link-style-button-active-color: #003061; - --artifact-tab-link-style-button-disabled-color: #929090; - --audio-player-container-background-color: #f3f0f0; - --audio-player-container-box-shadow-color: rgba(0, 0, 0, 0.1); - --audio-player-custom-controls-button-background-color: #305f9d; - --audio-player-custom-controls-button-color: white; - --audio-player-custom-controls-button-hover-background-color: #0f4784; - --chat-drawer-container-background-color: #ffffff; - --chat-event-container-color: #202124; - --chat-card-background-color: #ffffff; - --chat-function-event-button-background-color: #202124; - --chat-function-event-button-highlight-background-color: #0f5223; - --chat-function-event-button-highlight-border-color: #0f5223; - --chat-function-event-button-highlight-color: white; - --chat-user-message-message-card-background-color: #d5e3ff; - --chat-user-message-message-card-color: #202124; - --chat-bot-message-message-card-background-color: #f3f0f0; - --chat-bot-message-message-card-color: #202124; - --chat-bot-message-focus-within-message-card-background-color: #ffffff; - --chat-bot-message-focus-within-message-card-border-color: #305f9d; - --chat-message-textarea-background-color: #f3f0f0; - --chat-message-textarea-focus-background-color: #ffffff; - --chat-eval-compare-container-background-color: #e5e2e2; - --chat-actual-result-border-right-color: #c8c6c6; - --chat-eval-response-header-border-bottom-color: #c8c6c6; - --chat-header-expected-color: #0f5223; - --chat-header-actual-color: #ba1a1a; - --chat-eval-pass-color: #0f5223; - --chat-eval-fail-color: #ba1a1a; - --chat-side-drawer-background-color: #f3f0f0; - --chat-side-drawer-color: #202124; - --chat-file-item-background-color: #e5e2e2; - --chat-empty-state-container-color: #202124; - --chat-warning-color: #93000a; - --chat-error-color: #ba1a1a; - --chat-mat-mdc-unelevated-button-color: white; - --chat-mat-mdc-unelevated-button-background-color: #305f9d; - --chat-mdc-linear-progress-buffer-dots-background-color: #202124; - --chat-mat-mdc-text-field-wrapper-border-color: #adabab; - --chat-segment-key-color: #5f5e5e; - --chat-bottom-resize-handler-background-color: #adabab; - --chat-readonly-badge-background-color: #ba1a1a; - --chat-readonly-badge-color: white; - --chat-trace-detail-container-background-color: #f3f0f0; - --chat-toolbar-background-color: #f3f0f0; - --chat-toolbar-edit-mode-background-color: rgba(15, 82, 35, 0.1); - --chat-toolbar-session-text-color: #202124; - --chat-toolbar-session-id-color: #5f5e5e; - --chat-toolbar-icon-color: #5f5e5e; - --chat-toolbar-new-session-color: #5f5e5e; - --chat-toolbar-sse-toggle-label-text-color: #202124; - --chat-toolbar-sse-toggle-unselected-track-color: #c8c6c6; - --chat-toolbar-sse-toggle-unselected-handle-color: #5f5e5e; - --chat-toolbar-sse-toggle-selected-track-color: #82adf0; - --chat-toolbar-sse-toggle-selected-handle-color: #305f9d; - --chat-toolbar-sse-toggle-track-outline-color: #305f9d; - --chat-mat-drawer-border-right-color: #c8c6c6; - --edit-json-dialog-container-box-shadow-color: rgba(0, 0, 0, 0.2); - --eval-tab-eval-set-actions-color: #5f5e5e; - --eval-tab-empty-eval-info-background-color: #f3f0f0; - --eval-tab-empty-eval-info-box-shadow-color1: rgba(0, 0, 0, 0.08); - --eval-tab-empty-eval-info-box-shadow-color2: rgba(0, 0, 0, 0.15); - --eval-tab-info-title-color: #202124; - --eval-tab-info-detail-color: #202124; - --eval-tab-info-create-color: #305f9d; - --eval-tab-selected-eval-case-color: #305f9d; - --eval-tab-save-session-btn-background-color1: rgba(48, 95, 157, 0.12); - --eval-tab-save-session-btn-background-color2: #f3f0f0; - --eval-tab-save-session-btn-text-color: #0f4784; - --eval-tab-run-eval-btn-border-color: #adabab; - --eval-tab-run-eval-btn-color: #305f9d; - --eval-tab-run-eval-btn-hover-background-color: #f3f0f0; - --eval-tab-result-btn-border-color: #adabab; - --eval-tab-result-btn-hover-background-color: #f3f0f0; - --eval-tab-result-btn-pass-color: #0f5223; - --eval-tab-result-btn-fail-color: #ba1a1a; - --eval-tab-status-card-background-color: #f3f0f0; - --eval-tab-status-card-timestamp-color: #5f5e5e; - --eval-tab-status-card-metric-color: #787777; - --eval-tab-status-card-failed-color: #ba1a1a; - --eval-tab-status-card-separator-color: #c8c6c6; - --eval-tab-status-card-passed-color: #0f5223; - --eval-tab-status-card-action-mat-icon-color: #5f5e5e; - --eval-tab-status-card-icon-color: #5f5e5e; - --run-eval-config-dialog-container-box-shadow-color: rgba(0, 0, 0, 0.2); - --run-eval-config-dialog-threshold-slider-active-track-color: #305f9d; - --run-eval-config-dialog-threshold-slider-inactive-track-color: #c8c6c6; - --run-eval-config-dialog-threshold-slider-handle-color: #305f9d; - --run-eval-config-dialog-threshold-slider-ripple-color: #305f9d; - --run-eval-config-dialog-mdc-slider-thumb-background-color: white; - --event-tab-events-wrapper-color: #5f5e5e; - --event-tab-event-index-color: #787777; - --event-tab-event-list-active-indicator-color: #ff5449; - --event-tab-event-list-list-item-container-color: #f3f0f0; - --event-tab-mdc-list-item-border-color: #c8c6c6; - --event-tab-mdc-list-item-hover-background-color: #e5e2e2; - --trace-chart-trace-label-color: #202124; - --trace-chart-trace-bar-background-color: #a7c8ff; - --trace-chart-trace-bar-color: #305f9d; - --trace-chart-trace-duration-color: #787777; - --trace-chart-vertical-line-background-color: #c8c6c6; - --trace-chart-horizontal-line-background-color: #c8c6c6; - --session-tab-session-wrapper-color: #5f5e5e; - --session-tab-session-item-background-color: #f3f0f0; - --session-tab-session-item-hover-background-color: #e5e2e2; - --session-tab-session-item-current-background-color: #d5e3ff; - --session-tab-session-id-color: #202124; - --session-tab-session-date-color: #5f5e5e; - --side-panel-button-filled-container-color: #305f9d; - --side-panel-button-filled-label-text-color: white; - --side-panel-mat-icon-color: #5f5e5e; - --side-panel-resize-handler-background-color: #adabab; - --side-panel-details-panel-container-background-color: #f3f0f0; - --side-panel-details-content-color: #202124; - --side-panel-powered-by-adk-color: #787777; - --side-panel-app-select-container-background-color: #ffffff; - --side-panel-select-placeholder-text-color: #305f9d; - --side-panel-select-enabled-trigger-text-color: #305f9d; - --side-panel-select-enabled-arrow-color: #305f9d; - --side-panel-app-name-option-color: #5f5e5e; - --trace-tab-trace-title-color: #5f5e5e; - --trace-tab-trace-label-color: #202124; - --trace-tab-trace-bar-background-color: #a7c8ff; - --trace-tab-trace-bar-color: #305f9d; - --trace-tab-trace-duration-color: #787777; - --trace-tab-vertical-line-background-color: #c8c6c6; - --trace-tab-horizontal-line-background-color: #c8c6c6; - --trace-tab-trace-item-container-background-color: #f3f0f0; - --trace-tab-trace-item-header-focus-state-layer-color: rgba(48, 95, 157, 0.12); - --trace-tab-trace-item-header-description-color: #787777; - --trace-tab-mat-expansion-panel-header-focus-background-color: #e5e2e2; - --trace-tab-mat-expansion-panel-header-background-color: #e5e2e2; - --trace-tab-mat-expansion-panel-header-hover-background-color: #e5e2e2; - --trace-event-json-viewer-container-background-color: #ffffff; - --trace-tree-trace-label-color: #202124; - --trace-tree-trace-bar-background-color: #a7c8ff; - --trace-tree-trace-bar-color: #305f9d; - --trace-tree-short-trace-bar-duration-color: #305f9d; - --trace-tree-trace-duration-color: #787777; - --trace-tree-trace-row-hover-background-color: #e5e2e2; - --trace-tree-trace-row-selected-background-color: #e5e2e2; - --trace-tree-vertical-line-background-color: #c8c6c6; - --trace-tree-horizontal-line-background-color: #c8c6c6; - --trace-tree-invocation-id-container-color: #5f5e5e; - --trace-tree-trace-row-left-span-div-color: #202124; - --trace-tree-trace-row-left-is-event-row-color: #305f9d; - - // Builder mode custom properties - Light theme - --builder-container-background-color: #ffffff; - --builder-panel-background-color: #f3f0f0; - --builder-tabs-background-color: #f3f0f0; - --builder-card-background-color: #ffffff; - --builder-secondary-background-color: #e5e2e2; - --builder-tertiary-background-color: #f3f0f0; - --builder-hover-background-color: #dcd9d9; - --builder-border-color: #c8c6c6; - --builder-text-primary-color: #202124; - --builder-text-secondary-color: #5f5e5e; - --builder-text-tertiary-color: #787777; - --builder-text-muted-color: #929090; - --builder-text-link-color: #305f9d; - --builder-breadcrumb-separator-color: #c8c6c6; - --builder-form-field-background-color: #e5e2e2; - --builder-tool-chip-background-color: #ffffff; - --builder-tool-chip-hover-color: #e5e2e2; - --builder-callback-chip-background-color: #e5e2e2; - --builder-callback-chip-text-color: #202124; - --builder-callback-chip-type-color: #5f5e5e; - --builder-callback-chip-name-color: #202124; - --builder-expansion-background-color: #e5e2e2; - --builder-expansion-header-description-color: #787777; - --builder-expansion-hover-color: #dcd9d9; - --builder-menu-background-color: #ffffff; - --builder-menu-item-hover-color: #e5e2e2; - --builder-menu-divider-color: #c8c6c6; - --builder-button-primary-background-color: #305f9d; - --builder-button-primary-text-color: #ffffff; - --builder-button-primary-hover-color: #0f4784; - --builder-button-secondary-text-color: #5f5e5e; - --builder-button-secondary-border-color: rgba(95, 94, 94, 0.3); - --builder-button-secondary-hover-background-color: rgba(95, 94, 94, 0.1); - --builder-button-secondary-hover-text-color: #202124; - --builder-add-button-background-color: rgba(48, 95, 157, 0.12); - --builder-add-button-text-color: #0f4784; - --builder-icon-color: #202124; - --builder-assistant-panel-background-color: #f3f0f0; - --builder-assistant-panel-header-background-color: #e5e2e2; - --builder-assistant-panel-border-color: #c8c6c6; - --builder-assistant-input-background-color: #ffffff; - --builder-assistant-input-text-color: #202124; - --builder-assistant-input-placeholder-color: #929090; - --builder-assistant-user-message-background-color: #d5e3ff; - --builder-assistant-user-message-border-color: #a7c8ff; - --builder-assistant-user-message-text-color: #202124; - --builder-assistant-bot-message-text-color: #202124; - --builder-assistant-send-button-color: #5f5e5e; - --builder-assistant-send-button-hover-color: #305f9d; - --builder-assistant-send-button-disabled-color: #c8c6c6; - - // Canvas-specific custom properties - Light theme - --builder-canvas-container-background: linear-gradient(135deg, #f8f9fa 0%, #e8eaed 100%); - --builder-canvas-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); - --builder-canvas-header-background: linear-gradient(90deg, #ffffff 0%, #f3f0f0 100%); - --builder-canvas-header-title-gradient: linear-gradient(45deg, #305f9d, #0f4784); - --builder-canvas-workspace-background: #ffffff; - --builder-canvas-instruction-background: rgba(255, 255, 255, 0.95); - --builder-canvas-instruction-border: rgba(48, 95, 157, 0.3); - --builder-canvas-node-background: rgba(229, 226, 226, 0.6); - --builder-canvas-node-border: #c8c6c6; - --builder-canvas-node-hover-border: #adabab; - --builder-canvas-node-chip-outline: rgba(200, 198, 198, 0.3); - --builder-canvas-node-badge-background: linear-gradient(135deg, rgba(48, 95, 157, 0.15), rgba(15, 71, 132, 0.2)); - --builder-canvas-group-background: #f3f0f0; - --builder-canvas-group-border: #c8c6c6; - --builder-canvas-handle-fill: rgba(255, 255, 255, 1); - --builder-canvas-reconnect-handle-fill: rgba(48, 95, 157, 0.15); - --builder-canvas-workflow-chip-background: rgba(48, 95, 157, 0.15); - --builder-canvas-workflow-chip-border: rgba(48, 95, 157, 0.3); - --builder-canvas-add-btn-background: radial-gradient(circle at 50% 50%, #ffffff 0%, #f8f9fa 100%); - --builder-canvas-add-btn-hover-background: radial-gradient(circle at 50% 50%, #f3f0f0 0%, #e8eaed 100%); - --builder-canvas-add-btn-shadow: 0 4px 12px rgba(48, 95, 157, 0.25); - --builder-canvas-empty-group-background: rgba(48, 95, 157, 0.03); - --builder-canvas-empty-group-border: rgba(48, 95, 157, 0.3); - --builder-canvas-empty-group-hover-background: rgba(48, 95, 157, 0.06); - --builder-canvas-empty-group-hover-border: rgba(48, 95, 157, 0.5); - --builder-canvas-empty-group-btn-background: rgba(48, 95, 157, 0.1); - --builder-canvas-empty-group-btn-hover-background: rgba(48, 95, 157, 0.2); - --builder-button-background-color: rgba(48, 95, 157, 0.1); - --builder-button-border-color: rgba(48, 95, 157, 0.3); - --builder-button-text-color: #305f9d; - --builder-button-hover-background-color: rgba(48, 95, 157, 0.2); - --builder-button-hover-border-color: #305f9d; - --builder-item-hover-color: rgba(48, 95, 157, 0.1); - --builder-chip-background-color: rgba(48, 95, 157, 0.15); - --builder-accent-color: #305f9d; - --builder-tool-item-background-color: #f6f3f3; - --builder-tool-item-border-color: #c8c6c6; - --builder-tool-item-hover-background-color: #dcd9d9; - - @include mat.progress-spinner-overrides( - ( - active-indicator-color: #305f9d, - size: 80, - ) - ); -} - -// Form field overrides for dark theme -html.dark-theme { - @include mat.form-field-overrides( - ( - disabled-input-text-placeholder-color: orange, - filled-active-indicator-color: red, - outlined-outline-color: #cccccc, - outlined-input-text-color: #cccccc, - outlined-label-text-color: #cccccc, - outlined-hover-label-text-color: #cccccc, - outlined-focus-label-text-color: #cccccc, - outlined-disabled-label-text-color: #cccccc, - outlined-disabled-input-text-color: #cccccc, - outlined-disabled-outline-color: #cccccc, - outlined-caret-color: #cccccc, - ) - ); -} - -// Form field overrides for light theme -html.light-theme { - @include mat.form-field-overrides( - ( - disabled-input-text-placeholder-color: #ff8983, - filled-active-indicator-color: #ba1a1a, - outlined-outline-color: #787777, - outlined-input-text-color: #202124, - outlined-label-text-color: #5f5e5e, - outlined-hover-label-text-color: #202124, - outlined-focus-label-text-color: #305f9d, - outlined-disabled-label-text-color: #929090, - outlined-disabled-input-text-color: #929090, - outlined-disabled-outline-color: #c8c6c6, - outlined-caret-color: #305f9d, - ) - ); +markdown pre { + border-radius: 8px !important; } -.mdc-line-ripple { - display: none; -} - -// Tooltip styling to prevent overlap -.mat-mdc-tooltip { - z-index: 10000 !important; - max-width: 300px; +markdown code { + border-radius: 4px !important; } -// Ensure Material select panel has proper background -.mat-mdc-select-panel { - background-color: var(--mat-select-panel-background-color) !important; +.json-tooltip-panel { + color: var(--mat-sys-on-surface) !important; + border: 1px solid var(--mat-sys-outline-variant) !important; + border-radius: 8px !important; + padding: 12px 16px !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important; + max-width: 800px !important; + overflow: hidden !important; + background-color: var(--mat-sys-surface-container-high) !important; } -// Fix expansion panel box shadow in light theme -html.light-theme { - .mat-expansion-panel { - box-shadow: none !important; - border: 1px solid #e0e0e0; - border-radius: 4px !important; - - &:not(:last-child) { - margin-bottom: 8px; - } +// ngx-json-viewer custom colors +ngx-json-viewer { + .segment-key { + color: var(--mat-sys-primary) !important; } - .mat-expansion-panel-header { - border-bottom: none !important; + .segment-value { + color: inherit; } -} - -// Fix expansion panel box shadow in dark theme -html.dark-theme { - .mat-expansion-panel { - box-shadow: none !important; - border: 1px solid #444746; - border-radius: 4px !important; - &:not(:last-child) { - margin-bottom: 8px; - } + .segment-type-string { + color: var(--mat-sys-tertiary) !important; } - .mat-expansion-panel-header { - border-bottom: none !important; + .segment-type-number { + color: var(--mat-sys-error) !important; } -} - -.wide-agent-dropdown-panel { - padding: 0 !important; - .search-option { - position: sticky !important; - top: 0 !important; - z-index: 1000 !important; - opacity: 1 !important; - padding-top: 8px; - padding-bottom: 8px; + .segment-type-boolean { + color: var(--mat-sys-secondary) !important; } - span { - width: 100%; + .segment-type-null { + color: var(--mat-sys-outline) !important; } } -html.dark-theme .wide-agent-dropdown-panel .search-option { - background-color: #2b2b2f !important; - - input { - caret-color: white !important; +.user-avatar-menu { + .mat-mdc-menu-content { + padding: 0; } } -html.light-theme .wide-agent-dropdown-panel .search-option { - background-color: #ffffff !important; -} - -.function-args-tooltip { - .mdc-tooltip__surface { - background-color: #333 !important; - color: #fff !important; - border: 2px solid #666 !important; - border-radius: 2px !important; - padding: 8px 12px !important; - font-family: 'Courier New', monospace !important; - font-size: 12px !important; - white-space: pre-wrap !important; - max-width: 800px !important; - line-height: 1.2 !important; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important; +.html-tooltip-panel { + .content-bubble { + max-width: 100% !important; + } + + .message-text p { + white-space: pre-line; + word-break: break-word; + overflow-wrap: break-word; } -} - -html.dark-theme { - --chat-panel-event-number-label-color: rgba(255, 255, 255, 0.8); -} - -html.light-theme { - --chat-panel-event-number-label-color: #5f6368; -} - -.json-key { - color: #9876aa; - font-weight: 600; -} - -.json-string { - color: #6a8759; -} - -.json-number { - color: #6897bb; -} - -.json-boolean { - color: #cc7832; -} - -.json-null { - color: #808080; -} - -.json-tooltip-panel { - background-color: #333 !important; - color: #fff !important; - border: 2px solid #666 !important; - border-radius: 4px !important; - padding: 8px 12px !important; - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3) !important; - max-width: 800px !important; }