feat: enable remote SDK and resilient device orchestration
This commit is contained in:
1 parent
3cc711df51
commit
ddfa420945
110 files changed
+14450
-12954
No files matched your search
@@ -19,7 +19,7 @@
|
||||
"prefix": "app",
|
||||
"architect": {
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"builder": "@angular/build:application",
|
||||
"options": {
|
||||
"outputPath": "dist/frontend",
|
||||
"index": "src/index.html",
|
||||
@@ -65,7 +65,7 @@
|
||||
"defaultConfiguration": "production"
|
||||
},
|
||||
"serve": {
|
||||
"builder": "@angular-devkit/build-angular:dev-server",
|
||||
"builder": "@angular/build:dev-server",
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "frontend:build:production"
|
||||
@@ -77,10 +77,10 @@
|
||||
"defaultConfiguration": "development"
|
||||
},
|
||||
"extract-i18n": {
|
||||
"builder": "@angular-devkit/build-angular:extract-i18n"
|
||||
"builder": "@angular/build:extract-i18n"
|
||||
},
|
||||
"test": {
|
||||
"builder": "@angular-devkit/build-angular:karma",
|
||||
"builder": "@angular/build:karma",
|
||||
"options": {
|
||||
"polyfills": [
|
||||
"zone.js",
|
||||
@@ -102,5 +102,31 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"schematics": {
|
||||
"@schematics/angular:component": {
|
||||
"type": "component"
|
||||
},
|
||||
"@schematics/angular:directive": {
|
||||
"type": "directive"
|
||||
},
|
||||
"@schematics/angular:service": {
|
||||
"type": "service"
|
||||
},
|
||||
"@schematics/angular:guard": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:interceptor": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:module": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:pipe": {
|
||||
"typeSeparator": "."
|
||||
},
|
||||
"@schematics/angular:resolver": {
|
||||
"typeSeparator": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3538
-9640
File diff suppressed because it is too large.
Load diff
@@ -10,29 +10,29 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/common": "^19.2.0",
|
||||
"@angular/compiler": "^19.2.0",
|
||||
"@angular/core": "^19.2.0",
|
||||
"@angular/forms": "^19.2.0",
|
||||
"@angular/platform-browser": "^19.2.0",
|
||||
"@angular/platform-browser-dynamic": "^19.2.0",
|
||||
"@angular/router": "^19.2.0",
|
||||
"@angular/common": "^22.1.4",
|
||||
"@angular/compiler": "^22.1.4",
|
||||
"@angular/core": "^22.1.4",
|
||||
"@angular/forms": "^22.1.4",
|
||||
"@angular/platform-browser": "^22.1.4",
|
||||
"@angular/platform-browser-dynamic": "^22.1.4",
|
||||
"@angular/router": "^22.1.4",
|
||||
"rxjs": "~7.8.0",
|
||||
"tslib": "^2.3.0",
|
||||
"zone.js": "~0.15.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-devkit/build-angular": "^19.2.0",
|
||||
"@angular/cli": "^19.2.0",
|
||||
"@angular/compiler-cli": "^19.2.0",
|
||||
"@angular/build": "^22.1.6",
|
||||
"@angular/cli": "^22.1.6",
|
||||
"@angular/compiler-cli": "^22.1.4",
|
||||
"@types/jasmine": "~5.1.0",
|
||||
"istanbul-lib-instrument": "^6.0.3",
|
||||
"jasmine-core": "~5.6.0",
|
||||
"karma": "~6.4.0",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-coverage": "~2.2.0",
|
||||
"karma-jasmine": "~5.1.0",
|
||||
"karma-jasmine-html-reporter": "~2.1.0",
|
||||
"typescript": "~5.7.2"
|
||||
},
|
||||
"packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621"
|
||||
"typescript": "~6.0.3"
|
||||
}
|
||||
}
|
||||
@@ -16,14 +16,14 @@
|
||||
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClient, withXhr } from '@angular/common/http';
|
||||
import { AppComponent } from './app.component';
|
||||
|
||||
describe('AppComponent', () => {
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [AppComponent],
|
||||
providers: [provideRouter([]), provideHttpClient()]
|
||||
providers: [provideRouter([]), provideHttpClient(withXhr())]
|
||||
}).compileComponents();
|
||||
});
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { NavSwitcherComponent } from './components/nav-switcher/nav-switcher.component';
|
||||
|
||||
@@ -22,6 +22,7 @@ import { NavSwitcherComponent } from './components/nav-switcher/nav-switcher.com
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet, NavSwitcherComponent],
|
||||
templateUrl: './app.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './app.component.scss'
|
||||
})
|
||||
export class AppComponent {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
|
||||
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
|
||||
import { provideRouter } from '@angular/router';
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClient, withXhr } from '@angular/common/http';
|
||||
|
||||
import { routes } from './app.routes';
|
||||
|
||||
@@ -24,6 +24,6 @@ export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
provideZoneChangeDetection({ eventCoalescing: true }),
|
||||
provideRouter(routes),
|
||||
provideHttpClient()
|
||||
provideHttpClient(withXhr())
|
||||
]
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, NgZone, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
@@ -202,16 +202,28 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './agent-stream.component.html',
|
||||
styleUrl: './agent-stream.component.scss'
|
||||
styleUrl: './agent-stream.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class AgentStreamComponent implements AfterViewInit {
|
||||
public agentService = inject(AgentService);
|
||||
private http = inject(HttpClient);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
private zone = inject(NgZone);
|
||||
|
||||
// Auto-scroll state tracking
|
||||
public isUserAtBottom = true;
|
||||
private resizeObserver: ResizeObserver | null = null;
|
||||
private streamLogsContainer: HTMLElement | null = null;
|
||||
private autoScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private autoScrollStreamBoxes = false;
|
||||
private onContainerScroll = () => {
|
||||
const container = this.streamLogsContainer;
|
||||
if (!container) return;
|
||||
const threshold = 150;
|
||||
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
this.isUserAtBottom = distanceToBottom <= threshold;
|
||||
};
|
||||
|
||||
// Dynamic Planning Loader Game-style Phrases
|
||||
public currentPlanningText = signal<string>(PLANNING_LOADER_PHRASES[0]);
|
||||
@@ -230,9 +242,29 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
public collapsedStreams = signal<Set<string>>(new Set<string>());
|
||||
private scheduledCollapses = new Set<string>();
|
||||
|
||||
// Signals and maps for typewriter effect simulation
|
||||
// Signals and maps for typewriter effect simulation. All active typewriters
|
||||
// are driven by ONE requestAnimationFrame loop (run outside the Angular
|
||||
// zone) that batches every text advance into a single signal update.
|
||||
public typedTextsSignal = signal<Record<string, string>>({});
|
||||
private typingTimers = new Map<string, any>();
|
||||
private typingTargets = new Map<string, {
|
||||
blockId: string;
|
||||
isThinking: boolean;
|
||||
execId?: string;
|
||||
fallbackText: string;
|
||||
}>();
|
||||
private typingRafId: number | null = null;
|
||||
private lastTypingTimestamp = 0;
|
||||
// ≈ legacy pace of 8 characters every 12ms interval tick.
|
||||
private static readonly TYPING_CHARS_PER_SECOND = 667;
|
||||
private static readonly TYPING_MIN_FRAME_MS = 28;
|
||||
|
||||
// Rendered-markdown memo per template slot: returning the identical string
|
||||
// instance for unchanged text lets the [innerHTML] binding skip re-sanitizing
|
||||
// and re-parsing the whole fragment on every change-detection pass.
|
||||
private markdownHtmlCache = new Map<string, { source: string; html: string }>();
|
||||
// Thinking-text extraction memo (includes the JSON-detection heuristics).
|
||||
private thinkingTextCache = new WeakMap<any, { native: string | null; raw: string | null }>();
|
||||
private deviceSerialCache = new WeakMap<Session, string | null>();
|
||||
|
||||
// Set to track expanded and collapsed state of action cards
|
||||
public expandedActionCards = signal<Set<string>>(new Set<string>());
|
||||
@@ -285,6 +317,13 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
return notes['output.md'] || null;
|
||||
});
|
||||
|
||||
// Memoized so the Task Report card is not re-parsed (and its DOM rebuilt)
|
||||
// on every change-detection pass while streams are typing.
|
||||
public parsedOutputReport = computed<ParsedNote | null>(() => {
|
||||
const report = this.outputterReport();
|
||||
return report ? parseNote(report) : null;
|
||||
});
|
||||
|
||||
public parsedNote = computed<ParsedNote>(() => {
|
||||
return this.getParsedNote(this.currentNoteContent());
|
||||
});
|
||||
@@ -399,21 +438,24 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
constructor() {
|
||||
const retryClockInterval = setInterval(() => this.retryClock.set(Date.now()), 1000);
|
||||
this.destroyRef.onDestroy(() => clearInterval(retryClockInterval));
|
||||
// The retry clock only needs to tick while something on screen is timing
|
||||
// (startup progress, LLM retry countdowns). An idle page stays quiet.
|
||||
this.zone.runOutsideAngular(() => {
|
||||
const retryClockInterval = setInterval(() => {
|
||||
const status = this.agentService.agentStatus();
|
||||
if (status === 'running' || status === 'paused' || this.agentService.isRetrying()) {
|
||||
this.retryClock.set(Date.now());
|
||||
}
|
||||
}, 1000);
|
||||
this.destroyRef.onDestroy(() => clearInterval(retryClockInterval));
|
||||
});
|
||||
|
||||
// Auto scroll stream box during active text streaming
|
||||
effect(() => {
|
||||
const logs = this.filteredLogs();
|
||||
const activeStream = logs.find(log => log.type === 'llm_stream' && !log.data?.isCompleted);
|
||||
if (activeStream) {
|
||||
setTimeout(() => {
|
||||
const elements = document.getElementsByClassName('stream-box');
|
||||
for (let i = 0; i < elements.length; i++) {
|
||||
const el = elements[i] as HTMLElement;
|
||||
el.scrollTop = el.scrollHeight;
|
||||
}
|
||||
}, 30);
|
||||
this.scheduleAutoScroll(true);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -421,13 +463,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
effect(() => {
|
||||
this.consolidatedBlocks();
|
||||
this.typedTextsSignal();
|
||||
|
||||
setTimeout(() => {
|
||||
const container = document.querySelector('.stream-logs-content');
|
||||
if (container && this.isUserAtBottom) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, 50);
|
||||
this.scheduleAutoScroll(false);
|
||||
});
|
||||
|
||||
// Typewriter drive logic: triggers typing when block appears or expands
|
||||
@@ -451,7 +487,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
} else {
|
||||
const currentVal = currentRecord[blockId] || '';
|
||||
if (currentVal.length < rawText.length && !this.typingTimers.has(blockId)) {
|
||||
if (currentVal.length < rawText.length && !this.typingTargets.has(blockId)) {
|
||||
if (block.data?.isCompleted) {
|
||||
this.typedTextsSignal.update(r => ({ ...r, [blockId]: rawText }));
|
||||
} else {
|
||||
@@ -479,7 +515,7 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
} else {
|
||||
const currentVal = currentRecord[nativeKey] || '';
|
||||
if (currentVal.length < nativeText.length && !this.typingTimers.has(nativeKey)) {
|
||||
if (currentVal.length < nativeText.length && !this.typingTargets.has(nativeKey)) {
|
||||
if (block.data?.isCompleted) {
|
||||
this.typedTextsSignal.update(r => ({ ...r, [nativeKey]: nativeText }));
|
||||
this.collapsedStreams.update(set => {
|
||||
@@ -501,9 +537,9 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
effect(() => {
|
||||
this.agentService.currentSessionId();
|
||||
untracked(() => {
|
||||
this.typingTimers.forEach((timer) => clearInterval(timer));
|
||||
this.typingTimers.clear();
|
||||
this.stopTypingLoop();
|
||||
this.typedTextsSignal.set({});
|
||||
this.markdownHtmlCache.clear();
|
||||
this.scheduledCollapses.clear();
|
||||
this.collapsedStreams.set(new Set<string>());
|
||||
this.isUserAtBottom = true;
|
||||
@@ -547,27 +583,65 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
this.resizeObserver.disconnect();
|
||||
this.resizeObserver = null;
|
||||
}
|
||||
this.stopTypingLoop();
|
||||
if (this.autoScrollTimer) {
|
||||
clearTimeout(this.autoScrollTimer);
|
||||
this.autoScrollTimer = null;
|
||||
}
|
||||
if (this.streamLogsContainer) {
|
||||
this.streamLogsContainer.removeEventListener('scroll', this.onContainerScroll);
|
||||
this.streamLogsContainer = null;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public ngAfterViewInit(): void {
|
||||
const container = document.querySelector('.stream-logs-content');
|
||||
if (container && typeof ResizeObserver !== 'undefined') {
|
||||
const container = document.querySelector('.stream-logs-content') as HTMLElement | null;
|
||||
this.streamLogsContainer = container;
|
||||
if (!container) return;
|
||||
|
||||
// The scroll listener lives outside the Angular zone: tracking the
|
||||
// user's scroll position must not trigger change detection per frame.
|
||||
this.zone.runOutsideAngular(() => {
|
||||
container.addEventListener('scroll', this.onContainerScroll, { passive: true });
|
||||
});
|
||||
|
||||
if (typeof ResizeObserver !== 'undefined') {
|
||||
this.resizeObserver = new ResizeObserver(() => {
|
||||
if (this.isUserAtBottom) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
this.scheduleAutoScroll(false);
|
||||
}
|
||||
});
|
||||
this.resizeObserver.observe(container);
|
||||
}
|
||||
}
|
||||
|
||||
public onStreamScroll(event: Event): void {
|
||||
const container = event.target as HTMLElement;
|
||||
if (!container) return;
|
||||
const threshold = 150;
|
||||
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
|
||||
this.isUserAtBottom = distanceToBottom <= threshold;
|
||||
/**
|
||||
* Coalesce every auto-scroll request into one deferred pass that batches all
|
||||
* DOM reads before all writes, instead of thrashing layout per update.
|
||||
*/
|
||||
private scheduleAutoScroll(includeStreamBoxes: boolean): void {
|
||||
this.autoScrollStreamBoxes = this.autoScrollStreamBoxes || includeStreamBoxes;
|
||||
if (this.autoScrollTimer) return;
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.autoScrollTimer = setTimeout(() => {
|
||||
this.autoScrollTimer = null;
|
||||
const includeBoxes = this.autoScrollStreamBoxes;
|
||||
this.autoScrollStreamBoxes = false;
|
||||
|
||||
const boxes = includeBoxes
|
||||
? (Array.from(document.getElementsByClassName('stream-box')) as HTMLElement[])
|
||||
: [];
|
||||
const boxTargets = boxes.map((el) => el.scrollHeight);
|
||||
const container = this.streamLogsContainer;
|
||||
const containerTarget = container && this.isUserAtBottom ? container.scrollHeight : null;
|
||||
|
||||
boxes.forEach((el, i) => { el.scrollTop = boxTargets[i]; });
|
||||
if (container && containerTarget !== null) {
|
||||
container.scrollTop = containerTarget;
|
||||
}
|
||||
}, 50);
|
||||
});
|
||||
}
|
||||
|
||||
// Top Nav Action Methods
|
||||
@@ -619,22 +693,26 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
* Determine the device serial number for the session
|
||||
*/
|
||||
public getDeviceSerial(session: Session): string | null {
|
||||
if (this.deviceSerialCache.has(session)) {
|
||||
return this.deviceSerialCache.get(session) ?? null;
|
||||
}
|
||||
let resolved: string | null = null;
|
||||
const serial = session.device_serial || session.device_id;
|
||||
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
|
||||
return serial;
|
||||
}
|
||||
if (session.device_info) {
|
||||
resolved = serial;
|
||||
} else if (session.device_info) {
|
||||
try {
|
||||
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
|
||||
const s = info?.device_id || info?.device_serial;
|
||||
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
|
||||
return s;
|
||||
resolved = s;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
this.deviceSerialCache.set(session, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
public selectTask(sessionId: string, event?: Event): void {
|
||||
@@ -675,40 +753,75 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
|
||||
// Typewriter & Delayed Collapse
|
||||
private startTyping(key: string, targetText: string, isThinking: boolean = false, execId?: string) {
|
||||
if (this.typingTimers.has(key)) {
|
||||
clearInterval(this.typingTimers.get(key));
|
||||
}
|
||||
const blockId = key.endsWith('-native') ? key.replace('-native', '') : key;
|
||||
this.typingTargets.set(key, { blockId, isThinking, execId, fallbackText: targetText });
|
||||
this.ensureTypingLoop();
|
||||
}
|
||||
|
||||
const interval = 12;
|
||||
const timer = setInterval(() => {
|
||||
const currentRecord = this.typedTextsSignal();
|
||||
const current = currentRecord[key] || '';
|
||||
|
||||
const blocks = this.consolidatedBlocks();
|
||||
const blockId = key.endsWith('-native') ? key.replace('-native', '') : key;
|
||||
const block = blocks.find(b => b.id === blockId);
|
||||
let target = targetText;
|
||||
private ensureTypingLoop(): void {
|
||||
if (this.typingRafId !== null) return;
|
||||
this.lastTypingTimestamp = performance.now();
|
||||
this.zone.runOutsideAngular(() => {
|
||||
const step = (now: number) => {
|
||||
this.typingRafId = null;
|
||||
const elapsed = now - this.lastTypingTimestamp;
|
||||
if (elapsed >= AgentStreamComponent.TYPING_MIN_FRAME_MS) {
|
||||
this.lastTypingTimestamp = now;
|
||||
this.advanceTyping(elapsed);
|
||||
}
|
||||
if (this.typingTargets.size > 0) {
|
||||
this.typingRafId = requestAnimationFrame(step);
|
||||
}
|
||||
};
|
||||
this.typingRafId = requestAnimationFrame(step);
|
||||
});
|
||||
}
|
||||
|
||||
private stopTypingLoop(): void {
|
||||
this.typingTargets.clear();
|
||||
if (this.typingRafId !== null) {
|
||||
cancelAnimationFrame(this.typingRafId);
|
||||
this.typingRafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Advance every active typewriter by the elapsed wall time and publish all
|
||||
* changes as one signal update (one render pass per animation frame at most).
|
||||
*/
|
||||
private advanceTyping(elapsedMs: number): void {
|
||||
const blocks = untracked(() => this.consolidatedBlocks());
|
||||
const currentRecord = untracked(() => this.typedTextsSignal());
|
||||
const chars = Math.max(
|
||||
1,
|
||||
Math.round(AgentStreamComponent.TYPING_CHARS_PER_SECOND * elapsedMs / 1000)
|
||||
);
|
||||
const updates: Record<string, string> = {};
|
||||
let hasUpdates = false;
|
||||
|
||||
for (const [key, typing] of this.typingTargets) {
|
||||
const block = blocks.find((b) => b.id === typing.blockId);
|
||||
let target = typing.fallbackText;
|
||||
if (block) {
|
||||
target = isThinking ? (this.getNativeThinking(block) || targetText) : (this.getRawThinking(block) || targetText);
|
||||
target = (typing.isThinking ? this.getNativeThinking(block) : this.getRawThinking(block))
|
||||
|| typing.fallbackText;
|
||||
}
|
||||
|
||||
const current = currentRecord[key] || '';
|
||||
if (current.length < target.length) {
|
||||
const nextLength = Math.min(current.length + 8, target.length);
|
||||
const newText = target.slice(0, nextLength);
|
||||
this.typedTextsSignal.update(r => ({ ...r, [key]: newText }));
|
||||
} else {
|
||||
if (block?.data?.isCompleted) {
|
||||
clearInterval(timer);
|
||||
this.typingTimers.delete(key);
|
||||
|
||||
if (isThinking && execId) {
|
||||
this.triggerDelayedCollapse(execId);
|
||||
}
|
||||
updates[key] = target.slice(0, Math.min(current.length + chars, target.length));
|
||||
hasUpdates = true;
|
||||
} else if (block?.data?.isCompleted) {
|
||||
this.typingTargets.delete(key);
|
||||
if (typing.isThinking && typing.execId) {
|
||||
this.triggerDelayedCollapse(typing.execId);
|
||||
}
|
||||
}
|
||||
}, interval);
|
||||
}
|
||||
|
||||
this.typingTimers.set(key, timer);
|
||||
if (hasUpdates) {
|
||||
this.typedTextsSignal.update((r) => ({ ...r, ...updates }));
|
||||
}
|
||||
}
|
||||
|
||||
private triggerDelayedCollapse(execId: string) {
|
||||
@@ -886,6 +999,28 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
|
||||
// Generic Tool Delegation Methods
|
||||
|
||||
/**
|
||||
* First save_note call per note key, derived once per blocks change instead
|
||||
* of re-scanning every block for every rendered tool row.
|
||||
*/
|
||||
private firstSaveNoteByKey = computed<Map<string, any>>(() => {
|
||||
const firstByKey = new Map<string, any>();
|
||||
for (const block of this.consolidatedBlocks()) {
|
||||
if (block.type === 'step' && block.data.generic_tools) {
|
||||
for (const t of this.getUniqueGenericTools(block.data.generic_tools)) {
|
||||
if (t.name && t.name.toLowerCase() === 'save_note') {
|
||||
const key = this.getToolKey(t);
|
||||
if (key && !firstByKey.has(key)) {
|
||||
firstByKey.set(key, t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return firstByKey;
|
||||
});
|
||||
|
||||
public isFirstSaveNoteForKey(tool: any): boolean {
|
||||
if (!tool || !tool.name || tool.name.toLowerCase() !== 'save_note') {
|
||||
return false;
|
||||
@@ -893,26 +1028,11 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
const key = this.getToolKey(tool);
|
||||
if (!key) return true;
|
||||
|
||||
const blocks = this.consolidatedBlocks();
|
||||
const saveNoteCalls: any[] = [];
|
||||
for (const block of blocks) {
|
||||
if (block.type === 'step' && block.data.generic_tools) {
|
||||
const uniqTools = this.getUniqueGenericTools(block.data.generic_tools);
|
||||
for (const t of uniqTools) {
|
||||
if (t.name && t.name.toLowerCase() === 'save_note' && this.getToolKey(t) === key) {
|
||||
saveNoteCalls.push(t);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (saveNoteCalls.length > 0) {
|
||||
const firstCall = saveNoteCalls[0];
|
||||
return (tool.trace_id && firstCall.trace_id)
|
||||
? tool.trace_id === firstCall.trace_id
|
||||
: tool === firstCall;
|
||||
}
|
||||
return true;
|
||||
const firstCall = this.firstSaveNoteByKey().get(key);
|
||||
if (!firstCall) return true;
|
||||
return (tool.trace_id && firstCall.trace_id)
|
||||
? tool.trace_id === firstCall.trace_id
|
||||
: tool === firstCall;
|
||||
}
|
||||
|
||||
public getToolDisplayLabel(tool: any): string {
|
||||
@@ -1013,17 +1133,33 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
return getSortedStepEvents(stepData, this.sortedEventsCache);
|
||||
}
|
||||
|
||||
/**
|
||||
* Memoized per block-data object: templates call these many times per
|
||||
* change-detection pass, and the raw-thinking path runs JSON-detection
|
||||
* heuristics over the full text.
|
||||
*/
|
||||
private getThinkingTexts(data: any): { native: string | null; raw: string | null } {
|
||||
let entry = this.thinkingTextCache.get(data);
|
||||
if (!entry) {
|
||||
const nativeText = data.operator_native_thinking || (data.stream_type === 'thinking' ? data.text : null);
|
||||
const rawText = data.operator_raw_thinking || (data.stream_type === 'text' ? data.text : null);
|
||||
entry = {
|
||||
native: nativeText && nativeText.trim() ? nativeText : null,
|
||||
raw: rawText && rawText.trim() && this.isHumanThinking(rawText) ? rawText : null
|
||||
};
|
||||
this.thinkingTextCache.set(data, entry);
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
public getNativeThinking(block: any): string | null {
|
||||
if (!block || !block.data) return null;
|
||||
const text = block.data.operator_native_thinking || (block.data.stream_type === 'thinking' ? block.data.text : null);
|
||||
return text && text.trim() ? text : null;
|
||||
return this.getThinkingTexts(block.data).native;
|
||||
}
|
||||
|
||||
public getRawThinking(block: any): string | null {
|
||||
if (!block || !block.data) return null;
|
||||
const text = block.data.operator_raw_thinking || (block.data.stream_type === 'text' ? block.data.text : null);
|
||||
if (!text || !text.trim() || !this.isHumanThinking(text)) return null;
|
||||
return text;
|
||||
return this.getThinkingTexts(block.data).raw;
|
||||
}
|
||||
|
||||
public hasVisibleContent(block: any): boolean {
|
||||
@@ -1272,8 +1408,23 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
public renderMarkdown(text: string): string {
|
||||
return renderMarkdownToHtml(text);
|
||||
/**
|
||||
* Render markdown with a per-slot memo. Returning the identical cached
|
||||
* string instance for unchanged text means the [innerHTML] binding sees the
|
||||
* same reference and skips DOM/sanitizer work entirely.
|
||||
*/
|
||||
public renderMarkdown(text: string, cacheKey?: string): string {
|
||||
if (!text) return '';
|
||||
if (!cacheKey) {
|
||||
return renderMarkdownToHtml(text);
|
||||
}
|
||||
const cached = this.markdownHtmlCache.get(cacheKey);
|
||||
if (cached && cached.source === text) {
|
||||
return cached.html;
|
||||
}
|
||||
const html = renderMarkdownToHtml(text);
|
||||
this.markdownHtmlCache.set(cacheKey, { source: text, html });
|
||||
return html;
|
||||
}
|
||||
|
||||
public onToolKeyClick(key: string): void {
|
||||
@@ -1314,9 +1465,17 @@ export class AgentStreamComponent implements AfterViewInit {
|
||||
return tool.trace_id || index.toString();
|
||||
}
|
||||
|
||||
public trackStepEvent(index: number, item: { type: string; data: any }): string {
|
||||
public trackStepEvent(index: number, item: { type: string; data: any; timestamp?: number }): string {
|
||||
if (!item || !item.data) return index.toString();
|
||||
return item.type + '-' + (item.data.timestamp || item.data.start_time || item.data.created_at || item.data.id || item.data.action || item.data.name || item.data.text?.length || index);
|
||||
// A step has at most one thinking and one text event; keying them by type
|
||||
// alone keeps their DOM stable while the streamed text grows (keying by
|
||||
// text length would tear down and rebuild the node on every chunk).
|
||||
if (item.type === 'thinking' || item.type === 'text') {
|
||||
return item.type;
|
||||
}
|
||||
return item.type + '-' + (item.data.trace_id
|
||||
|| item.data.timestamp || item.data.start_time || item.data.created_at || item.data.id
|
||||
|| ((item.data.action || item.data.name || '') + '-' + index));
|
||||
}
|
||||
|
||||
public trackParam(index: number, param: { key: string; value: string }): string {
|
||||
|
||||
+280
-225
@@ -1,17 +1,17 @@
|
||||
<!--
|
||||
Copyright 2026 Google LLC
|
||||
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
|
||||
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
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
|
||||
<div class="chat-interface-container">
|
||||
@@ -19,267 +19,322 @@
|
||||
<header class="chat-header">
|
||||
<!-- Tab Selector Header inside row -->
|
||||
<div class="tab-selector-bar">
|
||||
<button
|
||||
class="tab-selector-btn"
|
||||
[class.active]="agentService.activeTab() === 'tasks'"
|
||||
<button
|
||||
class="tab-selector-btn"
|
||||
[class.active]="agentService.activeTab() === 'tasks'"
|
||||
(click)="agentService.activeTab.set('tasks')"
|
||||
>
|
||||
>
|
||||
<span class="material-symbols-outlined tab-icon">list_alt</span>
|
||||
<span class="tab-text">Task Queue</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab-selector-btn"
|
||||
[class.active]="agentService.activeTab() === 'notes'"
|
||||
<button
|
||||
class="tab-selector-btn"
|
||||
[class.active]="agentService.activeTab() === 'notes'"
|
||||
(click)="agentService.activeTab.set('notes')"
|
||||
>
|
||||
>
|
||||
<span class="material-symbols-outlined tab-icon">description</span>
|
||||
<span class="tab-text">Notes & Plans</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<button
|
||||
*ngIf="agentService.sessions().length > 0 && agentService.activeTab() === 'tasks'"
|
||||
class="btn-clear-history"
|
||||
(click)="clearHistory()"
|
||||
title="Clear all tasks and history"
|
||||
aria-label="Clear all tasks and history"
|
||||
[disabled]="isSubmitting"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
@if (agentService.sessions().length > 0 && agentService.activeTab() === 'tasks') {
|
||||
<button
|
||||
class="btn-clear-history"
|
||||
(click)="clearHistory()"
|
||||
title="Clear all tasks and history"
|
||||
aria-label="Clear all tasks and history"
|
||||
[disabled]="isSubmitting()"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Scrollable Messages/Content Area -->
|
||||
<main class="chat-messages">
|
||||
<!-- Tasks Tab Content -->
|
||||
<ng-container *ngIf="agentService.activeTab() === 'tasks'">
|
||||
@if (agentService.activeTab() === 'tasks') {
|
||||
<!-- Active Queue Section -->
|
||||
<div class="queue-section-header">
|
||||
<span class="material-symbols-outlined section-icon">bolt</span> Active Queue ({{ activeQueue().length }})
|
||||
</div>
|
||||
|
||||
<div *ngIf="activeQueue().length === 0" class="empty-section-placeholder">
|
||||
No active or queued tasks.
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngFor="let session of activeQueue(); trackBy: trackSession"
|
||||
class="task-card"
|
||||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||||
[class.running]="getTaskStatus(session) === 'running'"
|
||||
(click)="selectTask(session.session_id)"
|
||||
>
|
||||
<div class="task-card-header">
|
||||
<span class="task-badge" [ngClass]="getTaskStatus(session)">
|
||||
<span class="status-dot" *ngIf="getTaskStatus(session) === 'running'"></span>
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<div class="task-card-header-actions">
|
||||
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
@if (activeQueue().length === 0) {
|
||||
<div class="empty-section-placeholder">
|
||||
No active or queued tasks.
|
||||
</div>
|
||||
}
|
||||
@for (session of activeQueue(); track trackSession($index, session)) {
|
||||
<div
|
||||
class="task-card"
|
||||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||||
[class.running]="getTaskStatus(session) === 'running'"
|
||||
(click)="selectTask(session.session_id)"
|
||||
>
|
||||
<div class="task-card-header">
|
||||
<span class="task-badge" [ngClass]="getTaskStatus(session)">
|
||||
@if (getTaskStatus(session) === 'running') {
|
||||
<span class="status-dot"></span>
|
||||
}
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
|
||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
<button
|
||||
*ngIf="getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused'"
|
||||
type="button"
|
||||
class="btn-stop-card"
|
||||
(click)="stopTask(session.session_id, $event)"
|
||||
title="Stop this task"
|
||||
aria-label="Stop this task"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop_circle</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
[disabled]="isSubmitting"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
<div class="task-card-header-actions">
|
||||
@if (getDeviceSerial(session)) {
|
||||
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
</span>
|
||||
}
|
||||
@if (getDeviceSerial(session)) {
|
||||
<span class="task-time-divider">·</span>
|
||||
}
|
||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-stop-card"
|
||||
(click)="stopTask(session.session_id, $event)"
|
||||
title="Stop this task"
|
||||
aria-label="Stop this task"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop_circle</span>
|
||||
</button>
|
||||
}
|
||||
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
[disabled]="isSubmitting()"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
</div>
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
</div>
|
||||
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
<!-- Collapsible History Section -->
|
||||
<details class="history-section" open>
|
||||
<summary class="history-summary">
|
||||
<span class="material-symbols-outlined section-icon">history</span> Past Runs & History ({{ historyTasks().length }})
|
||||
</summary>
|
||||
|
||||
<div class="history-list-wrapper">
|
||||
<div *ngIf="historyTasks().length === 0" class="empty-section-placeholder">
|
||||
No past tasks.
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngFor="let session of historyTasks(); trackBy: trackSession"
|
||||
class="task-card"
|
||||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||||
[class.running]="getTaskStatus(session) === 'running'"
|
||||
(click)="selectTask(session.session_id)"
|
||||
>
|
||||
<div class="task-card-header">
|
||||
<span class="task-badge" [ngClass]="getTaskStatus(session)">
|
||||
<span class="status-dot" *ngIf="getTaskStatus(session) === 'running'"></span>
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<div class="task-card-header-actions">
|
||||
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
@if (historyTasks().length === 0) {
|
||||
<div class="empty-section-placeholder">
|
||||
No past tasks.
|
||||
</div>
|
||||
}
|
||||
@for (session of historyTasks(); track trackSession($index, session)) {
|
||||
<div
|
||||
class="task-card"
|
||||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||||
[class.running]="getTaskStatus(session) === 'running'"
|
||||
(click)="selectTask(session.session_id)"
|
||||
>
|
||||
<div class="task-card-header">
|
||||
<span class="task-badge" [ngClass]="getTaskStatus(session)">
|
||||
@if (getTaskStatus(session) === 'running') {
|
||||
<span class="status-dot"></span>
|
||||
}
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
|
||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
<button
|
||||
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
[disabled]="isSubmitting"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
<div class="task-card-header-actions">
|
||||
@if (getDeviceSerial(session)) {
|
||||
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
</span>
|
||||
}
|
||||
@if (getDeviceSerial(session)) {
|
||||
<span class="task-time-divider">·</span>
|
||||
}
|
||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
[disabled]="isSubmitting()"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
</div>
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
</div>
|
||||
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<!-- Notes Tab Content -->
|
||||
<ng-container *ngIf="agentService.activeTab() === 'notes'">
|
||||
@if (agentService.activeTab() === 'notes') {
|
||||
<div class="notes-tab-content">
|
||||
<!-- Notes Selector / Pills (Only shown if there are multiple markdown notes) -->
|
||||
<div class="notes-selector-container" *ngIf="noteKeys().length > 1">
|
||||
<div class="notes-selector-list">
|
||||
<button
|
||||
*ngFor="let key of noteKeys(); trackBy: trackNoteKey"
|
||||
class="note-selector-pill"
|
||||
[class.active]="agentService.selectedNoteKey() === key"
|
||||
(click)="selectNote(key)"
|
||||
>
|
||||
<span class="material-symbols-outlined pill-icon">draft</span>
|
||||
{{ key }}
|
||||
</button>
|
||||
@if (noteKeys().length > 1) {
|
||||
<div class="notes-selector-container">
|
||||
<div class="notes-selector-list">
|
||||
@for (key of noteKeys(); track trackNoteKey($index, key)) {
|
||||
<button
|
||||
class="note-selector-pill"
|
||||
[class.active]="agentService.selectedNoteKey() === key"
|
||||
(click)="selectNote(key)"
|
||||
>
|
||||
<span class="material-symbols-outlined pill-icon">draft</span>
|
||||
{{ key }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="noteKeys().length === 0" class="empty-notes-container">
|
||||
<div class="empty-notes-icon">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
}
|
||||
@if (noteKeys().length === 0) {
|
||||
<div class="empty-notes-container">
|
||||
<div class="empty-notes-icon">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
</div>
|
||||
<h4 class="empty-notes-title">No Notes & Plans</h4>
|
||||
<p class="empty-notes-subtitle">No notes or structured plans were recorded for this session.</p>
|
||||
</div>
|
||||
<h4 class="empty-notes-title">No Notes & Plans</h4>
|
||||
<p class="empty-notes-subtitle">No notes or structured plans were recorded for this session.</p>
|
||||
</div>
|
||||
|
||||
}
|
||||
<!-- Notes Content Viewer (Clean card blocks matching the second screenshot style) -->
|
||||
<div class="note-viewer-cards" *ngIf="noteKeys().length > 0">
|
||||
<!-- Render Note Title if present -->
|
||||
<h2 class="note-title" *ngIf="parsedNote().title">{{ parsedNote().title }}</h2>
|
||||
|
||||
<!-- Render Milestones list as Text Blocks -->
|
||||
<div class="milestones-container" *ngIf="parsedNote().milestones.length > 0">
|
||||
<div
|
||||
*ngFor="let milestone of parsedNote().milestones; trackBy: trackMilestone"
|
||||
class="milestone-block"
|
||||
[ngClass]="milestone.type"
|
||||
>
|
||||
<!-- Milestone Title Row with status icon -->
|
||||
<div class="milestone-title-row">
|
||||
<ng-container [ngSwitch]="milestone.type">
|
||||
<span *ngSwitchCase="'checked'" class="material-symbols-outlined milestone-status-icon checked">check_box</span>
|
||||
<span *ngSwitchCase="'progress'" class="material-symbols-outlined milestone-status-icon progress">indeterminate_check_box</span>
|
||||
<span *ngSwitchCase="'unchecked'" class="material-symbols-outlined milestone-status-icon unchecked">check_box_outline_blank</span>
|
||||
</ng-container>
|
||||
<div class="milestone-text">
|
||||
<span
|
||||
*ngFor="let seg of milestone.segments"
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
</div>
|
||||
@if (noteKeys().length > 0) {
|
||||
<div class="note-viewer-cards">
|
||||
<!-- Render Note Title if present -->
|
||||
@if (parsedNote().title) {
|
||||
<h2 class="note-title">{{ parsedNote().title }}</h2>
|
||||
}
|
||||
<!-- Render Milestones list as Text Blocks -->
|
||||
@if (parsedNote().milestones.length > 0) {
|
||||
<div class="milestones-container">
|
||||
@for (milestone of parsedNote().milestones; track trackMilestone($index, milestone)) {
|
||||
<div
|
||||
class="milestone-block"
|
||||
[ngClass]="milestone.type"
|
||||
>
|
||||
<!-- Milestone Title Row with status icon -->
|
||||
<div class="milestone-title-row">
|
||||
@switch (milestone.type) {
|
||||
@case ('checked') {
|
||||
<span class="material-symbols-outlined milestone-status-icon checked">check_box</span>
|
||||
}
|
||||
@case ('progress') {
|
||||
<span class="material-symbols-outlined milestone-status-icon progress">indeterminate_check_box</span>
|
||||
}
|
||||
@case ('unchecked') {
|
||||
<span class="material-symbols-outlined milestone-status-icon unchecked">check_box_outline_blank</span>
|
||||
}
|
||||
}
|
||||
<div class="milestone-text">
|
||||
@for (seg of milestone.segments; track seg) {
|
||||
<span
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sub-steps list if any -->
|
||||
@if (milestone.subSteps.length > 0) {
|
||||
<div class="milestone-substeps">
|
||||
@for (step of milestone.subSteps; track trackMarkdownLine($index, step)) {
|
||||
<div
|
||||
class="substep-row"
|
||||
[ngClass]="step.type"
|
||||
[style.padding-left.px]="(step.indent > 2 ? (step.indent - 2) * 12 : 0)"
|
||||
>
|
||||
@switch (step.type) {
|
||||
@case ('checked') {
|
||||
<span class="material-symbols-outlined check-icon checked">check_box</span>
|
||||
}
|
||||
@case ('progress') {
|
||||
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
|
||||
}
|
||||
@case ('unchecked') {
|
||||
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
|
||||
}
|
||||
@case ('list-item') {
|
||||
<span class="bullet">•</span>
|
||||
}
|
||||
}
|
||||
@if (step.type !== 'empty') {
|
||||
<span class="line-text">
|
||||
@for (seg of step.segments; track seg) {
|
||||
<span
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Sub-steps list if any -->
|
||||
<div class="milestone-substeps" *ngIf="milestone.subSteps.length > 0">
|
||||
<div
|
||||
*ngFor="let step of milestone.subSteps; trackBy: trackMarkdownLine"
|
||||
class="substep-row"
|
||||
[ngClass]="step.type"
|
||||
[style.padding-left.px]="(step.indent > 2 ? (step.indent - 2) * 12 : 0)"
|
||||
>
|
||||
<ng-container [ngSwitch]="step.type">
|
||||
<span *ngSwitchCase="'checked'" class="material-symbols-outlined check-icon checked">check_box</span>
|
||||
<span *ngSwitchCase="'progress'" class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
|
||||
<span *ngSwitchCase="'unchecked'" class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
|
||||
<span *ngSwitchCase="'list-item'" class="bullet">•</span>
|
||||
</ng-container>
|
||||
<span class="line-text" *ngIf="step.type !== 'empty'">
|
||||
<span
|
||||
*ngFor="let seg of step.segments"
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Render any other lines (e.g. text/markdown blocks at the end) -->
|
||||
@if (parsedNote().otherLines.length > 0) {
|
||||
<div class="other-lines-container">
|
||||
@for (line of parsedNote().otherLines; track trackMarkdownLine($index, line)) {
|
||||
<div
|
||||
class="md-line"
|
||||
[ngClass]="line.type"
|
||||
[style.margin-left.px]="line.indent * 12"
|
||||
>
|
||||
@switch (line.type) {
|
||||
@case ('checked') {
|
||||
<span class="material-symbols-outlined check-icon checked">check_box</span>
|
||||
}
|
||||
@case ('progress') {
|
||||
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
|
||||
}
|
||||
@case ('unchecked') {
|
||||
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
|
||||
}
|
||||
@case ('list-item') {
|
||||
<span class="bullet">•</span>
|
||||
}
|
||||
}
|
||||
@if (line.type !== 'empty') {
|
||||
<span class="line-text">
|
||||
@for (seg of line.segments; track seg) {
|
||||
<span
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Render any other lines (e.g. text/markdown blocks at the end) -->
|
||||
<div class="other-lines-container" *ngIf="parsedNote().otherLines.length > 0">
|
||||
<div
|
||||
*ngFor="let line of parsedNote().otherLines; trackBy: trackMarkdownLine"
|
||||
class="md-line"
|
||||
[ngClass]="line.type"
|
||||
[style.margin-left.px]="line.indent * 12"
|
||||
>
|
||||
<ng-container [ngSwitch]="line.type">
|
||||
<span *ngSwitchCase="'checked'" class="material-symbols-outlined check-icon checked">check_box</span>
|
||||
<span *ngSwitchCase="'progress'" class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
|
||||
<span *ngSwitchCase="'unchecked'" class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
|
||||
<span *ngSwitchCase="'list-item'" class="bullet">•</span>
|
||||
</ng-container>
|
||||
<span class="line-text" *ngIf="line.type !== 'empty'">
|
||||
<span
|
||||
*ngFor="let seg of line.segments"
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, inject, computed } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
@@ -29,14 +29,20 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './chat-interface.component.html',
|
||||
styleUrl: './chat-interface.component.scss'
|
||||
styleUrl: './chat-interface.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ChatInterfaceComponent {
|
||||
public agentService = inject(AgentService);
|
||||
|
||||
public taskInput: string = '';
|
||||
public isSubmitting: boolean = false;
|
||||
public errorMessage: string | null = null;
|
||||
// Signals so async completion handlers refresh this OnPush view.
|
||||
public isSubmitting = signal<boolean>(false);
|
||||
public errorMessage = signal<string | null>(null);
|
||||
|
||||
// Device-serial resolution can require a JSON.parse of device_info; memoize
|
||||
// it per session object so template re-evaluation stays cheap.
|
||||
private deviceSerialCache = new WeakMap<Session, string | null>();
|
||||
|
||||
/**
|
||||
* Filtered computed list of active tasks (running or pending) sorted by status and submission order
|
||||
@@ -76,23 +82,23 @@ export class ChatInterfaceComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
this.agentService.runTask(goal).subscribe({
|
||||
next: (res) => {
|
||||
this.taskInput = '';
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
// Fetch status to refresh sessions list and select new session
|
||||
this.agentService.fetchStatus();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to submit task:', err);
|
||||
this.isSubmitting = false;
|
||||
this.errorMessage = err.error?.detail || 'The runner is busy. Please wait for the current task to finish.';
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || 'The runner is busy. Please wait for the current task to finish.');
|
||||
// Auto-dismiss error banner after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.errorMessage = null;
|
||||
this.errorMessage.set(null);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
@@ -103,11 +109,11 @@ export class ChatInterfaceComponent {
|
||||
*/
|
||||
public stopTask(sessionId: string, event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.agentService.stopTask(sessionId, false);
|
||||
setTimeout(() => {
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
@@ -115,11 +121,11 @@ export class ChatInterfaceComponent {
|
||||
* Stop the currently running task and optionally clear the backend queue
|
||||
*/
|
||||
public stopTasks(stopAll: boolean = false): void {
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.agentService.stopTask(stopAll);
|
||||
setTimeout(() => {
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
@@ -130,16 +136,16 @@ export class ChatInterfaceComponent {
|
||||
if (!confirm('Are you sure you want to clear all tasks and history? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.agentService.clearAllHistory().subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error('Failed to clear history:', err);
|
||||
this.isSubmitting = false;
|
||||
this.errorMessage = err.error?.detail || 'Failed to clear history.';
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || 'Failed to clear history.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -152,16 +158,16 @@ export class ChatInterfaceComponent {
|
||||
if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.agentService.deleteSession(sessionId).subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error(`Failed to delete task ${sessionId}:`, err);
|
||||
this.isSubmitting = false;
|
||||
this.errorMessage = err.error?.detail || 'Failed to delete task.';
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || 'Failed to delete task.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -189,22 +195,26 @@ export class ChatInterfaceComponent {
|
||||
* Determine the device serial number for the session
|
||||
*/
|
||||
public getDeviceSerial(session: Session): string | null {
|
||||
if (this.deviceSerialCache.has(session)) {
|
||||
return this.deviceSerialCache.get(session) ?? null;
|
||||
}
|
||||
let resolved: string | null = null;
|
||||
const serial = session.device_serial || session.device_id;
|
||||
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
|
||||
return serial;
|
||||
}
|
||||
if (session.device_info) {
|
||||
resolved = serial;
|
||||
} else if (session.device_info) {
|
||||
try {
|
||||
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
|
||||
const s = info?.device_id || info?.device_serial;
|
||||
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
|
||||
return s;
|
||||
resolved = s;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
this.deviceSerialCache.set(session, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+414
-411
@@ -1,430 +1,433 @@
|
||||
<!--
|
||||
Copyright 2026 Google LLC
|
||||
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
|
||||
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
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
|
||||
<!-- Backdrop overlay only when in theater / modal mode -->
|
||||
<div
|
||||
class="theater-backdrop"
|
||||
*ngIf="agentService.isVideoWindowOpen() && isTheaterMode()"
|
||||
(click)="isTheaterMode.set(false)"
|
||||
></div>
|
||||
@if (agentService.isVideoWindowOpen() && isTheaterMode()) {
|
||||
<div
|
||||
class="theater-backdrop"
|
||||
(click)="isTheaterMode.set(false)"
|
||||
></div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="floating-video-wrapper"
|
||||
*ngIf="agentService.isVideoWindowOpen()"
|
||||
[class.minimized]="agentService.isVideoMinimized()"
|
||||
[class.theater]="isTheaterMode()"
|
||||
[style.left.px]="!isTheaterMode() ? posX() : null"
|
||||
[style.top.px]="!isTheaterMode() ? posY() : null"
|
||||
>
|
||||
<!-- Window Header (Draggable Handle) -->
|
||||
<header class="window-header" (mousedown)="startDrag($event)">
|
||||
<div class="header-left">
|
||||
<span class="material-symbols-outlined header-icon">
|
||||
{{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.playerMode() === 'steps' ? 'slideshow' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display')) }}
|
||||
</span>
|
||||
<span class="header-title" [title]="agentService.activeVideoTitle()">
|
||||
{{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.playerMode() === 'steps' ? ('Step Replay (' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording'))) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
||||
<!-- Mode Toggle Group: Video vs. Step Replay -->
|
||||
<div class="mode-toggle-group" *ngIf="agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()">
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'video'"
|
||||
(click)="agentService.setPlayerMode('video')"
|
||||
title="Video Recording"
|
||||
>
|
||||
<span class="material-symbols-outlined mode-icon">videocam</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'steps'"
|
||||
(click)="agentService.setPlayerMode('steps')"
|
||||
title="Step Screenshot Replay"
|
||||
>
|
||||
<span class="material-symbols-outlined mode-icon">slideshow</span>
|
||||
<span>Steps</span>
|
||||
</button>
|
||||
@if (agentService.isVideoWindowOpen()) {
|
||||
<div
|
||||
class="floating-video-wrapper"
|
||||
[class.minimized]="agentService.isVideoMinimized()"
|
||||
[class.theater]="isTheaterMode()"
|
||||
[style.left.px]="!isTheaterMode() ? posX() : null"
|
||||
[style.top.px]="!isTheaterMode() ? posY() : null"
|
||||
>
|
||||
<!-- Window Header (Draggable Handle) -->
|
||||
<header class="window-header" (mousedown)="startDrag($event)">
|
||||
<div class="header-left">
|
||||
<span class="material-symbols-outlined header-icon">
|
||||
{{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.playerMode() === 'steps' ? 'slideshow' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display')) }}
|
||||
</span>
|
||||
<span class="header-title" [title]="agentService.activeVideoTitle()">
|
||||
{{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.playerMode() === 'steps' ? ('Step Replay (' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording'))) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Minimize / Restore Toggle -->
|
||||
<button
|
||||
class="header-btn"
|
||||
(click)="toggleMinimize()"
|
||||
[title]="agentService.isVideoMinimized() ? 'Restore' : 'Minimize'"
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<span class="material-symbols-outlined">{{ agentService.isVideoMinimized() ? 'expand_less' : 'remove' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Theater / Fullscreen Toggle -->
|
||||
<button
|
||||
class="header-btn"
|
||||
(click)="toggleTheater()"
|
||||
[title]="isTheaterMode() ? 'Exit Theater' : 'Theater Mode'"
|
||||
*ngIf="!agentService.isVideoMinimized()"
|
||||
aria-label="Theater"
|
||||
>
|
||||
<span class="material-symbols-outlined">{{ isTheaterMode() ? 'close_fullscreen' : 'open_in_full' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Close Window -->
|
||||
<button
|
||||
class="header-btn close-btn"
|
||||
(click)="agentService.closeVideoPlayer()"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Video Main Content Area -->
|
||||
<div class="video-body" *ngIf="!agentService.isVideoMinimized()">
|
||||
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
||||
<ng-container *ngIf="agentService.recordingPlaybackStatus() === 'live'">
|
||||
<div class="live-stream-container" *ngIf="!liveStreamError()">
|
||||
<img
|
||||
class="live-screen-img"
|
||||
[src]="liveStreamUrl()"
|
||||
(error)="onLiveStreamError()"
|
||||
alt="Live Device Screen"
|
||||
/>
|
||||
<div class="live-status-overlay">
|
||||
<div class="live-badge">
|
||||
<span class="pulse-dot"></span>
|
||||
<span>LIVE</span>
|
||||
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
||||
<!-- Mode Toggle Group: Video vs. Step Replay -->
|
||||
@if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
|
||||
<div class="mode-toggle-group">
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'video'"
|
||||
(click)="agentService.setPlayerMode('video')"
|
||||
title="Video Recording"
|
||||
>
|
||||
<span class="material-symbols-outlined mode-icon">videocam</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'steps'"
|
||||
(click)="agentService.setPlayerMode('steps')"
|
||||
title="Step Screenshot Replay"
|
||||
>
|
||||
<span class="material-symbols-outlined mode-icon">slideshow</span>
|
||||
<span>Steps</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="live-refresh-btn" (click)="retryLiveStream()" title="Refresh Live Stream">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-video-fallback" *ngIf="liveStreamError()">
|
||||
<div class="fallback-icon-wrap">
|
||||
<span class="material-symbols-outlined fallback-icon">phonelink_off</span>
|
||||
</div>
|
||||
<h4 class="fallback-title">Device Stream Connecting</h4>
|
||||
<p class="fallback-desc">Waiting for ADB device screen stream...</p>
|
||||
<button class="retry-btn" (click)="retryLiveStream()">
|
||||
<span class="material-symbols-outlined">refresh</span> Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- 2. RECORDING PLAYBACK / STEP REPLAY (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
|
||||
<ng-container *ngIf="agentService.recordingPlaybackStatus() !== 'live'">
|
||||
<!-- Loading State (when in video mode and processing) -->
|
||||
<div class="video-loading-state" *ngIf="agentService.playerMode() === 'video' && (agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading())">
|
||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||
<span>{{ agentService.recordingPlaybackMessage() || 'Preparing screen recording...' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Option A: STEP REPLAY VIEW (ACTIVE IN STEP MODE) -->
|
||||
<ng-container *ngIf="agentService.playerMode() === 'steps' && totalStepFrames() > 0">
|
||||
<div class="step-replay-container">
|
||||
<!-- Fallback info banner if recording had failed -->
|
||||
<div class="step-replay-notice" *ngIf="agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()">
|
||||
<span class="material-symbols-outlined notice-icon">info</span>
|
||||
<span>Screen recording unavailable. Replaying step-by-step screenshots.</span>
|
||||
</div>
|
||||
|
||||
<!-- Step Image Viewport with Action Coordinates Overlay -->
|
||||
<div
|
||||
class="step-image-viewport"
|
||||
(mouseenter)="onViewportMouseEnter()"
|
||||
(mouseleave)="onViewportMouseLeave()"
|
||||
}
|
||||
<!-- Minimize / Restore Toggle -->
|
||||
<button
|
||||
class="header-btn"
|
||||
(click)="toggleMinimize()"
|
||||
[title]="agentService.isVideoMinimized() ? 'Restore' : 'Minimize'"
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<!-- Loading Skeleton (Clean Light Spinner) -->
|
||||
<div class="step-image-loading-state" *ngIf="isStepImageLoading()">
|
||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||
<span>Loading screenshot...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error / Missing Image Fallback -->
|
||||
<div class="step-image-error-state" *ngIf="stepImageError() && !isStepImageLoading()">
|
||||
<span class="material-symbols-outlined broken-icon">broken_image</span>
|
||||
<span>Screenshot preview unavailable for this step</span>
|
||||
</div>
|
||||
|
||||
<!-- Device Screen Frame & Action Coordinates Overlay -->
|
||||
<div class="step-image-frame-wrap" *ngIf="!stepImageError()">
|
||||
<img
|
||||
#stepImgRef
|
||||
class="step-frame-img"
|
||||
[src]="currentStepImageUrl()"
|
||||
[alt]="currentStepFrame()?.title"
|
||||
(load)="onStepImageLoad()"
|
||||
(error)="onStepImageError()"
|
||||
(click)="openImageInNewTab()"
|
||||
title="Click to view image in new tab"
|
||||
/>
|
||||
|
||||
<!-- Action Visual Coordinates Overlay (Touch point, swipe arrow, sequence path) -->
|
||||
<div
|
||||
#stepOverlayRef
|
||||
class="overlay-layer"
|
||||
[style.display]="viewMode() === 'post' ? 'none' : 'block'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Hideable Floating Detail Card (Appears on hover or when pinned) -->
|
||||
<div
|
||||
class="step-frame-badge"
|
||||
*ngIf="currentStepFrame() as frame"
|
||||
[class.visible]="isCardVisible()"
|
||||
(click)="$event.stopPropagation()"
|
||||
<span class="material-symbols-outlined">{{ agentService.isVideoMinimized() ? 'expand_less' : 'remove' }}</span>
|
||||
</button>
|
||||
<!-- Theater / Fullscreen Toggle -->
|
||||
@if (!agentService.isVideoMinimized()) {
|
||||
<button
|
||||
class="header-btn"
|
||||
(click)="toggleTheater()"
|
||||
[title]="isTheaterMode() ? 'Exit Theater' : 'Theater Mode'"
|
||||
aria-label="Theater"
|
||||
>
|
||||
<div class="badge-content-left">
|
||||
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
|
||||
<span class="badge-action-desc" [title]="frame.actionText || frame.title">
|
||||
<span class="material-symbols-outlined action-type-icon" *ngIf="frame.action">
|
||||
{{ getStepActionIcon(frame.action) }}
|
||||
</span>
|
||||
{{ frame.actionText || frame.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="badge-actions-right">
|
||||
<!-- Pre/Post Screenshot Switcher (Only if post-screenshot is available) -->
|
||||
<div class="pre-post-toggle" *ngIf="frame.postImageUrl">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'pre'"
|
||||
(click)="setStepViewMode('pre', $event)"
|
||||
title="View screen before action (with touch coordinates)"
|
||||
>
|
||||
Pre
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'post'"
|
||||
(click)="setStepViewMode('post', $event)"
|
||||
title="View resulting screen after action executed"
|
||||
>
|
||||
Post
|
||||
<span class="material-symbols-outlined">{{ isTheaterMode() ? 'close_fullscreen' : 'open_in_full' }}</span>
|
||||
</button>
|
||||
}
|
||||
<!-- Close Window -->
|
||||
<button
|
||||
class="header-btn close-btn"
|
||||
(click)="agentService.closeVideoPlayer()"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Video Main Content Area -->
|
||||
@if (!agentService.isVideoMinimized()) {
|
||||
<div class="video-body">
|
||||
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
||||
@if (agentService.recordingPlaybackStatus() === 'live') {
|
||||
@if (!liveStreamError()) {
|
||||
<div class="live-stream-container">
|
||||
<img
|
||||
class="live-screen-img"
|
||||
[src]="liveStreamUrl()"
|
||||
(error)="onLiveStreamError()"
|
||||
alt="Live Device Screen"
|
||||
/>
|
||||
<div class="live-status-overlay">
|
||||
<div class="live-badge">
|
||||
<span class="pulse-dot"></span>
|
||||
<span>LIVE</span>
|
||||
</div>
|
||||
<button class="live-refresh-btn" (click)="retryLiveStream()" title="Refresh Live Stream">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Pin Card Button -->
|
||||
<button
|
||||
class="pin-card-btn"
|
||||
[class.pinned]="isCardPinned()"
|
||||
(click)="toggleCardPin($event)"
|
||||
[title]="isCardPinned() ? 'Unpin details card (auto-hide on mouse leave)' : 'Pin details card (keep always visible)'"
|
||||
>
|
||||
<span class="material-symbols-outlined">{{ isCardPinned() ? 'push_pin' : 'keep' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (liveStreamError()) {
|
||||
<div class="empty-video-fallback">
|
||||
<div class="fallback-icon-wrap">
|
||||
<span class="material-symbols-outlined fallback-icon">phonelink_off</span>
|
||||
</div>
|
||||
<h4 class="fallback-title">Device Stream Connecting</h4>
|
||||
<p class="fallback-desc">Waiting for ADB device screen stream...</p>
|
||||
<button class="retry-btn" (click)="retryLiveStream()">
|
||||
<span class="material-symbols-outlined">refresh</span> Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step Replay Controls Footer -->
|
||||
<div class="video-controls-footer">
|
||||
<!-- Timeline Scrubber Bar with Step Ticks -->
|
||||
<div class="scrubber-bar-container">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="totalStepFrames() - 1"
|
||||
[step]="1"
|
||||
[value]="activeStepIndex()"
|
||||
(input)="onStepScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="stepProgressPercent()"></div>
|
||||
<!-- Step Tick Markers -->
|
||||
<div class="step-ticks-wrapper" *ngIf="totalStepFrames() > 1">
|
||||
<span
|
||||
*ngFor="let f of stepFrames(); let i = index"
|
||||
class="step-tick"
|
||||
[class.active]="i === activeStepIndex()"
|
||||
[class.passed]="i < activeStepIndex()"
|
||||
[style.left.%]="(i / (totalStepFrames() - 1)) * 100"
|
||||
(click)="seekStepFrame(i); $event.stopPropagation()"
|
||||
[title]="'Step ' + (i + 1) + ': ' + (f.actionText || f.title)"
|
||||
></span>
|
||||
}
|
||||
}
|
||||
<!-- 2. RECORDING PLAYBACK / STEP REPLAY (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
|
||||
@if (agentService.recordingPlaybackStatus() !== 'live') {
|
||||
<!-- Loading State (when in video mode and processing) -->
|
||||
@if (agentService.playerMode() === 'video' && (agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading())) {
|
||||
<div class="video-loading-state">
|
||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||
<span>{{ agentService.recordingPlaybackMessage() || 'Preparing screen recording...' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Controls Row -->
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<!-- Play / Pause Button -->
|
||||
<button class="ctrl-btn play-btn" (click)="toggleStepPlay()" [title]="isStepPlaying() ? 'Pause' : 'Play Slideshow'">
|
||||
<span class="material-symbols-outlined">{{ isStepPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Previous Frame Button -->
|
||||
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Step">
|
||||
<span class="material-symbols-outlined">skip_previous</span>
|
||||
</button>
|
||||
|
||||
<!-- Next Frame Button -->
|
||||
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Step">
|
||||
<span class="material-symbols-outlined">skip_next</span>
|
||||
</button>
|
||||
|
||||
<!-- Step Replay / Reset -->
|
||||
<button class="ctrl-btn" (click)="restartStep()" title="Replay from start">
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
|
||||
<!-- Step Counter Display -->
|
||||
<span class="time-display">
|
||||
Step {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
|
||||
</span>
|
||||
}
|
||||
<!-- Option A: STEP REPLAY VIEW (ACTIVE IN STEP MODE) -->
|
||||
@if (agentService.playerMode() === 'steps' && totalStepFrames() > 0) {
|
||||
<div class="step-replay-container">
|
||||
<!-- Fallback info banner if recording had failed -->
|
||||
@if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) {
|
||||
<div class="step-replay-notice">
|
||||
<span class="material-symbols-outlined notice-icon">info</span>
|
||||
<span>Screen recording unavailable. Replaying step-by-step screenshots.</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Step Image Viewport with Action Coordinates Overlay -->
|
||||
<div
|
||||
class="step-image-viewport"
|
||||
(mouseenter)="onViewportMouseEnter()"
|
||||
(mouseleave)="onViewportMouseLeave()"
|
||||
>
|
||||
<!-- Loading Skeleton (Clean Light Spinner) -->
|
||||
@if (isStepImageLoading()) {
|
||||
<div class="step-image-loading-state">
|
||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||
<span>Loading screenshot...</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Error / Missing Image Fallback -->
|
||||
@if (stepImageError() && !isStepImageLoading()) {
|
||||
<div class="step-image-error-state">
|
||||
<span class="material-symbols-outlined broken-icon">broken_image</span>
|
||||
<span>Screenshot preview unavailable for this step</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Device Screen Frame & Action Coordinates Overlay -->
|
||||
@if (!stepImageError()) {
|
||||
<div class="step-image-frame-wrap">
|
||||
<img
|
||||
#stepImgRef
|
||||
class="step-frame-img"
|
||||
[src]="currentStepImageUrl()"
|
||||
[alt]="$safeNavigationMigration(currentStepFrame()?.title)"
|
||||
(load)="onStepImageLoad()"
|
||||
(error)="onStepImageError()"
|
||||
(click)="openImageInNewTab()"
|
||||
title="Click to view image in new tab"
|
||||
/>
|
||||
<!-- Action Visual Coordinates Overlay (Touch point, swipe arrow, sequence path) -->
|
||||
<div
|
||||
#stepOverlayRef
|
||||
class="overlay-layer"
|
||||
[style.display]="viewMode() === 'post' ? 'none' : 'block'"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
<!-- Hideable Floating Detail Card (Appears on hover or when pinned) -->
|
||||
@if (currentStepFrame(); as frame) {
|
||||
<div
|
||||
class="step-frame-badge"
|
||||
[class.visible]="isCardVisible()"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="badge-content-left">
|
||||
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
|
||||
<span class="badge-action-desc" [title]="frame.actionText || frame.title">
|
||||
@if (frame.action) {
|
||||
<span class="material-symbols-outlined action-type-icon">
|
||||
{{ getStepActionIcon(frame.action) }}
|
||||
</span>
|
||||
}
|
||||
{{ frame.actionText || frame.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="badge-actions-right">
|
||||
<!-- Pre/Post Screenshot Switcher (Only if post-screenshot is available) -->
|
||||
@if (frame.postImageUrl) {
|
||||
<div class="pre-post-toggle">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'pre'"
|
||||
(click)="setStepViewMode('pre', $event)"
|
||||
title="View screen before action (with touch coordinates)"
|
||||
>
|
||||
Pre
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'post'"
|
||||
(click)="setStepViewMode('post', $event)"
|
||||
title="View resulting screen after action executed"
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<!-- Pin Card Button -->
|
||||
<button
|
||||
class="pin-card-btn"
|
||||
[class.pinned]="isCardPinned()"
|
||||
(click)="toggleCardPin($event)"
|
||||
[title]="isCardPinned() ? 'Unpin details card (auto-hide on mouse leave)' : 'Pin details card (keep always visible)'"
|
||||
>
|
||||
<span class="material-symbols-outlined">{{ isCardPinned() ? 'push_pin' : 'keep' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Step Replay Controls Footer -->
|
||||
<div class="video-controls-footer">
|
||||
<!-- Timeline Scrubber Bar with Step Ticks -->
|
||||
<div class="scrubber-bar-container">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="totalStepFrames() - 1"
|
||||
[step]="1"
|
||||
[value]="activeStepIndex()"
|
||||
(input)="onStepScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="stepProgressPercent()"></div>
|
||||
<!-- Step Tick Markers -->
|
||||
@if (totalStepFrames() > 1) {
|
||||
<div class="step-ticks-wrapper">
|
||||
@for (f of stepFrames(); track trackStepFrame(i, f); let i = $index) {
|
||||
<span
|
||||
class="step-tick"
|
||||
[class.active]="i === activeStepIndex()"
|
||||
[class.passed]="i < activeStepIndex()"
|
||||
[style.left.%]="(i / (totalStepFrames() - 1)) * 100"
|
||||
(click)="seekStepFrame(i); $event.stopPropagation()"
|
||||
[title]="'Step ' + (i + 1) + ': ' + (f.actionText || f.title)"
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Bottom Controls Row -->
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<!-- Play / Pause Button -->
|
||||
<button class="ctrl-btn play-btn" (click)="toggleStepPlay()" [title]="isStepPlaying() ? 'Pause' : 'Play Slideshow'">
|
||||
<span class="material-symbols-outlined">{{ isStepPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
<!-- Previous Frame Button -->
|
||||
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Step">
|
||||
<span class="material-symbols-outlined">skip_previous</span>
|
||||
</button>
|
||||
<!-- Next Frame Button -->
|
||||
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Step">
|
||||
<span class="material-symbols-outlined">skip_next</span>
|
||||
</button>
|
||||
<!-- Step Replay / Reset -->
|
||||
<button class="ctrl-btn" (click)="restartStep()" title="Replay from start">
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
<!-- Step Counter Display -->
|
||||
<span class="time-display">
|
||||
Step {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||||
<button
|
||||
class="ctrl-btn speed-pill-btn"
|
||||
(click)="cycleSpeed()"
|
||||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||||
>
|
||||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||||
</button>
|
||||
<!-- Loop Toggle -->
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||||
>
|
||||
<span class="material-symbols-outlined">repeat</span>
|
||||
</button>
|
||||
<!-- Open in new tab -->
|
||||
<button class="ctrl-btn" (click)="openImageInNewTab()" title="Open image in new tab">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Option B: ACTIVE VIDEO PLAYBACK VIEW -->
|
||||
@if (agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl(); as videoUrl) {
|
||||
@if (!videoLoadError()) {
|
||||
<div class="video-container">
|
||||
<video
|
||||
#videoRef
|
||||
[src]="currentVideoUrl()"
|
||||
playsinline
|
||||
(loadedmetadata)="onLoadedMetadata()"
|
||||
(timeupdate)="onTimeUpdate()"
|
||||
(play)="onPlay()"
|
||||
(pause)="onPause()"
|
||||
(ended)="onEnded()"
|
||||
(error)="onError()"
|
||||
(click)="togglePlay()"
|
||||
></video>
|
||||
</div>
|
||||
}
|
||||
<!-- Dedicated Bottom Controls Bar for Video -->
|
||||
@if (!videoLoadError()) {
|
||||
<div class="video-controls-footer">
|
||||
<!-- Timeline Scrubber Bar -->
|
||||
<div class="scrubber-bar-container">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="duration() || 100"
|
||||
[step]="0.1"
|
||||
[value]="currentTime()"
|
||||
(input)="onScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||
</div>
|
||||
<!-- Bottom Controls Row -->
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<!-- Play / Pause Button -->
|
||||
<button class="ctrl-btn play-btn" (click)="togglePlay()" [title]="isPlaying() ? 'Pause' : 'Play'">
|
||||
<span class="material-symbols-outlined">{{ isPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
<!-- Replay / Reset -->
|
||||
<button class="ctrl-btn" (click)="restart()" title="Replay from start">
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
<!-- Time Display -->
|
||||
<span class="time-display">
|
||||
{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||||
<button
|
||||
class="ctrl-btn speed-pill-btn"
|
||||
(click)="cycleSpeed()"
|
||||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||||
>
|
||||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||||
</button>
|
||||
<!-- Loop Toggle -->
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||||
>
|
||||
<span class="material-symbols-outlined">repeat</span>
|
||||
</button>
|
||||
<!-- Mute / Unmute Toggle -->
|
||||
<button class="ctrl-btn" (click)="toggleMute()" [title]="isMuted() ? 'Unmute' : 'Mute'">
|
||||
<span class="material-symbols-outlined">{{ isMuted() ? 'volume_off' : 'volume_up' }}</span>
|
||||
</button>
|
||||
<!-- Open in new tab -->
|
||||
<button class="ctrl-btn" (click)="openInNewTab()" title="Open video in new tab">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<!-- Option C: EMPTY / FALLBACK STATE (Only when no video AND no step frames) -->
|
||||
@if (!agentService.isVideoLoading() && (agentService.playerMode() === 'video' || totalStepFrames() === 0) && (agentService.recordingPlaybackStatus() === 'failed' || agentService.recordingPlaybackStatus() === 'unavailable' || videoLoadError()) && totalStepFrames() === 0) {
|
||||
<div class="empty-video-fallback">
|
||||
<div class="fallback-icon-wrap">
|
||||
<span class="material-symbols-outlined fallback-icon">videocam_off</span>
|
||||
</div>
|
||||
<h4 class="fallback-title">
|
||||
{{ agentService.recordingPlaybackStatus() === 'failed' || videoLoadError() ? 'Recording Playback Failed' : 'No Video Recording Available' }}
|
||||
</h4>
|
||||
<p class="fallback-desc">
|
||||
{{ videoLoadError() ? 'The finalized recording could not be played.' : (agentService.recordingPlaybackMessage() || 'No screen recording was found for this task.') }}
|
||||
</p>
|
||||
@if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) {
|
||||
<button class="retry-btn" (click)="videoLoadError.set(false); agentService.retryVideoRecording()">
|
||||
<span class="material-symbols-outlined">refresh</span> Retry
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||||
<button
|
||||
class="ctrl-btn speed-pill-btn"
|
||||
(click)="cycleSpeed()"
|
||||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||||
>
|
||||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||||
</button>
|
||||
|
||||
<!-- Loop Toggle -->
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||||
>
|
||||
<span class="material-symbols-outlined">repeat</span>
|
||||
</button>
|
||||
|
||||
<!-- Open in new tab -->
|
||||
<button class="ctrl-btn" (click)="openImageInNewTab()" title="Open image in new tab">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Option B: ACTIVE VIDEO PLAYBACK VIEW -->
|
||||
<ng-container *ngIf="agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl() as videoUrl">
|
||||
<div class="video-container" *ngIf="!videoLoadError()">
|
||||
<video
|
||||
#videoRef
|
||||
[src]="currentVideoUrl()"
|
||||
playsinline
|
||||
(loadedmetadata)="onLoadedMetadata()"
|
||||
(timeupdate)="onTimeUpdate()"
|
||||
(play)="onPlay()"
|
||||
(pause)="onPause()"
|
||||
(ended)="onEnded()"
|
||||
(error)="onError()"
|
||||
(click)="togglePlay()"
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<!-- Dedicated Bottom Controls Bar for Video -->
|
||||
<div class="video-controls-footer" *ngIf="!videoLoadError()">
|
||||
<!-- Timeline Scrubber Bar -->
|
||||
<div class="scrubber-bar-container">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="duration() || 100"
|
||||
[step]="0.1"
|
||||
[value]="currentTime()"
|
||||
(input)="onScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Controls Row -->
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<!-- Play / Pause Button -->
|
||||
<button class="ctrl-btn play-btn" (click)="togglePlay()" [title]="isPlaying() ? 'Pause' : 'Play'">
|
||||
<span class="material-symbols-outlined">{{ isPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Replay / Reset -->
|
||||
<button class="ctrl-btn" (click)="restart()" title="Replay from start">
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
|
||||
<!-- Time Display -->
|
||||
<span class="time-display">
|
||||
{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||||
<button
|
||||
class="ctrl-btn speed-pill-btn"
|
||||
(click)="cycleSpeed()"
|
||||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||||
>
|
||||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||||
</button>
|
||||
|
||||
<!-- Loop Toggle -->
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||||
>
|
||||
<span class="material-symbols-outlined">repeat</span>
|
||||
</button>
|
||||
|
||||
<!-- Mute / Unmute Toggle -->
|
||||
<button class="ctrl-btn" (click)="toggleMute()" [title]="isMuted() ? 'Unmute' : 'Mute'">
|
||||
<span class="material-symbols-outlined">{{ isMuted() ? 'volume_off' : 'volume_up' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Open in new tab -->
|
||||
<button class="ctrl-btn" (click)="openInNewTab()" title="Open video in new tab">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Option C: EMPTY / FALLBACK STATE (Only when no video AND no step frames) -->
|
||||
<div class="empty-video-fallback" *ngIf="!agentService.isVideoLoading() && (agentService.playerMode() === 'video' || totalStepFrames() === 0) && (agentService.recordingPlaybackStatus() === 'failed' || agentService.recordingPlaybackStatus() === 'unavailable' || videoLoadError()) && totalStepFrames() === 0">
|
||||
<div class="fallback-icon-wrap">
|
||||
<span class="material-symbols-outlined fallback-icon">videocam_off</span>
|
||||
</div>
|
||||
<h4 class="fallback-title">
|
||||
{{ agentService.recordingPlaybackStatus() === 'failed' || videoLoadError() ? 'Recording Playback Failed' : 'No Video Recording Available' }}
|
||||
</h4>
|
||||
<p class="fallback-desc">
|
||||
{{ videoLoadError() ? 'The finalized recording could not be played.' : (agentService.recordingPlaybackMessage() || 'No screen recording was found for this task.') }}
|
||||
</p>
|
||||
<button class="retry-btn" *ngIf="agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()" (click)="videoLoadError.set(false); agentService.retryVideoRecording()">
|
||||
<span class="material-symbols-outlined">refresh</span> Retry
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+27
-10
@@ -16,6 +16,8 @@
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
NgZone,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
effect,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { StepReplayFrame } from '../../core/models/stream.model';
|
||||
@@ -35,12 +37,14 @@ import { getActionIcon } from '../../utils/action-formatter.util';
|
||||
@Component({
|
||||
selector: 'app-floating-video-player',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [FormsModule],
|
||||
templateUrl: './floating-video-player.component.html',
|
||||
styleUrl: './floating-video-player.component.scss'
|
||||
styleUrl: './floating-video-player.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
public agentService = inject(AgentService);
|
||||
private zone = inject(NgZone);
|
||||
|
||||
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
|
||||
@ViewChild('stepImgRef') stepImgRef?: ElementRef<HTMLImageElement>;
|
||||
@@ -217,7 +221,9 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
});
|
||||
|
||||
/**
|
||||
* Start dragging the window from header
|
||||
* Start dragging the window from header. The move/up listeners are attached
|
||||
* only for the duration of the drag and live outside the Angular zone, so
|
||||
* ordinary mouse movement over the page never touches change detection.
|
||||
*/
|
||||
public startDrag(event: MouseEvent): void {
|
||||
if (this.isTheaterMode()) return;
|
||||
@@ -227,10 +233,14 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
this.initialPosX = this.posX();
|
||||
this.initialPosY = this.posY();
|
||||
event.preventDefault();
|
||||
|
||||
this.zone.runOutsideAngular(() => {
|
||||
document.addEventListener('mousemove', this.onDrag);
|
||||
document.addEventListener('mouseup', this.stopDrag);
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('window:mousemove', ['$event'])
|
||||
public onDrag(event: MouseEvent): void {
|
||||
private onDrag = (event: MouseEvent): void => {
|
||||
if (!this.isDragging) return;
|
||||
const deltaX = event.clientX - this.dragStartX;
|
||||
const deltaY = event.clientY - this.dragStartY;
|
||||
@@ -245,12 +255,13 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
|
||||
this.posX.set(targetX);
|
||||
this.posY.set(targetY);
|
||||
}
|
||||
};
|
||||
|
||||
@HostListener('window:mouseup')
|
||||
public stopDrag(): void {
|
||||
private stopDrag = (): void => {
|
||||
this.isDragging = false;
|
||||
}
|
||||
document.removeEventListener('mousemove', this.onDrag);
|
||||
document.removeEventListener('mouseup', this.stopDrag);
|
||||
};
|
||||
|
||||
@HostListener('window:resize')
|
||||
public onResize(): void {
|
||||
@@ -593,10 +604,16 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
public trackStepFrame(index: number, frame: StepReplayFrame): string {
|
||||
return frame.stepId || String(index);
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
if (this.stepTimer) {
|
||||
clearTimeout(this.stepTimer);
|
||||
this.stepTimer = null;
|
||||
}
|
||||
document.removeEventListener('mousemove', this.onDrag);
|
||||
document.removeEventListener('mouseup', this.stopDrag);
|
||||
}
|
||||
}
|
||||
+98
-84
@@ -1,17 +1,17 @@
|
||||
<!--
|
||||
Copyright 2026 Google LLC
|
||||
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
|
||||
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
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
|
||||
<div class="agent-stream-container">
|
||||
@@ -29,14 +29,15 @@
|
||||
[value]="agentService.currentSessionId() || ''"
|
||||
(change)="onSessionChange($event)"
|
||||
class="session-dropdown"
|
||||
>
|
||||
<option value="" disabled selected>-- Select a Session --</option>
|
||||
<option
|
||||
*ngFor="let session of agentService.sessions()"
|
||||
[value]="session.session_id"
|
||||
>
|
||||
{{ session.initial_goal || 'Unnamed Goal' }} ({{ session.device_serial ? session.device_serial + ' · ' : '' }}{{ session.start_time * 1000 | date:'short' }})
|
||||
</option>
|
||||
<option value="" disabled selected>-- Select a Session --</option>
|
||||
@for (session of agentService.sessions(); track session) {
|
||||
<option
|
||||
[value]="session.session_id"
|
||||
>
|
||||
{{ session.initial_goal || 'Unnamed Goal' }} ({{ session.device_serial ? session.device_serial + ' · ' : '' }}{{ session.start_time * 1000 | date:'short' }})
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn-refresh" (click)="agentService.fetchSessions()">🔄 Refresh</button>
|
||||
</div>
|
||||
@@ -44,73 +45,86 @@
|
||||
|
||||
<!-- Event Logs Section -->
|
||||
<main class="stream-logs-content">
|
||||
<div *ngIf="agentService.sessionLogs().length === 0" class="empty-state">
|
||||
<div class="empty-icon">📡</div>
|
||||
<h3>No Stream Activity Selected</h3>
|
||||
<p>Please select a running session from the dropdown above to view real-time log payloads.</p>
|
||||
</div>
|
||||
|
||||
<div *ngIf="agentService.sessionLogs().length > 0" class="logs-list">
|
||||
<div
|
||||
*ngFor="let log of agentService.sessionLogs(); let i = index"
|
||||
class="log-card"
|
||||
[ngClass]="['type-' + log.type, isStepEvent(log.type) ? 'step-event-card' : 'raw-event-card']"
|
||||
>
|
||||
<div class="log-card-header">
|
||||
<div class="badge-group">
|
||||
<span class="index-badge">#{{ i + 1 }}</span>
|
||||
<span class="type-badge" [ngClass]="log.type">{{ log.type }}</span>
|
||||
</div>
|
||||
<span class="timestamp">{{ log.timestamp | date:'HH:mm:ss.SSS' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Render Step Events in a beautiful, structured layout -->
|
||||
<div *ngIf="isStepEvent(log.type)" class="log-card-body step-body">
|
||||
<!-- Step Summary (Message to User) -->
|
||||
<div class="step-summary-section" *ngIf="log.data.summary">
|
||||
<div class="section-title">💬 Message to User / Summary:</div>
|
||||
<div class="summary-bubble">{{ log.data.summary }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Model thinking process (collapsible) -->
|
||||
<details class="thinking-details" *ngIf="log.data.operator_native_thinking">
|
||||
<summary class="details-summary">🧠 Model Thinking Process (Click to expand)</summary>
|
||||
<div class="details-content thinking-text">{{ log.data.operator_native_thinking }}</div>
|
||||
</details>
|
||||
|
||||
<!-- Raw thinking process (collapsible) -->
|
||||
<details class="planning-details" *ngIf="log.data.operator_raw_thinking">
|
||||
<summary class="details-summary">📝 Raw Plan / Thoughts (Click to expand)</summary>
|
||||
<div class="details-content planning-text">{{ log.data.operator_raw_thinking }}</div>
|
||||
</details>
|
||||
|
||||
<!-- Tool Call (collapsible) -->
|
||||
<details class="tool-details" *ngIf="log.data.action_taken" [open]="isShortContent(log.data.action_taken)">
|
||||
<summary class="details-summary">🛠️ Tool Call Details (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data.action_taken) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Tool Call Result (collapsible) -->
|
||||
<details class="result-details" *ngIf="log.data.last_execution_result">
|
||||
<summary class="details-summary">✓ Execution Result (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data.last_execution_result) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Render other event types as raw JSON (collapsible) -->
|
||||
<div *ngIf="!isStepEvent(log.type)" class="log-card-body raw-body">
|
||||
<details class="raw-details" [open]="isShortContent(log.data)">
|
||||
<summary class="details-summary">📄 Raw Payload Data (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@if (agentService.sessionLogs().length === 0) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📡</div>
|
||||
<h3>No Stream Activity Selected</h3>
|
||||
<p>Please select a running session from the dropdown above to view real-time log payloads.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (agentService.sessionLogs().length > 0) {
|
||||
<div class="logs-list">
|
||||
@for (log of agentService.sessionLogs(); track log; let i = $index) {
|
||||
<div
|
||||
class="log-card"
|
||||
[ngClass]="['type-' + log.type, isStepEvent(log.type) ? 'step-event-card' : 'raw-event-card']"
|
||||
>
|
||||
<div class="log-card-header">
|
||||
<div class="badge-group">
|
||||
<span class="index-badge">#{{ i + 1 }}</span>
|
||||
<span class="type-badge" [ngClass]="log.type">{{ log.type }}</span>
|
||||
</div>
|
||||
<span class="timestamp">{{ log.timestamp | date:'HH:mm:ss.SSS' }}</span>
|
||||
</div>
|
||||
<!-- Render Step Events in a beautiful, structured layout -->
|
||||
@if (isStepEvent(log.type)) {
|
||||
<div class="log-card-body step-body">
|
||||
<!-- Step Summary (Message to User) -->
|
||||
@if (log.data.summary) {
|
||||
<div class="step-summary-section">
|
||||
<div class="section-title">💬 Message to User / Summary:</div>
|
||||
<div class="summary-bubble">{{ log.data.summary }}</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Model thinking process (collapsible) -->
|
||||
@if (log.data.operator_native_thinking) {
|
||||
<details class="thinking-details">
|
||||
<summary class="details-summary">🧠 Model Thinking Process (Click to expand)</summary>
|
||||
<div class="details-content thinking-text">{{ log.data.operator_native_thinking }}</div>
|
||||
</details>
|
||||
}
|
||||
<!-- Raw thinking process (collapsible) -->
|
||||
@if (log.data.operator_raw_thinking) {
|
||||
<details class="planning-details">
|
||||
<summary class="details-summary">📝 Raw Plan / Thoughts (Click to expand)</summary>
|
||||
<div class="details-content planning-text">{{ log.data.operator_raw_thinking }}</div>
|
||||
</details>
|
||||
}
|
||||
<!-- Tool Call (collapsible) -->
|
||||
@if (log.data.action_taken) {
|
||||
<details class="tool-details" [open]="isShortContent(log.data.action_taken)">
|
||||
<summary class="details-summary">🛠️ Tool Call Details (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data.action_taken) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
<!-- Tool Call Result (collapsible) -->
|
||||
@if (log.data.last_execution_result) {
|
||||
<details class="result-details">
|
||||
<summary class="details-summary">✓ Execution Result (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data.last_execution_result) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Render other event types as raw JSON (collapsible) -->
|
||||
@if (!isStepEvent(log.type)) {
|
||||
<div class="log-card-body raw-body">
|
||||
<details class="raw-details" [open]="isShortContent(log.data)">
|
||||
<summary class="details-summary">📄 Raw Payload Data (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
+2
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
@@ -24,6 +24,7 @@ import { AgentService } from '../../services/agent.service';
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './legacy-agent-stream.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './legacy-agent-stream.component.scss'
|
||||
})
|
||||
export class LegacyAgentStreamComponent {
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.9);
|
||||
user-select: none;
|
||||
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
@@ -56,7 +58,10 @@
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-switcher',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, RouterLinkActive],
|
||||
imports: [RouterLink, RouterLinkActive],
|
||||
template: `
|
||||
<nav class="floating-nav-switcher" aria-label="Main Navigation">
|
||||
<a
|
||||
@@ -45,6 +45,7 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
</a>
|
||||
</nav>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrls: ['./nav-switcher.component.scss']
|
||||
})
|
||||
export class NavSwitcherComponent {}
|
||||
@@ -36,6 +36,42 @@ export interface DeviceInfo {
|
||||
installed_packages?: string[];
|
||||
}
|
||||
|
||||
export interface AdbServerEndpoint {
|
||||
host: string;
|
||||
port: number;
|
||||
socket: string;
|
||||
identity: string;
|
||||
mode: 'local' | 'remote';
|
||||
is_local_default: boolean;
|
||||
}
|
||||
|
||||
export interface AdbServerDevice {
|
||||
serial: string;
|
||||
state: string;
|
||||
model: string | null;
|
||||
product: string | null;
|
||||
}
|
||||
|
||||
export interface AdbServerStatus {
|
||||
endpoint: AdbServerEndpoint;
|
||||
}
|
||||
|
||||
export interface AdbServerConnectionResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
endpoint: AdbServerEndpoint;
|
||||
devices: AdbServerDevice[];
|
||||
persisted?: boolean;
|
||||
persistence_error?: string | null;
|
||||
error_code?: string;
|
||||
output?: string;
|
||||
}
|
||||
|
||||
export interface AdbServerConnectionResponse {
|
||||
connection_result: AdbServerConnectionResult;
|
||||
report?: SystemReadinessReport;
|
||||
}
|
||||
|
||||
export interface ProbeResult {
|
||||
id: string;
|
||||
category: ProbeCategory;
|
||||
|
||||
@@ -674,15 +674,24 @@
|
||||
<div class="step-title-row">
|
||||
<h3 class="step-title">Android Device & Emulator Connection</h3>
|
||||
</div>
|
||||
<p class="step-desc">Physical Android phone or local Android Virtual Device (AVD)</p>
|
||||
<p class="step-desc">Local emulator, attached phone, wireless device, or another ADB server</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-restart-adb btn-connection-options"
|
||||
(click)="toggleConnectionMethods()"
|
||||
>
|
||||
<span class="material-symbols-outlined">swap_horiz</span>
|
||||
<span>{{ showConnectionMethods() ? 'Hide Options' : 'Change Connection' }}</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn-restart-adb"
|
||||
[disabled]="isRestartingAdb()"
|
||||
(click)="restartAdbServer()"
|
||||
title="Restart local ADB daemon"
|
||||
[title]="isRemoteAdbServer() ? 'Refresh devices without stopping the remote ADB server' : 'Restart local ADB daemon'"
|
||||
>
|
||||
@if (isRestartingAdb()) {
|
||||
<span class="material-symbols-outlined spinning">sync</span>
|
||||
@@ -692,7 +701,7 @@
|
||||
<span class="feedback-text">{{ adbRestartFeedback() }}</span>
|
||||
} @else {
|
||||
<span class="material-symbols-outlined">sync</span>
|
||||
<span>Restart ADB</span>
|
||||
<span>{{ isRemoteAdbServer() ? 'Refresh Devices' : 'Restart ADB' }}</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@@ -726,6 +735,12 @@
|
||||
<span class="meta-dot">·</span>
|
||||
<span>Android {{ activeDevice()?.android_version }}</span>
|
||||
}
|
||||
@if (isRemoteAdbServer()) {
|
||||
<span class="meta-dot">·</span>
|
||||
<span class="remote-endpoint-label">
|
||||
Remote via {{ adbServerStatus()?.endpoint?.host }}:{{ adbServerStatus()?.endpoint?.port }}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="device-hero-status">
|
||||
@@ -919,7 +934,7 @@
|
||||
|
||||
<div class="failed-actions-bar">
|
||||
@if (emulatorLaunchState()?.avd_name) {
|
||||
<button type="button" class="btn-retry-launch" (click)="launchAvdEmulator(emulatorLaunchState()!.avd_name!)">
|
||||
<button type="button" class="btn-retry-launch" [disabled]="isRemoteAdbServer()" (click)="launchAvdEmulator(emulatorLaunchState()!.avd_name!)">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
<span>Retry Launch ({{ emulatorLaunchState()?.avd_name }})</span>
|
||||
</button>
|
||||
@@ -937,7 +952,7 @@
|
||||
}
|
||||
|
||||
<!-- STATE B-3: NO DEVICE DETECTED / READY TO CONNECT GUIDE -->
|
||||
@if (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.summary !== 'Device Locked' && adbProbe()?.summary !== 'Lock State Unknown' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed') {
|
||||
@if (showConnectionMethods() || (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.summary !== 'Device Locked' && adbProbe()?.summary !== 'Lock State Unknown' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed')) {
|
||||
<div class="foolproof-guide-container">
|
||||
<!-- Method Selection Nav Cards -->
|
||||
<div class="connection-methods-nav">
|
||||
@@ -954,13 +969,14 @@
|
||||
<div class="method-info">
|
||||
<div class="method-title-row">
|
||||
<span class="method-title">Android Emulator</span>
|
||||
@if (installedAvds().length > 0) {
|
||||
<span class="method-badge-pill detected">{{ installedAvds().length }} DETECTED</span>
|
||||
} @else {
|
||||
<span class="method-badge-pill recommended">RECOMMENDED</span>
|
||||
}
|
||||
</div>
|
||||
<span class="method-desc">Launch local Android Virtual Device</span>
|
||||
<span class="method-desc">
|
||||
@if (installedAvds().length > 0) {
|
||||
{{ installedAvds().length }} local virtual device{{ installedAvds().length > 1 ? 's' : '' }} available
|
||||
} @else {
|
||||
Launch a local Android Virtual Device
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="method-card-right">
|
||||
@@ -981,7 +997,6 @@
|
||||
<div class="method-info">
|
||||
<div class="method-title-row">
|
||||
<span class="method-title">USB Cable</span>
|
||||
<span class="method-badge-pill neutral">4 STEPS</span>
|
||||
</div>
|
||||
<span class="method-desc">Connect physical phone via USB</span>
|
||||
</div>
|
||||
@@ -1004,7 +1019,6 @@
|
||||
<div class="method-info">
|
||||
<div class="method-title-row">
|
||||
<span class="method-title">Wireless ADB</span>
|
||||
<span class="method-badge-pill info">WI-FI</span>
|
||||
</div>
|
||||
<span class="method-desc">Connect over local Wi-Fi</span>
|
||||
</div>
|
||||
@@ -1013,8 +1027,52 @@
|
||||
<span class="radio-indicator" [class.checked]="activeAdbGuideTab() === 'wifi'"></span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="method-nav-card"
|
||||
[class.active]="activeAdbGuideTab() === 'remote'"
|
||||
(click)="setAdbGuideTab('remote')"
|
||||
>
|
||||
<div class="method-card-left">
|
||||
<div class="method-icon-box">
|
||||
<span class="material-symbols-outlined">devices</span>
|
||||
</div>
|
||||
<div class="method-info">
|
||||
<div class="method-title-row">
|
||||
<span class="method-title">Remote Computer</span>
|
||||
</div>
|
||||
<span class="method-desc">Phone connected to another computer</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="method-card-right">
|
||||
<span class="radio-indicator" [class.checked]="activeAdbGuideTab() === 'remote'"></span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@if (activeAdbGuideTab() !== 'remote' && isRemoteAdbServer()) {
|
||||
<div class="active-remote-notice">
|
||||
<div class="active-remote-copy">
|
||||
<span class="material-symbols-outlined">hub</span>
|
||||
<span>
|
||||
Artemis is currently using
|
||||
<strong>{{ adbServerStatus()?.endpoint?.host }}:{{ adbServerStatus()?.endpoint?.port }}</strong>.
|
||||
Switch to local ADB before using a local emulator, USB cable, or Wireless ADB.
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-use-local-adb"
|
||||
[disabled]="isSwitchingToLocalAdb()"
|
||||
(click)="switchToLocalAdbServer()"
|
||||
>
|
||||
<span class="material-symbols-outlined" [class.spinning]="isSwitchingToLocalAdb()">sync</span>
|
||||
<span>{{ isSwitchingToLocalAdb() ? 'Switching...' : 'Use Local ADB' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Method 1: Emulator -->
|
||||
@if (activeAdbGuideTab() === 'emulator') {
|
||||
<div class="method-details-panel">
|
||||
@@ -1049,7 +1107,7 @@
|
||||
type="button"
|
||||
class="btn-avd-launch"
|
||||
[class.launching]="launchingAvd() === avd || (isEmulatorLaunching() && emulatorLaunchState()?.avd_name === avd)"
|
||||
[disabled]="isEmulatorLaunching()"
|
||||
[disabled]="isEmulatorLaunching() || isRemoteAdbServer()"
|
||||
(click)="launchAvdEmulator(avd)"
|
||||
[title]="isEmulatorLaunching() ? 'An emulator is currently launching...' : 'Launch ' + avd"
|
||||
>
|
||||
@@ -1319,7 +1377,7 @@
|
||||
</div>
|
||||
|
||||
<div class="wifi-btn-col">
|
||||
<button type="button" class="btn-wifi-connect" [disabled]="isConnectingWifi() || !wifiHost().trim()"
|
||||
<button type="button" class="btn-wifi-connect" [disabled]="isConnectingWifi() || isRemoteAdbServer() || !wifiHost().trim()"
|
||||
(click)="connectWifiDevice()">
|
||||
@if (isConnectingWifi()) {
|
||||
<span class="material-symbols-outlined spinning">sync</span>
|
||||
@@ -1354,6 +1412,132 @@
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Method 4: ADB server on another computer -->
|
||||
@if (activeAdbGuideTab() === 'remote') {
|
||||
<div class="method-details-panel remote-adb-panel">
|
||||
<div class="remote-guide-intro">
|
||||
<span class="material-symbols-outlined intro-icon">hub</span>
|
||||
<span>Enter the host and port of an ADB server endpoint that this computer can access, so Artemis can discover and control a phone physically connected to another computer.</span>
|
||||
</div>
|
||||
|
||||
<div class="remote-adb-interactive-box">
|
||||
<div class="remote-adb-form-row">
|
||||
<div class="remote-adb-input-col host-col">
|
||||
<label class="remote-adb-label" for="remote-adb-host">ADB Server Host</label>
|
||||
<div class="remote-adb-input-wrap">
|
||||
<span class="material-symbols-outlined wrap-icon">dns</span>
|
||||
<input
|
||||
id="remote-adb-host"
|
||||
type="text"
|
||||
class="remote-adb-input"
|
||||
[ngModel]="remoteAdbHost()"
|
||||
(ngModelChange)="updateRemoteAdbHost($event)"
|
||||
placeholder="127.0.0.1"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="remote-adb-input-col port-col">
|
||||
<label class="remote-adb-label" for="remote-adb-port">ADB Server Port</label>
|
||||
<div class="remote-adb-input-wrap">
|
||||
<span class="material-symbols-outlined wrap-icon">tag</span>
|
||||
<input
|
||||
id="remote-adb-port"
|
||||
type="text"
|
||||
inputmode="numeric"
|
||||
class="remote-adb-input"
|
||||
[ngModel]="remoteAdbPort()"
|
||||
(ngModelChange)="updateRemoteAdbPort($event)"
|
||||
placeholder="5038"
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="remote-adb-btn-col">
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remote-adb-connect"
|
||||
[disabled]="isConnectingRemoteAdb() || isActivatingRemoteAdb() || !remoteAdbHost().trim()"
|
||||
(click)="connectRemoteAdbServer()"
|
||||
>
|
||||
@if (isConnectingRemoteAdb()) {
|
||||
<span class="material-symbols-outlined spinning">sync</span>
|
||||
<span>Checking...</span>
|
||||
} @else {
|
||||
<span class="material-symbols-outlined">bolt</span>
|
||||
<span>Test Connection</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="remember-remote-adb">
|
||||
<input
|
||||
type="checkbox"
|
||||
[ngModel]="rememberRemoteAdb()"
|
||||
(ngModelChange)="rememberRemoteAdb.set($event)"
|
||||
/>
|
||||
<span>Remember this endpoint for future Artemis launches</span>
|
||||
</label>
|
||||
|
||||
@if (remoteAdbMessage()) {
|
||||
<div class="remote-adb-status-alert success">
|
||||
<span class="material-symbols-outlined">check_circle</span>
|
||||
<span>{{ remoteAdbMessage() }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (remoteAdbError()) {
|
||||
<div class="remote-adb-status-alert error" role="alert">
|
||||
<span class="material-symbols-outlined">error</span>
|
||||
<span>{{ remoteAdbError() }}</span>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (remoteAdbDevices().length > 0) {
|
||||
<div class="remote-device-results">
|
||||
@for (device of remoteAdbDevices(); track device.serial) {
|
||||
<div class="remote-device-row">
|
||||
<span class="material-symbols-outlined">phone_android</span>
|
||||
<div class="remote-device-copy">
|
||||
<strong>{{ device.model || device.serial }}</strong>
|
||||
<span>{{ device.serial }}</span>
|
||||
</div>
|
||||
<span class="remote-device-state" [class.ready]="device.state === 'device'">
|
||||
{{ device.state === 'device' ? 'Ready' : device.state }}
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (remoteAdbProbeResult()?.success && !isProbedRemoteAdbActive()) {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-remote-adb-connect btn-use-tested-endpoint"
|
||||
[disabled]="isActivatingRemoteAdb()"
|
||||
(click)="activateRemoteAdbServer()"
|
||||
>
|
||||
@if (isActivatingRemoteAdb()) {
|
||||
<span class="material-symbols-outlined spinning">sync</span>
|
||||
<span>Connecting...</span>
|
||||
} @else {
|
||||
<span class="material-symbols-outlined">check</span>
|
||||
<span>{{ remoteAdbHasReadyDevice() ? 'Use Connection' : 'Use Anyway' }}</span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
<p class="remote-adb-explanation">
|
||||
Artemis sends all ADB commands to this server instead of the default local server. Any phone visible to that server becomes available to Artemis.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1416,7 +1600,7 @@
|
||||
</div>
|
||||
<button type="button" class="btn-retry-auth" (click)="restartAdbServer()">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
<span>Restart ADB & Check</span>
|
||||
<span>{{ isRemoteAdbServer() ? 'Refresh Devices' : 'Restart ADB & Check' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -110,12 +110,16 @@ $text-light: #94a3b8;
|
||||
|
||||
@mixin glass-primary-btn {
|
||||
background: linear-gradient(135deg, rgba(26, 115, 232, 0.92) 0%, rgba(37, 99, 235, 0.85) 100%);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
backdrop-filter: blur(10px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(180%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.28);
|
||||
box-shadow: 0 4px 14px rgba(26, 115, 232, 0.32), inset 0 1px 1px rgba(255, 255, 255, 0.45);
|
||||
color: #ffffff !important;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
span, .material-symbols-outlined, .run-icon, .action-icon {
|
||||
color: #ffffff !important;
|
||||
@@ -146,12 +150,17 @@ $text-light: #94a3b8;
|
||||
|
||||
@mixin glass-secondary-btn {
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(14px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(14px) saturate(180%);
|
||||
backdrop-filter: blur(10px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(180%);
|
||||
border: 1px solid rgba(226, 232, 240, 0.88);
|
||||
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.05), inset 0 1px 1px rgba(255, 255, 255, 0.95);
|
||||
color: $text-secondary;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
@@ -213,10 +222,13 @@ $text-light: #94a3b8;
|
||||
opacity: 0.9;
|
||||
transition: opacity 0.8s ease;
|
||||
|
||||
/* The glow orbs are pre-softened radial gradients. A `filter: blur(90px+)`
|
||||
on top of them looked nearly identical but forced the GPU to re-blur
|
||||
half-megapixel layers on every frame of the float animations — the
|
||||
gradients below bake the same softness in for free. */
|
||||
.glow {
|
||||
position: absolute;
|
||||
border-radius: 50%;
|
||||
filter: blur(90px);
|
||||
transform: translate3d(0, 0, 0);
|
||||
will-change: transform, opacity;
|
||||
pointer-events: none;
|
||||
@@ -227,7 +239,7 @@ $text-light: #94a3b8;
|
||||
left: 10%;
|
||||
width: 520px;
|
||||
height: 520px;
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.04) 55%, transparent 75%);
|
||||
background: radial-gradient(circle, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.04) 45%, transparent 82%);
|
||||
animation: floatGlow1 22s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@@ -236,7 +248,7 @@ $text-light: #94a3b8;
|
||||
right: 12%;
|
||||
width: 560px;
|
||||
height: 560px;
|
||||
background: radial-gradient(circle, rgba(147, 51, 234, 0.08) 0%, rgba(219, 39, 119, 0.03) 55%, transparent 75%);
|
||||
background: radial-gradient(circle, rgba(147, 51, 234, 0.08) 0%, rgba(219, 39, 119, 0.03) 45%, transparent 82%);
|
||||
animation: floatGlow2 26s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@@ -245,7 +257,7 @@ $text-light: #94a3b8;
|
||||
left: 30%;
|
||||
width: 460px;
|
||||
height: 460px;
|
||||
background: radial-gradient(circle, rgba(6, 182, 212, 0.08) 0%, rgba(59, 130, 246, 0.03) 55%, transparent 75%);
|
||||
background: radial-gradient(circle, rgba(6, 182, 212, 0.08) 0%, rgba(59, 130, 246, 0.03) 45%, transparent 82%);
|
||||
animation: floatGlow3 20s ease-in-out infinite alternate;
|
||||
}
|
||||
|
||||
@@ -256,9 +268,8 @@ $text-light: #94a3b8;
|
||||
margin: 0 auto;
|
||||
width: 720px;
|
||||
height: 520px;
|
||||
background: radial-gradient(circle, rgba(245, 158, 11, 0.09) 0%, rgba(217, 119, 6, 0.035) 45%, transparent 75%);
|
||||
background: radial-gradient(circle, rgba(245, 158, 11, 0.09) 0%, rgba(217, 119, 6, 0.035) 38%, transparent 82%);
|
||||
opacity: 0;
|
||||
filter: blur(100px);
|
||||
transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: readyPulseGlow 9s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
@@ -271,9 +282,8 @@ $text-light: #94a3b8;
|
||||
margin: 0 auto;
|
||||
width: 700px;
|
||||
height: 520px;
|
||||
background: radial-gradient(circle, rgba(16, 185, 129, 0.14) 0%, rgba(6, 182, 212, 0.08) 45%, transparent 75%);
|
||||
background: radial-gradient(circle, rgba(16, 185, 129, 0.14) 0%, rgba(6, 182, 212, 0.08) 38%, transparent 82%);
|
||||
opacity: 0;
|
||||
filter: blur(100px);
|
||||
transition: opacity 1s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
animation: readyPulseGlow 7s ease-in-out infinite alternate;
|
||||
pointer-events: none;
|
||||
@@ -286,9 +296,8 @@ $text-light: #94a3b8;
|
||||
margin: 0 auto;
|
||||
width: 680px;
|
||||
height: 520px;
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.18) 0%, rgba(147, 51, 234, 0.08) 45%, transparent 75%);
|
||||
background: radial-gradient(circle, rgba(99, 102, 241, 0.18) 0%, rgba(147, 51, 234, 0.08) 38%, transparent 82%);
|
||||
opacity: 0;
|
||||
filter: blur(95px);
|
||||
transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
pointer-events: none;
|
||||
}
|
||||
@@ -383,8 +392,8 @@ $text-light: #94a3b8;
|
||||
max-width: 520px;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(16px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(180%);
|
||||
backdrop-filter: blur(12px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(180%);
|
||||
padding: 3px;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid rgba(226, 232, 240, 0.85);
|
||||
@@ -392,7 +401,9 @@ $text-light: #94a3b8;
|
||||
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.9);
|
||||
user-select: none;
|
||||
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
@@ -1717,6 +1728,12 @@ $text-light: #94a3b8;
|
||||
.meta-dot {
|
||||
color: $text-light;
|
||||
}
|
||||
|
||||
.remote-endpoint-label {
|
||||
color: $primary;
|
||||
font-family: $font-mono;
|
||||
font-size: 11px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1739,6 +1756,7 @@ $text-light: #94a3b8;
|
||||
animation: statusDotPulse 2s infinite cubic-bezier(0.4, 0, 0.6, 1);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.multi-device-switcher {
|
||||
@@ -2365,10 +2383,14 @@ $text-light: #94a3b8;
|
||||
|
||||
.connection-methods-nav {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
|
||||
@media (max-width: 860px) {
|
||||
@media (max-width: 1100px) {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
@media (max-width: 620px) {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -2421,37 +2443,12 @@ $text-light: #94a3b8;
|
||||
.method-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.method-title {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: $text-main;
|
||||
}
|
||||
|
||||
.method-badge-pill {
|
||||
font-size: 9.5px;
|
||||
font-weight: 700;
|
||||
padding: 1px 5px;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
|
||||
&.detected, &.info {
|
||||
background: $primary-light;
|
||||
color: $primary;
|
||||
}
|
||||
|
||||
&.recommended {
|
||||
background: $emerald-light;
|
||||
color: $emerald;
|
||||
}
|
||||
|
||||
&.neutral {
|
||||
background: #f1f5f9;
|
||||
color: $text-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.method-desc {
|
||||
@@ -2511,6 +2508,68 @@ $text-light: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
.active-remote-notice {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: #fffbeb;
|
||||
border: 1px solid $amber-border;
|
||||
border-radius: 8px;
|
||||
|
||||
.active-remote-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
color: #78350f;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
|
||||
.material-symbols-outlined {
|
||||
color: $amber;
|
||||
font-size: 17px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
strong {
|
||||
font-family: $font-mono;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-use-local-adb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
min-height: 32px;
|
||||
padding: 0 12px;
|
||||
@include glass-secondary-btn;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
.btn-use-local-adb {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
.method-details-panel {
|
||||
background: #ffffff;
|
||||
border: 1px solid $card-border;
|
||||
@@ -2852,7 +2911,8 @@ $text-light: #94a3b8;
|
||||
}
|
||||
|
||||
.usb-guide-intro,
|
||||
.wifi-guide-intro {
|
||||
.wifi-guide-intro,
|
||||
.remote-guide-intro {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
@@ -3086,6 +3146,258 @@ $text-light: #94a3b8;
|
||||
margin-top: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
&.remote-adb-panel {
|
||||
.remote-adb-interactive-box {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid $card-border;
|
||||
border-radius: 8px;
|
||||
|
||||
.remote-adb-form-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.remote-adb-input-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
|
||||
&.host-col {
|
||||
flex: 1 1 220px;
|
||||
}
|
||||
|
||||
&.port-col {
|
||||
flex: 0 1 120px;
|
||||
}
|
||||
|
||||
.remote-adb-label {
|
||||
color: $text-muted;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.remote-adb-input-wrap {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 36px;
|
||||
padding: 0 9px;
|
||||
background: #ffffff;
|
||||
border: 1px solid $card-border;
|
||||
border-radius: 6px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
|
||||
&:focus-within {
|
||||
border-color: $primary;
|
||||
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.1);
|
||||
}
|
||||
|
||||
.wrap-icon {
|
||||
margin-right: 6px;
|
||||
color: $text-light;
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.remote-adb-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
border: 0;
|
||||
outline: 0;
|
||||
background: transparent;
|
||||
color: $text-main;
|
||||
font-family: $font-mono;
|
||||
font-size: 12px;
|
||||
|
||||
&::placeholder {
|
||||
color: $text-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remote-adb-btn-col {
|
||||
flex-shrink: 0;
|
||||
|
||||
.btn-remote-adb-connect {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 5px;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
@include glass-primary-btn;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
||||
.remote-adb-input-col,
|
||||
.remote-adb-input-col.host-col,
|
||||
.remote-adb-input-col.port-col,
|
||||
.remote-adb-btn-col {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.remote-adb-btn-col .btn-remote-adb-connect {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remember-remote-adb {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
align-self: flex-start;
|
||||
gap: 7px;
|
||||
color: $text-secondary;
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
|
||||
input {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
margin: 0;
|
||||
accent-color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-use-tested-endpoint {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
align-self: flex-end;
|
||||
gap: 5px;
|
||||
min-width: 150px;
|
||||
height: 36px;
|
||||
padding: 0 16px;
|
||||
@include glass-primary-btn;
|
||||
border-radius: 6px;
|
||||
font-family: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.remote-adb-status-alert {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
padding: 7px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 15px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.success {
|
||||
color: $emerald-dark;
|
||||
background: $emerald-light;
|
||||
border: 1px solid $emerald-border;
|
||||
}
|
||||
|
||||
&.error {
|
||||
color: $rose-dark;
|
||||
background: $rose-light;
|
||||
border: 1px solid $rose-border;
|
||||
}
|
||||
}
|
||||
|
||||
.remote-device-results {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: #ffffff;
|
||||
border: 1px solid $card-border;
|
||||
border-radius: 7px;
|
||||
|
||||
.remote-device-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border-bottom: 1px solid #f1f5f9;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
> .material-symbols-outlined {
|
||||
color: $primary;
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.remote-device-copy {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
|
||||
strong {
|
||||
color: $text-main;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
span {
|
||||
overflow: hidden;
|
||||
color: $text-muted;
|
||||
font-family: $font-mono;
|
||||
font-size: 10.5px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
.remote-device-state {
|
||||
color: $amber-dark;
|
||||
font-size: 10.5px;
|
||||
font-weight: 600;
|
||||
text-transform: capitalize;
|
||||
|
||||
&.ready {
|
||||
color: $emerald-dark;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.remote-adb-explanation {
|
||||
margin: 0;
|
||||
color: $text-muted;
|
||||
font-size: 11.5px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3496,13 +3808,15 @@ $text-light: #94a3b8;
|
||||
/* Unified Sculptured Composer (Linear / Claude / ChatGPT Grade) */
|
||||
.composer-card {
|
||||
background: rgba(255, 255, 255, 0.88);
|
||||
backdrop-filter: blur(28px) saturate(190%);
|
||||
-webkit-backdrop-filter: blur(28px) saturate(190%);
|
||||
backdrop-filter: blur(16px) saturate(190%);
|
||||
-webkit-backdrop-filter: blur(16px) saturate(190%);
|
||||
border: 1px solid rgba(255, 255, 255, 0.95);
|
||||
box-shadow: 0 20px 50px -12px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.95);
|
||||
border-radius: 20px;
|
||||
padding: 18px 20px 14px;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
background 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
position: relative;
|
||||
|
||||
&.is-pro-active {
|
||||
@@ -4125,12 +4439,15 @@ $text-light: #94a3b8;
|
||||
padding: 14px 16px;
|
||||
border-radius: 16px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
backdrop-filter: blur(20px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(20px) saturate(180%);
|
||||
backdrop-filter: blur(10px) saturate(180%);
|
||||
-webkit-backdrop-filter: blur(10px) saturate(180%);
|
||||
border: 1px solid rgba(226, 232, 240, 0.85);
|
||||
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.95);
|
||||
cursor: pointer;
|
||||
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: background 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.22s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.22s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
user-select: none;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -4367,18 +4684,17 @@ $text-light: #94a3b8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Transform-only: animating opacity here overrode the .glow-pending/.glow-ready
|
||||
fade transitions and kept both state glows permanently visible. */
|
||||
@keyframes readyPulseGlow {
|
||||
0% {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.7;
|
||||
}
|
||||
50% {
|
||||
transform: scale(1.06);
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(0.98);
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4411,3 +4727,13 @@ $text-light: #94a3b8;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect the user's motion preference: freeze the purely decorative ambient
|
||||
animations (aurora drift, pulses, sparkles). Functional spinners keep going. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.home-container .aurora-background .glow,
|
||||
.sparkle-icon,
|
||||
.status-dot {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -14,13 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, signal, computed, effect, inject, OnInit, OnDestroy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, signal, computed, effect, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { Router, RouterLink } from '@angular/router';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { SystemService } from '../../services/system.service';
|
||||
import { DeviceInfo, ProbeResult } from '../../core/models/system.model';
|
||||
import {
|
||||
AdbServerConnectionResult,
|
||||
AdbServerDevice,
|
||||
DeviceInfo,
|
||||
ProbeResult
|
||||
} from '../../core/models/system.model';
|
||||
import {
|
||||
AppReference,
|
||||
SmartSuggestion,
|
||||
@@ -30,12 +35,15 @@ import { TaskRecommendationService } from '../../core/services/task-recommendati
|
||||
|
||||
export type { AppReference, SmartSuggestion, SuggestionCategory };
|
||||
|
||||
type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-home',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [FormsModule],
|
||||
templateUrl: './home.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './home.component.scss'
|
||||
})
|
||||
export class HomeComponent implements OnInit, OnDestroy {
|
||||
@@ -47,8 +55,8 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
// High-level navigation mode: 'diagnostics' (System Setup Guide) vs 'launcher' (Task Execution)
|
||||
public activeTab = signal<'diagnostics' | 'launcher'>('launcher');
|
||||
|
||||
// Interactive guide sub-tab inside ADB section: 'emulator' | 'usb' | 'wifi'
|
||||
public activeAdbGuideTab = signal<'emulator' | 'usb' | 'wifi'>('emulator');
|
||||
// Interactive guide sub-tab inside the ADB section
|
||||
public activeAdbGuideTab = signal<AdbGuideTab>('emulator');
|
||||
public emulatorSetupMode = signal<'studio' | 'cli'>('studio');
|
||||
|
||||
// Interactive guide tab for LLM / OCR credentials: 'gemini' | 'ocr'
|
||||
@@ -90,6 +98,29 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
public wifiConnectMessage = signal<string | null>(null);
|
||||
public wifiConnectError = signal<string | null>(null);
|
||||
public adbRestartFeedback = signal<string | null>(null);
|
||||
public showConnectionMethods = signal<boolean>(false);
|
||||
|
||||
// ADB server endpoint connection state
|
||||
public remoteAdbHost = signal<string>('127.0.0.1');
|
||||
public remoteAdbPort = signal<string>('5038');
|
||||
public rememberRemoteAdb = signal<boolean>(true);
|
||||
public isConnectingRemoteAdb = signal<boolean>(false);
|
||||
public isActivatingRemoteAdb = signal<boolean>(false);
|
||||
public isSwitchingToLocalAdb = signal<boolean>(false);
|
||||
public remoteAdbMessage = signal<string | null>(null);
|
||||
public remoteAdbError = signal<string | null>(null);
|
||||
public remoteAdbDevices = signal<AdbServerDevice[]>([]);
|
||||
public remoteAdbProbeResult = signal<AdbServerConnectionResult | null>(null);
|
||||
public adbServerStatus = computed(() => this.systemService.adbServerStatus());
|
||||
public isRemoteAdbServer = computed(() => this.systemService.isRemoteAdbServer());
|
||||
public remoteAdbHasReadyDevice = computed(() =>
|
||||
this.remoteAdbDevices().some(device => device.state === 'device')
|
||||
);
|
||||
public isProbedRemoteAdbActive = computed(() => {
|
||||
const tested = this.remoteAdbProbeResult()?.endpoint;
|
||||
const active = this.adbServerStatus()?.endpoint;
|
||||
return !!tested && !!active && tested.identity === active.identity;
|
||||
});
|
||||
|
||||
public wifiCommand = computed(() => {
|
||||
const h = this.wifiHost().trim() || '<phone-ip>';
|
||||
@@ -334,6 +365,16 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
// Initial fetch of system readiness & model configuration
|
||||
this.systemService.fetchReadiness().subscribe();
|
||||
this.systemService.fetchModelConfigEnv().subscribe();
|
||||
this.systemService.fetchAdbServerStatus().subscribe({
|
||||
next: status => {
|
||||
if (status.endpoint.mode === 'remote') {
|
||||
this.remoteAdbHost.set(status.endpoint.host);
|
||||
this.remoteAdbPort.set(String(status.endpoint.port));
|
||||
this.activeAdbGuideTab.set('remote');
|
||||
}
|
||||
},
|
||||
error: () => {}
|
||||
});
|
||||
|
||||
window.addEventListener('focus', this.focusListener);
|
||||
}
|
||||
@@ -354,8 +395,10 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.selectedOs.set(os);
|
||||
}
|
||||
|
||||
public setAdbGuideTab(tab: 'emulator' | 'usb' | 'wifi'): void {
|
||||
public setAdbGuideTab(tab: AdbGuideTab): void {
|
||||
this.activeAdbGuideTab.set(tab);
|
||||
this.remoteAdbError.set(null);
|
||||
this.remoteAdbMessage.set(null);
|
||||
}
|
||||
|
||||
public setEmulatorSetupMode(mode: 'studio' | 'cli'): void {
|
||||
@@ -609,7 +652,9 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
this.adbRestartFeedback.set(null);
|
||||
this.systemService.restartAdb().subscribe({
|
||||
next: (res) => {
|
||||
this.adbRestartFeedback.set('ADB Refreshed ✓');
|
||||
this.adbRestartFeedback.set(
|
||||
res?.restart_result?.skipped ? 'Devices Refreshed ✓' : 'ADB Refreshed ✓'
|
||||
);
|
||||
setTimeout(() => this.adbRestartFeedback.set(null), 2500);
|
||||
},
|
||||
error: () => {
|
||||
@@ -620,6 +665,10 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
|
||||
public connectWifiDevice(): void {
|
||||
if (this.isRemoteAdbServer()) {
|
||||
this.wifiConnectError.set('Switch to local ADB before connecting a Wireless ADB device.');
|
||||
return;
|
||||
}
|
||||
const host = this.wifiHost().trim();
|
||||
const portStr = this.wifiPort().trim() || '5555';
|
||||
const port = parseInt(portStr, 10) || 5555;
|
||||
@@ -651,7 +700,123 @@ export class HomeComponent implements OnInit, OnDestroy {
|
||||
});
|
||||
}
|
||||
|
||||
public connectRemoteAdbServer(): void {
|
||||
const host = this.remoteAdbHost().trim();
|
||||
const port = Number(this.remoteAdbPort().trim());
|
||||
|
||||
if (!host) {
|
||||
this.remoteAdbError.set('Enter the host name or IP address of the ADB server.');
|
||||
return;
|
||||
}
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
this.remoteAdbError.set('Enter a port between 1 and 65535.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isConnectingRemoteAdb.set(true);
|
||||
this.remoteAdbError.set(null);
|
||||
this.remoteAdbMessage.set(null);
|
||||
this.remoteAdbDevices.set([]);
|
||||
this.remoteAdbProbeResult.set(null);
|
||||
|
||||
this.systemService.probeAdbServer(host, port).subscribe({
|
||||
next: response => {
|
||||
this.isConnectingRemoteAdb.set(false);
|
||||
const result = response.connection_result;
|
||||
if (result.success) {
|
||||
this.remoteAdbProbeResult.set(result);
|
||||
this.remoteAdbDevices.set(result.devices || []);
|
||||
this.remoteAdbMessage.set(result.message);
|
||||
} else {
|
||||
this.remoteAdbError.set(result.message);
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
this.isConnectingRemoteAdb.set(false);
|
||||
this.remoteAdbError.set(
|
||||
error?.error?.detail || 'Unable to test the ADB server endpoint.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public activateRemoteAdbServer(): void {
|
||||
const tested = this.remoteAdbProbeResult();
|
||||
if (!tested?.success) {
|
||||
this.remoteAdbError.set('Test the endpoint before using it.');
|
||||
return;
|
||||
}
|
||||
|
||||
this.isActivatingRemoteAdb.set(true);
|
||||
this.remoteAdbError.set(null);
|
||||
this.systemService.connectAdbServer(
|
||||
tested.endpoint.host,
|
||||
tested.endpoint.port,
|
||||
this.rememberRemoteAdb()
|
||||
).subscribe({
|
||||
next: response => {
|
||||
this.isActivatingRemoteAdb.set(false);
|
||||
const result = response.connection_result;
|
||||
if (result.success) {
|
||||
this.remoteAdbProbeResult.set(result);
|
||||
this.remoteAdbDevices.set(result.devices || []);
|
||||
this.remoteAdbMessage.set(result.message);
|
||||
} else {
|
||||
this.remoteAdbError.set(result.message);
|
||||
}
|
||||
},
|
||||
error: error => {
|
||||
this.isActivatingRemoteAdb.set(false);
|
||||
this.remoteAdbError.set(
|
||||
error?.error?.detail || 'Unable to use the ADB server endpoint.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public updateRemoteAdbHost(value: string): void {
|
||||
this.remoteAdbHost.set(value);
|
||||
this.clearRemoteAdbProbe();
|
||||
}
|
||||
|
||||
public updateRemoteAdbPort(value: string): void {
|
||||
this.remoteAdbPort.set(value);
|
||||
this.clearRemoteAdbProbe();
|
||||
}
|
||||
|
||||
private clearRemoteAdbProbe(): void {
|
||||
this.remoteAdbProbeResult.set(null);
|
||||
this.remoteAdbDevices.set([]);
|
||||
this.remoteAdbMessage.set(null);
|
||||
this.remoteAdbError.set(null);
|
||||
}
|
||||
|
||||
public switchToLocalAdbServer(): void {
|
||||
this.isSwitchingToLocalAdb.set(true);
|
||||
this.remoteAdbError.set(null);
|
||||
this.systemService.useLocalAdbServer(true).subscribe({
|
||||
next: response => {
|
||||
this.isSwitchingToLocalAdb.set(false);
|
||||
this.remoteAdbDevices.set([]);
|
||||
this.remoteAdbMessage.set(response.connection_result.message);
|
||||
},
|
||||
error: error => {
|
||||
this.isSwitchingToLocalAdb.set(false);
|
||||
this.remoteAdbError.set(
|
||||
error?.error?.detail || 'Unable to switch back to the local ADB server.'
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
public toggleConnectionMethods(): void {
|
||||
this.showConnectionMethods.update(value => !value);
|
||||
}
|
||||
|
||||
public launchAvdEmulator(avdName: string): void {
|
||||
if (this.isRemoteAdbServer()) {
|
||||
return;
|
||||
}
|
||||
this.systemService.launchEmulator(avdName).subscribe();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,16 +14,17 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, HostListener } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, HostListener, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
import { LegacyAgentStreamComponent } from '../../components/legacy-agent-stream/legacy-agent-stream.component';
|
||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-legacy-workspace',
|
||||
standalone: true,
|
||||
imports: [CommonModule, LegacyAgentStreamComponent, ChatInterfaceComponent],
|
||||
imports: [LegacyAgentStreamComponent, ChatInterfaceComponent],
|
||||
templateUrl: './legacy-workspace.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './legacy-workspace.component.scss'
|
||||
})
|
||||
export class LegacyWorkspaceComponent {
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
<!--
|
||||
Copyright 2026 Google LLC
|
||||
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
|
||||
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
|
||||
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.
|
||||
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.
|
||||
-->
|
||||
|
||||
<div class="workspace-container" [class.is-resizing]="isDragging">
|
||||
<div class="workspace-container" [class.is-resizing]="isDragging()">
|
||||
<!-- Left Main Panel: Raw Agent Log Stream -->
|
||||
<main class="left-panel glass-panel">
|
||||
<app-agent-stream></app-agent-stream>
|
||||
@@ -23,13 +23,15 @@
|
||||
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
|
||||
[class.is-focused]="isInputFocused()" [class.has-text]="!!taskInput.trim()" [class.is-running]="isTaskRunning()">
|
||||
<!-- Floating Error / Notification Toast -->
|
||||
<div class="floating-dock-toast error" *ngIf="errorMessage">
|
||||
<span class="material-symbols-outlined toast-icon">error</span>
|
||||
<span class="toast-text">{{ errorMessage }}</span>
|
||||
<button type="button" class="btn-toast-close" (click)="errorMessage = null" aria-label="Close error message">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
@if (errorMessage()) {
|
||||
<div class="floating-dock-toast error">
|
||||
<span class="material-symbols-outlined toast-icon">error</span>
|
||||
<span class="toast-text">{{ errorMessage() }}</span>
|
||||
<button type="button" class="btn-toast-close" (click)="errorMessage.set(null)" aria-label="Close error message">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
<!-- Dock Row (Contains the Main Card and the Separate Stop Button when running) -->
|
||||
<div class="floating-dock-row">
|
||||
@@ -52,9 +54,9 @@
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 0,25 Q 300,55 600,20 T 1200,25 Q 1500,55 1800,20 T 2400,25 L 2400,80 L 0,80 Z"
|
||||
fill="url(#waveGradBack)"></path>
|
||||
fill="url(#waveGradBack)"></path>
|
||||
</svg>
|
||||
|
||||
|
||||
<!-- Mid Fluid Wave -->
|
||||
<svg class="liquid-wave wave-mid" viewBox="0 0 2400 80" preserveAspectRatio="none">
|
||||
<defs>
|
||||
@@ -66,9 +68,9 @@
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 0,35 Q 300,5 600,45 T 1200,35 Q 1500,5 1800,45 T 2400,35 L 2400,80 L 0,80 Z"
|
||||
fill="url(#waveGradMid)"></path>
|
||||
fill="url(#waveGradMid)"></path>
|
||||
</svg>
|
||||
|
||||
|
||||
<!-- Front Fluid Wave -->
|
||||
<svg class="liquid-wave wave-front" viewBox="0 0 2400 80" preserveAspectRatio="none">
|
||||
<defs>
|
||||
@@ -80,16 +82,16 @@
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="M 0,18 Q 300,50 600,15 T 1200,18 Q 1500,50 1800,15 T 2400,18 L 2400,80 L 0,80 Z"
|
||||
fill="url(#waveGradFront)"></path>
|
||||
fill="url(#waveGradFront)"></path>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Dormant / Capsule Peek View (Visible when mouse is away and input idle) -->
|
||||
<div class="capsule-peek-view">
|
||||
<span class="capsule-placeholder">Start a new task...</span>
|
||||
</div>
|
||||
|
||||
|
||||
<!-- Expanded Full View Content (Visible on hover, focus, or text) -->
|
||||
<div class="expanded-card-content">
|
||||
<div class="card-main-input-row">
|
||||
@@ -101,7 +103,7 @@
|
||||
(focus)="onInputFocus()"
|
||||
(blur)="onInputBlur()"
|
||||
rows="1"
|
||||
[disabled]="isSubmitting"
|
||||
[disabled]="isSubmitting()"
|
||||
[placeholder]="selectedProfile() === 'pro'
|
||||
? 'Pro: Multi-agent cognitive graph architecture for complex multi-step tasks'
|
||||
: 'Flash: Reactive fast loop for standard app workflows within 25 steps'"
|
||||
@@ -111,30 +113,38 @@
|
||||
</div>
|
||||
|
||||
<!-- Clear Button (visible when text is entered) -->
|
||||
<button *ngIf="taskInput.trim() && !isSubmitting" type="button" class="dock-circle-btn btn-clear"
|
||||
(click)="clearInput($event)" title="Clear input text" aria-label="Clear input text">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
|
||||
@if (taskInput.trim() && !isSubmitting()) {
|
||||
<button type="button" class="dock-circle-btn btn-clear"
|
||||
(click)="clearInput($event)" title="Clear input text" aria-label="Clear input text">
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Stop Button (Visible when currently viewed task is active) -->
|
||||
<button *ngIf="isTaskRunning()" type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting"
|
||||
(click)="stopTask($event)"
|
||||
[title]="stopButtonTitle()"
|
||||
[attr.aria-label]="stopButtonTitle()"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop</span>
|
||||
</button>
|
||||
@if (isTaskRunning()) {
|
||||
<button type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting()"
|
||||
(click)="stopTask($event)"
|
||||
[title]="stopButtonTitle()"
|
||||
[attr.aria-label]="stopButtonTitle()"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop</span>
|
||||
</button>
|
||||
}
|
||||
|
||||
<!-- Send Button -->
|
||||
<button type="button" class="dock-circle-btn btn-send"
|
||||
[class.has-content]="!!taskInput.trim()"
|
||||
[disabled]="!taskInput.trim() || isSubmitting"
|
||||
[disabled]="!taskInput.trim() || isSubmitting()"
|
||||
(click)="submitTask()"
|
||||
[title]="isSubmitting ? 'Starting task...' : 'Start task (Enter)'"
|
||||
[title]="isSubmitting() ? 'Starting task...' : 'Start task (Enter)'"
|
||||
aria-label="Start task"
|
||||
>
|
||||
<span class="material-symbols-outlined spinning-icon" *ngIf="isSubmitting">sync</span>
|
||||
<span class="material-symbols-outlined" *ngIf="!isSubmitting">arrow_upward</span>
|
||||
>
|
||||
@if (isSubmitting()) {
|
||||
<span class="material-symbols-outlined spinning-icon">sync</span>
|
||||
}
|
||||
@if (!isSubmitting()) {
|
||||
<span class="material-symbols-outlined">arrow_upward</span>
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -162,28 +172,30 @@
|
||||
</div>
|
||||
|
||||
<!-- Separate Standalone Stop Button Beside the Capsule (Visible when currently viewed task is active) -->
|
||||
<button *ngIf="isTaskRunning()" type="button" class="standalone-stop-btn"
|
||||
[disabled]="isSubmitting"
|
||||
(click)="stopTask($event)"
|
||||
[title]="stopButtonTitle()"
|
||||
[attr.aria-label]="stopButtonTitle()"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop</span>
|
||||
</button>
|
||||
@if (isTaskRunning()) {
|
||||
<button type="button" class="standalone-stop-btn"
|
||||
[disabled]="isSubmitting()"
|
||||
(click)="stopTask($event)"
|
||||
[title]="stopButtonTitle()"
|
||||
[attr.aria-label]="stopButtonTitle()"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- Split Pane Drag Resizer -->
|
||||
<div class="resizer" (mousedown)="onDragStart($event)">
|
||||
<div class="resizer-handle"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar Panel: Chat Interface Mock -->
|
||||
<aside class="right-panel" [style.width.px]="rightPanelWidth">
|
||||
<app-chat-interface></app-chat-interface>
|
||||
</aside>
|
||||
|
||||
<!-- Floating Video Replay Player Modal / Window -->
|
||||
<app-floating-video-player></app-floating-video-player>
|
||||
|
||||
<!-- Split Pane Drag Resizer -->
|
||||
<div class="resizer" (mousedown)="onDragStart($event)">
|
||||
<div class="resizer-handle"></div>
|
||||
</div>
|
||||
|
||||
<!-- Right Sidebar Panel: Chat Interface Mock -->
|
||||
<aside class="right-panel" [style.width.px]="rightPanelWidth()">
|
||||
<app-chat-interface></app-chat-interface>
|
||||
</aside>
|
||||
|
||||
<!-- Floating Video Replay Player Modal / Window -->
|
||||
<app-floating-video-player></app-floating-video-player>
|
||||
</div>
|
||||
@@ -104,7 +104,7 @@ $rose-dark: #dc2626;
|
||||
height: 40px;
|
||||
background: rgba(0, 0, 0, 0.15);
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
transition: background 0.2s ease, height 0.2s ease;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -308,7 +308,11 @@ $rose-dark: #dc2626;
|
||||
background: radial-gradient(ellipse at center, rgba(168, 85, 247, 0.45) 0%, rgba(236, 72, 153, 0.25) 50%, transparent 80%);
|
||||
}
|
||||
|
||||
/* Wave Tracks Wrapper with Soft Gaussian Blur */
|
||||
/* Wave Tracks Wrapper. The soft-liquid look comes from blurring each
|
||||
wave individually: blurring the WRAPPER forced the GPU to re-run the
|
||||
gaussian pass every animation frame (child transforms invalidate the
|
||||
parent's filtered layer), while per-wave blur over transform-only
|
||||
animations is rasterized once and then just composited. */
|
||||
.wave-tracks-wrapper {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
@@ -316,7 +320,6 @@ $rose-dark: #dc2626;
|
||||
width: 100%;
|
||||
height: 22px;
|
||||
overflow: hidden;
|
||||
filter: blur(7px); /* Soften sharp vector wave edges into silky liquid light */
|
||||
mask-image: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 60%, transparent 100%);
|
||||
-webkit-mask-image: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 60%, transparent 100%);
|
||||
|
||||
@@ -327,7 +330,7 @@ $rose-dark: #dc2626;
|
||||
width: 200%;
|
||||
height: 100%;
|
||||
will-change: transform;
|
||||
filter: blur(3px); /* Secondary soft layer blur */
|
||||
filter: blur(8px); /* Silky liquid softness, applied per cached wave layer */
|
||||
|
||||
&.wave-back {
|
||||
opacity: 0.45;
|
||||
@@ -562,7 +565,11 @@ $rose-dark: #dc2626;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: background 0.18s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
color 0.18s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
transform 0.18s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.18s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
border-color 0.18s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 18px;
|
||||
@@ -695,7 +702,9 @@ $rose-dark: #dc2626;
|
||||
font-size: 11px;
|
||||
font-weight: 550;
|
||||
cursor: pointer;
|
||||
transition: all 0.16s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
transition: color 0.16s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
background 0.16s cubic-bezier(0.16, 1, 0.3, 1),
|
||||
box-shadow 0.16s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
outline: none;
|
||||
user-select: none;
|
||||
box-sizing: border-box;
|
||||
@@ -863,3 +872,14 @@ $rose-dark: #dc2626;
|
||||
transform: scaleX(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
/* Respect the user's motion preference: freeze the decorative liquid waves
|
||||
and pulses while keeping the frosted dock visuals intact. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.liquid-wave,
|
||||
.wave-glow-ambient,
|
||||
.pulse-indicator-dot,
|
||||
.running-pulse-dot {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, HostListener, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy, NgZone, DestroyRef, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
|
||||
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
|
||||
@@ -26,26 +26,35 @@ import { AgentService } from '../../services/agent.service';
|
||||
selector: 'app-workspace',
|
||||
standalone: true,
|
||||
imports: [
|
||||
CommonModule,
|
||||
FormsModule,
|
||||
AgentStreamComponent,
|
||||
ChatInterfaceComponent,
|
||||
FloatingVideoPlayerComponent
|
||||
],
|
||||
],
|
||||
templateUrl: './workspace.component.html',
|
||||
styleUrl: './workspace.component.scss'
|
||||
styleUrl: './workspace.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class WorkspaceComponent implements OnInit {
|
||||
public agentService = inject(AgentService);
|
||||
private zone = inject(NgZone);
|
||||
private destroyRef = inject(DestroyRef);
|
||||
|
||||
// Default right panel width to 1/3 of the screen (or 450px as fallback)
|
||||
public rightPanelWidth = typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450;
|
||||
public isDragging = false;
|
||||
public rightPanelWidth = signal<number>(
|
||||
typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450
|
||||
);
|
||||
public isDragging = signal<boolean>(false);
|
||||
private dragWidthRafId: number | null = null;
|
||||
private pendingDragWidth = 0;
|
||||
|
||||
// Floating Command Bar State
|
||||
public taskInput: string = '';
|
||||
public isSubmitting: boolean = false;
|
||||
public errorMessage: string | null = null;
|
||||
// Floating Command Bar State. taskInput is backed by a signal so computed
|
||||
// expressions (isBarExpanded) genuinely track it under OnPush.
|
||||
private taskInputSignal = signal<string>('');
|
||||
public get taskInput(): string { return this.taskInputSignal(); }
|
||||
public set taskInput(value: string) { this.taskInputSignal.set(value); }
|
||||
public isSubmitting = signal<boolean>(false);
|
||||
public errorMessage = signal<string | null>(null);
|
||||
public selectedProfile = signal<'flash' | 'pro'>('flash');
|
||||
|
||||
// Expand States (Signals for 0-latency reactivity)
|
||||
@@ -61,6 +70,16 @@ export class WorkspaceComponent implements OnInit {
|
||||
this.selectedProfile.set(saved);
|
||||
}
|
||||
}
|
||||
|
||||
// The global ⌘K/Ctrl+K shortcut is registered outside the Angular zone so
|
||||
// ordinary typing never schedules an extra change-detection pass.
|
||||
this.zone.runOutsideAngular(() => {
|
||||
window.addEventListener('keydown', this.onGlobalKeyDown);
|
||||
});
|
||||
this.destroyRef.onDestroy(() => {
|
||||
window.removeEventListener('keydown', this.onGlobalKeyDown);
|
||||
this.detachDragListeners();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,13 +198,12 @@ export class WorkspaceComponent implements OnInit {
|
||||
/**
|
||||
* Global keyboard shortcut (⌘K or Ctrl+K) to focus input bar from anywhere
|
||||
*/
|
||||
@HostListener('window:keydown', ['$event'])
|
||||
public onGlobalKeyDown(event: KeyboardEvent): void {
|
||||
private onGlobalKeyDown = (event: KeyboardEvent): void => {
|
||||
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
||||
event.preventDefault();
|
||||
this.focusInput();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Handle enter key in floating dock textarea
|
||||
@@ -202,12 +220,12 @@ export class WorkspaceComponent implements OnInit {
|
||||
*/
|
||||
public submitTask(): void {
|
||||
const goal = this.taskInput.trim();
|
||||
if (!goal || this.isSubmitting) {
|
||||
if (!goal || this.isSubmitting()) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
if (this.dockInputRef?.nativeElement) {
|
||||
this.dockInputRef.nativeElement.blur();
|
||||
@@ -220,15 +238,15 @@ export class WorkspaceComponent implements OnInit {
|
||||
if (this.dockInputRef?.nativeElement) {
|
||||
this.dockInputRef.nativeElement.style.height = 'auto';
|
||||
}
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
this.agentService.fetchStatus();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to submit task:', err);
|
||||
this.isSubmitting = false;
|
||||
this.errorMessage = err.error?.detail || 'The runner is busy. Please wait for current task to finish.';
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || 'The runner is busy. Please wait for current task to finish.');
|
||||
setTimeout(() => {
|
||||
this.errorMessage = null;
|
||||
this.errorMessage.set(null);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
@@ -245,28 +263,31 @@ export class WorkspaceComponent implements OnInit {
|
||||
return;
|
||||
}
|
||||
const targetSessionId = this.agentService.currentSessionId();
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.agentService.stopTask(targetSessionId, false);
|
||||
setTimeout(() => {
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle mouse down on resizer bar to start dragging
|
||||
* Handle mouse down on resizer bar to start dragging. The move/up listeners
|
||||
* are attached only for the duration of the drag and run outside the Angular
|
||||
* zone: idle mouse movement over the workspace never triggers change
|
||||
* detection, and drag updates are coalesced to one per animation frame.
|
||||
*/
|
||||
public onDragStart(event: MouseEvent): void {
|
||||
this.isDragging = true;
|
||||
this.isDragging.set(true);
|
||||
event.preventDefault();
|
||||
this.zone.runOutsideAngular(() => {
|
||||
document.addEventListener('mousemove', this.onMouseMove);
|
||||
document.addEventListener('mouseup', this.onMouseUp);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to global mousemove events to adjust the width dynamically
|
||||
*/
|
||||
@HostListener('document:mousemove', ['$event'])
|
||||
public onMouseMove(event: MouseEvent): void {
|
||||
if (!this.isDragging) {
|
||||
private onMouseMove = (event: MouseEvent): void => {
|
||||
if (!this.isDragging()) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -276,15 +297,27 @@ export class WorkspaceComponent implements OnInit {
|
||||
|
||||
// Apply boundary limits to prevent panels from shrinking too much
|
||||
if (newWidth >= minWidth && newWidth <= maxWidth) {
|
||||
this.rightPanelWidth = newWidth;
|
||||
this.pendingDragWidth = newWidth;
|
||||
if (this.dragWidthRafId === null) {
|
||||
this.dragWidthRafId = requestAnimationFrame(() => {
|
||||
this.dragWidthRafId = null;
|
||||
this.rightPanelWidth.set(this.pendingDragWidth);
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
private onMouseUp = (): void => {
|
||||
this.isDragging.set(false);
|
||||
this.detachDragListeners();
|
||||
};
|
||||
|
||||
private detachDragListeners(): void {
|
||||
document.removeEventListener('mousemove', this.onMouseMove);
|
||||
document.removeEventListener('mouseup', this.onMouseUp);
|
||||
if (this.dragWidthRafId !== null) {
|
||||
cancelAnimationFrame(this.dragWidthRafId);
|
||||
this.dragWidthRafId = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Listen to global mouseup events to stop resizing
|
||||
*/
|
||||
@HostListener('document:mouseup')
|
||||
public onMouseUp(): void {
|
||||
this.isDragging = false;
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Injectable, signal, inject, computed } from '@angular/core';
|
||||
import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable } from 'rxjs';
|
||||
|
||||
@@ -63,6 +63,7 @@ interface SessionVideoResponse {
|
||||
})
|
||||
export class AgentService {
|
||||
private http = inject(HttpClient);
|
||||
private zone = inject(NgZone);
|
||||
private activePauseCardKey: string | null = null;
|
||||
|
||||
// Signals to expose state to components
|
||||
@@ -81,21 +82,25 @@ export class AgentService {
|
||||
const activeList = this.activeTasks();
|
||||
|
||||
const sessionMap = new Map<string, Session>();
|
||||
// Lookup maps keep the per-session merge O(1) instead of scanning the
|
||||
// pending/active lists multiple times for every raw session.
|
||||
const pendingById = new Map(pending.map((p) => [p.session_id, p]));
|
||||
const activeById = new Map(activeList.map((at) => [at.session_id, at]));
|
||||
|
||||
// 1. First add raw sessions from DB
|
||||
raw.forEach((s) => {
|
||||
const activeMatch = activeById.get(s.session_id);
|
||||
const pendingMatch = pendingById.get(s.session_id);
|
||||
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|
||||
|| activeList.some(at => at.session_id === s.session_id);
|
||||
|| !!activeMatch;
|
||||
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
|
||||
let sStatus = s.status;
|
||||
if (!isTerminal) {
|
||||
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentActive;
|
||||
const isPending = !!pendingMatch && !isCurrentActive;
|
||||
sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status);
|
||||
} else {
|
||||
sStatus = (s.status === 'success') ? 'completed' : s.status;
|
||||
}
|
||||
const activeMatch = activeList.find(at => at.session_id === s.session_id);
|
||||
const pendingMatch = this.pendingQueue().find(p => p.session_id === s.session_id);
|
||||
let serial = s.device_serial || s.device_id || activeMatch?.device_id || pendingMatch?.device_serial || null;
|
||||
if (!serial && s.device_info) {
|
||||
try {
|
||||
@@ -122,9 +127,9 @@ export class AgentService {
|
||||
// 2. Add pending queue sessions if not yet in raw sessions
|
||||
pending.forEach((p) => {
|
||||
if (!sessionMap.has(p.session_id)) {
|
||||
const activeMatch = activeById.get(p.session_id);
|
||||
const isCurrentRunning = ((status === 'running' || status === 'paused') && runId === p.session_id)
|
||||
|| activeList.some(at => at.session_id === p.session_id);
|
||||
const activeMatch = activeList.find(at => at.session_id === p.session_id);
|
||||
|| !!activeMatch;
|
||||
const finalPending: Session = {
|
||||
...p,
|
||||
status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'),
|
||||
@@ -241,11 +246,23 @@ export class AgentService {
|
||||
private videoRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private videoWaitStartedAt = 0;
|
||||
|
||||
/**
|
||||
* Step logs only. Streaming text chunks cannot change replay frames, so the
|
||||
* custom equality keeps downstream frame extraction from re-running on every
|
||||
* llm_stream update of the sessionLogs signal.
|
||||
*/
|
||||
private stepLogsForReplay = computed<any[]>(
|
||||
() => this.sessionLogs().filter(
|
||||
(log) => log && (log.type === 'step_updated' || log.type === 'step_recorded')
|
||||
),
|
||||
{ equal: (a, b) => a.length === b.length && a.every((log, i) => log === b[i]) }
|
||||
);
|
||||
|
||||
/**
|
||||
* Computed step replay frames from current session logs
|
||||
*/
|
||||
public currentSessionStepFrames = computed<StepReplayFrame[]>(() => {
|
||||
return extractStepReplayFrames(this.sessionLogs());
|
||||
return extractStepReplayFrames(this.stepLogsForReplay());
|
||||
});
|
||||
|
||||
public hasCurrentSessionStepFrames = computed<boolean>(() => {
|
||||
@@ -328,11 +345,29 @@ export class AgentService {
|
||||
private sessionSnapshotAppliedId = 0;
|
||||
private pendingSnapshotRequests = new Set<number>();
|
||||
|
||||
// Streaming chunks are coalesced into one signal update per short window so a
|
||||
// chatty LLM stream cannot force a full recompute pass per SSE chunk.
|
||||
private pendingStreamChunks = new Map<string, { execId: any; stepId: any; streamType: string; chunk: string }>();
|
||||
private streamFlushTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private lastQueueSignature: string | null = null;
|
||||
private lastActiveTasksSignature: string | null = null;
|
||||
private lastPersistedSessionsJson: string | null = null;
|
||||
private onVisibilityChange = () => {
|
||||
if (typeof document !== 'undefined' && !document.hidden) {
|
||||
this.fetchStatus();
|
||||
this.fetchSessions();
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.restoreSessionsCache();
|
||||
this.fetchSessions();
|
||||
this.startStatusPolling();
|
||||
this.ensureLiveStream();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
}
|
||||
inject(DestroyRef).onDestroy(() => this.destroy());
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -451,20 +486,25 @@ export class AgentService {
|
||||
this.isPaused.set(false);
|
||||
this.pausedError.set(null);
|
||||
this.isRetrying.set(false);
|
||||
this.invalidateStatusSignatures();
|
||||
if (effectiveStopAll) {
|
||||
this.pendingQueue.set([]);
|
||||
}
|
||||
|
||||
// Mark active streaming / pending logs as finished if viewing target session
|
||||
if (!targetSessionId || this.currentSessionId() === targetSessionId) {
|
||||
this.sessionLogs.update((logs) =>
|
||||
logs.map((l) => {
|
||||
this.flushStreamChunks();
|
||||
this.sessionLogs.update((logs) => {
|
||||
if (!logs.some((l) => l.type === 'llm_stream' && !l.data?.isCompleted)) {
|
||||
return logs;
|
||||
}
|
||||
return logs.map((l) => {
|
||||
if (l.type === 'llm_stream' && !l.data?.isCompleted) {
|
||||
return { ...l, data: { ...l.data, isCompleted: true } };
|
||||
}
|
||||
return l;
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Optimistically remove from activeTasks
|
||||
@@ -545,6 +585,7 @@ export class AgentService {
|
||||
*/
|
||||
public deleteSession(sessionId: string): Observable<any> {
|
||||
// 1. Optimistically update local session state immediately
|
||||
this.invalidateStatusSignatures();
|
||||
this.rawSessions.update((list) => list.filter((s) => s.session_id !== sessionId));
|
||||
this.persistSessionsCache(this.rawSessions());
|
||||
this.pendingQueue.update((list) => list.filter((s) => s.session_id !== sessionId));
|
||||
@@ -588,6 +629,7 @@ export class AgentService {
|
||||
*/
|
||||
public clearAllHistory(): Observable<any> {
|
||||
// 1. Optimistically clear all local session state immediately
|
||||
this.invalidateStatusSignatures();
|
||||
this.userPinnedSessionId.set(null);
|
||||
this.rawSessions.set([]);
|
||||
this.clearSessionsCache();
|
||||
@@ -637,6 +679,7 @@ export class AgentService {
|
||||
if (!sessionId) {
|
||||
this.sessionLoadGeneration++;
|
||||
this.pendingSnapshotRequests.clear();
|
||||
this.discardPendingStreamChunks();
|
||||
this.isSessionContentLoading.set(false);
|
||||
this.currentSessionId.set(null);
|
||||
this.sessionLogs.set([]);
|
||||
@@ -650,6 +693,7 @@ export class AgentService {
|
||||
|
||||
const loadGeneration = ++this.sessionLoadGeneration;
|
||||
this.pendingSnapshotRequests.clear();
|
||||
this.discardPendingStreamChunks();
|
||||
this.sessionSnapshotAppliedId = 0;
|
||||
this.isSessionContentLoading.set(true);
|
||||
this.currentSessionId.set(sessionId);
|
||||
@@ -681,6 +725,10 @@ export class AgentService {
|
||||
}
|
||||
|
||||
console.log('Establishing persistent unified live stream via /api/stream');
|
||||
// The stream is registered outside the Angular zone: high-frequency SSE
|
||||
// callbacks must not schedule a change-detection pass each. Signal writes
|
||||
// still notify the render scheduler, so the UI stays live.
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.eventSource = new EventSource('/api/stream');
|
||||
|
||||
this.eventSource.addEventListener('info', () => {
|
||||
@@ -717,6 +765,12 @@ export class AgentService {
|
||||
const parsedData = JSON.parse(event.data);
|
||||
const evtSessionId = parsedData?.session_id;
|
||||
|
||||
// Preserve ordering: buffered stream text must land before any
|
||||
// non-stream event is appended to the session logs.
|
||||
if (eventType !== 'llm_stream') {
|
||||
this.flushStreamChunks();
|
||||
}
|
||||
|
||||
if (eventType === 'recording_ready' || eventType === 'recording_failed') {
|
||||
if (
|
||||
this.isVideoWindowOpen()
|
||||
@@ -765,6 +819,7 @@ export class AgentService {
|
||||
const endedId = evtSessionId || this.runningSessionId();
|
||||
if (endedId) {
|
||||
this.applySessionEndedStatus(endedId, parsedData);
|
||||
this.invalidateStatusSignatures();
|
||||
this.activeTasks.update(list => list.filter(at => at.session_id !== endedId));
|
||||
}
|
||||
const remaining = this.activeTasks();
|
||||
@@ -874,62 +929,15 @@ export class AgentService {
|
||||
}
|
||||
|
||||
if (eventType === 'llm_stream') {
|
||||
const execId = parsedData.execution_id;
|
||||
const stepId = parsedData.step_id;
|
||||
const chunk = parsedData.chunk;
|
||||
const streamType = parsedData.stream_type || 'text';
|
||||
|
||||
this.sessionLogs.update((logs) => {
|
||||
const existingIndex = logs.findIndex(
|
||||
(l) => l.type === 'llm_stream' &&
|
||||
l.data.execution_id === execId &&
|
||||
(l.data.stream_type || 'text') === streamType
|
||||
);
|
||||
|
||||
if (existingIndex > -1) {
|
||||
const updatedLogs = [...logs];
|
||||
const existingLog = updatedLogs[existingIndex];
|
||||
updatedLogs[existingIndex] = {
|
||||
...existingLog,
|
||||
data: {
|
||||
...existingLog.data,
|
||||
text: existingLog.data.text + chunk,
|
||||
step_id: stepId || existingLog.data.step_id
|
||||
}
|
||||
};
|
||||
return updatedLogs;
|
||||
} else {
|
||||
const updatedLogs = logs.map((l) => {
|
||||
if (l.type === 'llm_stream' &&
|
||||
(l.data.stream_type || 'text') === streamType &&
|
||||
!l.data.isCompleted) {
|
||||
return {
|
||||
...l,
|
||||
data: { ...l.data, isCompleted: true }
|
||||
};
|
||||
}
|
||||
return l;
|
||||
});
|
||||
|
||||
return [
|
||||
...updatedLogs,
|
||||
{
|
||||
type: 'llm_stream',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
execution_id: execId,
|
||||
step_id: stepId,
|
||||
text: chunk,
|
||||
stream_type: streamType,
|
||||
isCompleted: false
|
||||
}
|
||||
}
|
||||
];
|
||||
}
|
||||
});
|
||||
this.queueStreamChunk(parsedData);
|
||||
} else {
|
||||
this.sessionLogs.update((logs) => {
|
||||
const updatedLogs = logs.map((l) => {
|
||||
// Copying every log object on every event is wasted work when no
|
||||
// stream is open; only rewrite entries that actually change.
|
||||
const hasOpenStream = logs.some(
|
||||
(l) => l.type === 'llm_stream' && !l.data.isCompleted
|
||||
);
|
||||
const updatedLogs = !hasOpenStream ? [...logs] : logs.map((l) => {
|
||||
if (l.type === 'llm_stream' && !l.data.isCompleted) {
|
||||
return {
|
||||
...l,
|
||||
@@ -991,6 +999,92 @@ export class AgentService {
|
||||
this.eventSource.onerror = (err) => {
|
||||
console.warn('Persistent live stream issue, browser will auto-reconnect:', err);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Buffer one llm_stream chunk; the buffer is flushed into sessionLogs at most
|
||||
* once per short window (and immediately before any non-stream event so the
|
||||
* relative ordering of stream text and step events is preserved).
|
||||
*/
|
||||
private queueStreamChunk(parsedData: any): void {
|
||||
const execId = parsedData.execution_id;
|
||||
const stepId = parsedData.step_id;
|
||||
const streamType = parsedData.stream_type || 'text';
|
||||
const key = `${execId}|${streamType}`;
|
||||
const pending = this.pendingStreamChunks.get(key);
|
||||
if (pending) {
|
||||
pending.chunk += parsedData.chunk;
|
||||
if (stepId) pending.stepId = stepId;
|
||||
} else {
|
||||
this.pendingStreamChunks.set(key, { execId, stepId, streamType, chunk: parsedData.chunk });
|
||||
}
|
||||
if (!this.streamFlushTimer) {
|
||||
const delay = typeof document !== 'undefined' && document.hidden ? 500 : 80;
|
||||
this.streamFlushTimer = setTimeout(() => this.flushStreamChunks(), delay);
|
||||
}
|
||||
}
|
||||
|
||||
private discardPendingStreamChunks(): void {
|
||||
this.pendingStreamChunks?.clear();
|
||||
if (this.streamFlushTimer) {
|
||||
clearTimeout(this.streamFlushTimer);
|
||||
this.streamFlushTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
private flushStreamChunks(): void {
|
||||
if (this.streamFlushTimer) {
|
||||
clearTimeout(this.streamFlushTimer);
|
||||
this.streamFlushTimer = null;
|
||||
}
|
||||
if (!this.pendingStreamChunks?.size) return;
|
||||
const batches = Array.from(this.pendingStreamChunks.values());
|
||||
this.pendingStreamChunks.clear();
|
||||
|
||||
this.sessionLogs.update((logs) => {
|
||||
let next = [...logs];
|
||||
for (const batch of batches) {
|
||||
const existingIndex = next.findIndex(
|
||||
(l) => l.type === 'llm_stream' &&
|
||||
l.data.execution_id === batch.execId &&
|
||||
(l.data.stream_type || 'text') === batch.streamType
|
||||
);
|
||||
|
||||
if (existingIndex > -1) {
|
||||
const existingLog = next[existingIndex];
|
||||
next[existingIndex] = {
|
||||
...existingLog,
|
||||
data: {
|
||||
...existingLog.data,
|
||||
text: existingLog.data.text + batch.chunk,
|
||||
step_id: batch.stepId || existingLog.data.step_id
|
||||
}
|
||||
};
|
||||
} else {
|
||||
next = next.map((l) => {
|
||||
if (l.type === 'llm_stream' &&
|
||||
(l.data.stream_type || 'text') === batch.streamType &&
|
||||
!l.data.isCompleted) {
|
||||
return { ...l, data: { ...l.data, isCompleted: true } };
|
||||
}
|
||||
return l;
|
||||
});
|
||||
next.push({
|
||||
type: 'llm_stream',
|
||||
timestamp: new Date().toISOString(),
|
||||
data: {
|
||||
execution_id: batch.execId,
|
||||
step_id: batch.stepId,
|
||||
text: batch.chunk,
|
||||
stream_type: batch.streamType,
|
||||
isCompleted: false
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1065,6 +1159,7 @@ export class AgentService {
|
||||
if (!cached) return;
|
||||
const sessions = JSON.parse(cached);
|
||||
if (Array.isArray(sessions)) {
|
||||
this.lastPersistedSessionsJson = cached;
|
||||
this.rawSessions.set(sessions);
|
||||
}
|
||||
} catch {
|
||||
@@ -1074,13 +1169,17 @@ export class AgentService {
|
||||
|
||||
private persistSessionsCache(sessions: Session[]): void {
|
||||
try {
|
||||
localStorage.setItem(SESSION_CACHE_KEY, JSON.stringify(sessions));
|
||||
const serialized = JSON.stringify(sessions);
|
||||
if (serialized === this.lastPersistedSessionsJson) return;
|
||||
this.lastPersistedSessionsJson = serialized;
|
||||
localStorage.setItem(SESSION_CACHE_KEY, serialized);
|
||||
} catch {
|
||||
// Storage can be unavailable in private browsing or embedded contexts.
|
||||
}
|
||||
}
|
||||
|
||||
private clearSessionsCache(): void {
|
||||
this.lastPersistedSessionsJson = null;
|
||||
try {
|
||||
localStorage.removeItem(SESSION_CACHE_KEY);
|
||||
} catch {
|
||||
@@ -1217,6 +1316,7 @@ export class AgentService {
|
||||
private setSessionStatus(sessionId: string | null, status: Session['status']): void {
|
||||
if (!sessionId) return;
|
||||
|
||||
this.invalidateStatusSignatures();
|
||||
this.rawSessions.update((sessions) => sessions.map((session) =>
|
||||
session.session_id === sessionId ? { ...session, status } : session
|
||||
));
|
||||
@@ -1240,6 +1340,7 @@ export class AgentService {
|
||||
if (status) {
|
||||
this.activeSessionTracking.delete(String(sessionId));
|
||||
this.setSessionStatus(String(sessionId), status);
|
||||
this.invalidateStatusSignatures();
|
||||
this.activeTasks.update((list) => list.filter((at) => at.session_id !== String(sessionId)));
|
||||
}
|
||||
}
|
||||
@@ -1249,14 +1350,30 @@ export class AgentService {
|
||||
*/
|
||||
private startStatusPolling(): void {
|
||||
this.fetchStatus();
|
||||
this.statusInterval = setInterval(() => {
|
||||
this.fetchStatus();
|
||||
this.pollCounter++;
|
||||
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
|
||||
if (this.pollCounter % 3 === 0) {
|
||||
this.fetchSessions();
|
||||
}
|
||||
}, 2000);
|
||||
// The interval runs outside the Angular zone so a poll tick alone never
|
||||
// schedules change detection; only actual signal updates do.
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.statusInterval = setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.hidden) {
|
||||
return; // Paused while the tab is in the background; resumed by visibilitychange.
|
||||
}
|
||||
this.fetchStatus();
|
||||
this.pollCounter++;
|
||||
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
|
||||
if (this.pollCounter % 3 === 0) {
|
||||
this.fetchSessions();
|
||||
}
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Optimistic local mutations of queue/active state must force the next poll
|
||||
* to re-apply backend data even when its payload has not changed.
|
||||
*/
|
||||
private invalidateStatusSignatures(): void {
|
||||
this.lastQueueSignature = null;
|
||||
this.lastActiveTasksSignature = null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1291,26 +1408,37 @@ export class AgentService {
|
||||
this.activePauseCardKey = null;
|
||||
}
|
||||
|
||||
// Map backend pending queue to real Session / TaskQueueItem objects
|
||||
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
// Skip signal updates when backend payloads did not change: setting a
|
||||
// fresh array reference every 2s poll would force the whole sessions
|
||||
// computed chain to re-derive for identical data.
|
||||
const queueSignature = JSON.stringify(data.queue || []);
|
||||
if (queueSignature !== this.lastQueueSignature) {
|
||||
this.lastQueueSignature = queueSignature;
|
||||
// Map backend pending queue to real Session / TaskQueueItem objects
|
||||
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
return {
|
||||
session_id: item.session_id || `pending-task-${index}`,
|
||||
initial_goal: item.goal || '',
|
||||
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
|
||||
status: item.status || 'pending',
|
||||
device_serial: item.device_serial || item.device_id || null
|
||||
};
|
||||
}
|
||||
return {
|
||||
session_id: item.session_id || `pending-task-${index}`,
|
||||
initial_goal: item.goal || '',
|
||||
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
|
||||
status: item.status || 'pending',
|
||||
device_serial: item.device_serial || item.device_id || null
|
||||
session_id: `task-queued-${index}`,
|
||||
initial_goal: String(item),
|
||||
start_time: (Date.now() / 1000) + index,
|
||||
status: 'pending'
|
||||
};
|
||||
}
|
||||
return {
|
||||
session_id: `task-queued-${index}`,
|
||||
initial_goal: String(item),
|
||||
start_time: (Date.now() / 1000) + index,
|
||||
status: 'pending'
|
||||
};
|
||||
});
|
||||
this.pendingQueue.set(pending);
|
||||
this.activeTasks.set(data.active_tasks || []);
|
||||
});
|
||||
this.pendingQueue.set(pending);
|
||||
}
|
||||
const activeTasksSignature = JSON.stringify(data.active_tasks || []);
|
||||
if (activeTasksSignature !== this.lastActiveTasksSignature) {
|
||||
this.lastActiveTasksSignature = activeTasksSignature;
|
||||
this.activeTasks.set(data.active_tasks || []);
|
||||
}
|
||||
|
||||
if (oldStatus !== data.status || oldRunningSessionId !== data.session_id) {
|
||||
this.fetchSessions();
|
||||
@@ -1367,9 +1495,15 @@ export class AgentService {
|
||||
public destroy(): void {
|
||||
if (this.statusInterval) {
|
||||
clearInterval(this.statusInterval);
|
||||
this.statusInterval = null;
|
||||
}
|
||||
if (this.eventSource) {
|
||||
this.eventSource.close();
|
||||
this.eventSource = null;
|
||||
}
|
||||
this.discardPendingStreamChunks();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
}
|
||||
this.cancelVideoRetry();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { provideHttpClient } from '@angular/common/http';
|
||||
import { provideHttpClient, withXhr } from '@angular/common/http';
|
||||
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
|
||||
import { TestBed } from '@angular/core/testing';
|
||||
|
||||
@@ -23,7 +23,7 @@ describe('SystemService readiness polling', () => {
|
||||
TestBed.configureTestingModule({
|
||||
providers: [
|
||||
SystemService,
|
||||
provideHttpClient(),
|
||||
provideHttpClient(withXhr()),
|
||||
provideHttpClientTesting()
|
||||
]
|
||||
});
|
||||
@@ -69,4 +69,105 @@ describe('SystemService readiness polling', () => {
|
||||
expect(request.request.params.get('force')).toBe('true');
|
||||
request.flush(report(30));
|
||||
});
|
||||
|
||||
it('loads the active ADB server endpoint', () => {
|
||||
service.fetchAdbServerStatus().subscribe();
|
||||
|
||||
const request = http.expectOne('/api/system/adb/server');
|
||||
request.flush({
|
||||
endpoint: {
|
||||
host: '127.0.0.1',
|
||||
port: 5038,
|
||||
socket: 'tcp:127.0.0.1:5038',
|
||||
mode: 'remote',
|
||||
is_local_default: false
|
||||
}
|
||||
});
|
||||
|
||||
expect(service.isRemoteAdbServer()).toBeTrue();
|
||||
expect(service.adbServerStatus()?.endpoint.port).toBe(5038);
|
||||
});
|
||||
|
||||
it('activates an ADB server endpoint and applies its readiness report', () => {
|
||||
service.connectAdbServer('127.0.0.1', 5038, true).subscribe();
|
||||
|
||||
const request = http.expectOne('/api/system/adb/server/connect');
|
||||
expect(request.request.body).toEqual({
|
||||
host: '127.0.0.1',
|
||||
port: 5038,
|
||||
persist: true
|
||||
});
|
||||
request.flush({
|
||||
connection_result: {
|
||||
success: true,
|
||||
message: 'Connected.',
|
||||
endpoint: {
|
||||
host: '127.0.0.1',
|
||||
port: 5038,
|
||||
socket: 'tcp:127.0.0.1:5038',
|
||||
mode: 'remote',
|
||||
is_local_default: false
|
||||
},
|
||||
devices: []
|
||||
},
|
||||
report: report(40)
|
||||
});
|
||||
|
||||
expect(service.isRemoteAdbServer()).toBeTrue();
|
||||
expect(service.readinessReport()?.timestamp).toBe(40);
|
||||
});
|
||||
|
||||
it('probes an ADB server without changing the active endpoint', () => {
|
||||
service.probeAdbServer('127.0.0.1', 5038).subscribe();
|
||||
|
||||
const request = http.expectOne('/api/system/adb/server/probe');
|
||||
expect(request.request.body).toEqual({
|
||||
host: '127.0.0.1',
|
||||
port: 5038,
|
||||
persist: false
|
||||
});
|
||||
request.flush({
|
||||
connection_result: {
|
||||
success: true,
|
||||
message: 'Endpoint is reachable.',
|
||||
endpoint: {
|
||||
host: '127.0.0.1',
|
||||
port: 5038,
|
||||
socket: 'tcp:127.0.0.1:5038',
|
||||
identity: 'tcp:127.0.0.1:5038',
|
||||
mode: 'remote',
|
||||
is_local_default: false
|
||||
},
|
||||
devices: []
|
||||
}
|
||||
});
|
||||
|
||||
expect(service.adbServerStatus()).toBeNull();
|
||||
});
|
||||
|
||||
it('restores the standard local ADB server explicitly', () => {
|
||||
service.useLocalAdbServer(true).subscribe();
|
||||
|
||||
const request = http.expectOne(req =>
|
||||
req.url === '/api/system/adb/server/local' && req.params.get('persist') === 'true'
|
||||
);
|
||||
request.flush({
|
||||
connection_result: {
|
||||
success: true,
|
||||
message: 'Using local ADB.',
|
||||
endpoint: {
|
||||
host: '127.0.0.1',
|
||||
port: 5037,
|
||||
socket: 'tcp:127.0.0.1:5037',
|
||||
mode: 'local',
|
||||
is_local_default: true
|
||||
},
|
||||
devices: []
|
||||
},
|
||||
report: report(50)
|
||||
});
|
||||
|
||||
expect(service.isRemoteAdbServer()).toBeFalse();
|
||||
expect(service.adbServerStatus()?.endpoint.port).toBe(5037);
|
||||
});
|
||||
});
|
||||
@@ -14,10 +14,18 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Injectable, signal, computed, inject } from '@angular/core';
|
||||
import { Injectable, signal, computed, inject, DestroyRef, NgZone } from '@angular/core';
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { Observable, finalize, shareReplay, tap } from 'rxjs';
|
||||
import { SystemReadinessReport, DeviceInfo, ProbeResult, EmulatorLaunchState, EmulatorLaunchStage } from '../core/models/system.model';
|
||||
import {
|
||||
AdbServerConnectionResponse,
|
||||
AdbServerStatus,
|
||||
SystemReadinessReport,
|
||||
DeviceInfo,
|
||||
ProbeResult,
|
||||
EmulatorLaunchState,
|
||||
EmulatorLaunchStage
|
||||
} from '../core/models/system.model';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
@@ -30,6 +38,10 @@ export class SystemService {
|
||||
public hasReadinessReport = computed(() => this.readinessReport() !== null);
|
||||
public isLoading = signal<boolean>(false);
|
||||
public isRestartingAdb = signal<boolean>(false);
|
||||
public adbServerStatus = signal<AdbServerStatus | null>(null);
|
||||
public isRemoteAdbServer = computed(
|
||||
() => this.adbServerStatus()?.endpoint.mode === 'remote'
|
||||
);
|
||||
public launchingAvd = signal<string | null>(null);
|
||||
public emulatorLaunchState = signal<EmulatorLaunchState | null>(null);
|
||||
public isEmulatorLaunching = computed(() => {
|
||||
@@ -138,24 +150,49 @@ export class SystemService {
|
||||
|
||||
private pollingTimer: any = null;
|
||||
private readinessRequest$: Observable<SystemReadinessReport> | null = null;
|
||||
private zone = inject(NgZone);
|
||||
private lastAppliedReportJson: string | null = null;
|
||||
private onVisibilityChange = () => {
|
||||
if (typeof document !== 'undefined' && !document.hidden) {
|
||||
this.fetchReadiness(true).subscribe({ error: () => {} });
|
||||
}
|
||||
};
|
||||
|
||||
constructor() {
|
||||
this.startAutoPolling(3000);
|
||||
if (typeof document !== 'undefined') {
|
||||
document.addEventListener('visibilitychange', this.onVisibilityChange);
|
||||
}
|
||||
inject(DestroyRef).onDestroy(() => {
|
||||
this.stopAutoPolling();
|
||||
this.stopEmulatorStatusPolling();
|
||||
if (typeof document !== 'undefined') {
|
||||
document.removeEventListener('visibilitychange', this.onVisibilityChange);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Start periodic auto-polling for system readiness changes
|
||||
* Start periodic auto-polling for system readiness changes. The interval
|
||||
* runs outside the Angular zone (a tick alone must not trigger change
|
||||
* detection) and pauses while the tab is hidden; a visibilitychange listener
|
||||
* refreshes immediately when the tab becomes visible again.
|
||||
*/
|
||||
public startAutoPolling(intervalMs: number = 3000): void {
|
||||
if (this.pollingTimer) {
|
||||
clearInterval(this.pollingTimer);
|
||||
}
|
||||
this.pollingTimer = setInterval(() => {
|
||||
// Perform silent background check without disturbing UI loading state
|
||||
this.fetchReadiness(true).subscribe({
|
||||
error: () => {} // Silent catch
|
||||
});
|
||||
}, intervalMs);
|
||||
this.zone.runOutsideAngular(() => {
|
||||
this.pollingTimer = setInterval(() => {
|
||||
if (typeof document !== 'undefined' && document.hidden) {
|
||||
return;
|
||||
}
|
||||
// Perform silent background check without disturbing UI loading state
|
||||
this.fetchReadiness(true).subscribe({
|
||||
error: () => {} // Silent catch
|
||||
});
|
||||
}, intervalMs);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -214,8 +251,15 @@ export class SystemService {
|
||||
if (current && report.timestamp < current.timestamp) {
|
||||
return;
|
||||
}
|
||||
this.readinessReport.set(report);
|
||||
this.lastCheckedTime.set(new Date(report.timestamp * 1000));
|
||||
// A poll usually returns an identical report; setting a new object anyway
|
||||
// would cascade through every probe computed for no visible change.
|
||||
const serialized = JSON.stringify({ ...report, timestamp: 0 });
|
||||
if (serialized === this.lastAppliedReportJson) {
|
||||
return;
|
||||
}
|
||||
this.lastAppliedReportJson = serialized;
|
||||
this.readinessReport.set(report);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -388,6 +432,69 @@ export class SystemService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch the process-wide ADB server endpoint used by Artemis.
|
||||
*/
|
||||
public fetchAdbServerStatus(): Observable<AdbServerStatus> {
|
||||
return this.http.get<AdbServerStatus>('/api/system/adb/server').pipe(
|
||||
tap(status => this.adbServerStatus.set(status))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test an ADB server endpoint without changing the active connection.
|
||||
*/
|
||||
public probeAdbServer(
|
||||
host: string,
|
||||
port: number
|
||||
): Observable<AdbServerConnectionResponse> {
|
||||
return this.http.post<AdbServerConnectionResponse>(
|
||||
'/api/system/adb/server/probe',
|
||||
{ host, port, persist: false }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate and activate an ADB server endpoint.
|
||||
*/
|
||||
public connectAdbServer(
|
||||
host: string,
|
||||
port: number,
|
||||
persist: boolean = true
|
||||
): Observable<AdbServerConnectionResponse> {
|
||||
return this.http.post<AdbServerConnectionResponse>(
|
||||
'/api/system/adb/server/connect',
|
||||
{ host, port, persist }
|
||||
).pipe(
|
||||
tap(response => {
|
||||
if (response.connection_result.success) {
|
||||
this.adbServerStatus.set({ endpoint: response.connection_result.endpoint });
|
||||
if (response.report) {
|
||||
this.applyReadinessReport(response.report);
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch Artemis back to the standard local ADB server.
|
||||
*/
|
||||
public useLocalAdbServer(persist: boolean = true): Observable<AdbServerConnectionResponse> {
|
||||
return this.http.post<AdbServerConnectionResponse>(
|
||||
'/api/system/adb/server/local',
|
||||
{},
|
||||
{ params: { persist } }
|
||||
).pipe(
|
||||
tap(response => {
|
||||
this.adbServerStatus.set({ endpoint: response.connection_result.endpoint });
|
||||
if (response.report) {
|
||||
this.applyReadinessReport(response.report);
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a specific connected device serial as active target
|
||||
*/
|
||||
|
||||
@@ -283,6 +283,39 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache for the token-estimate fallback. Tool payloads can carry multi-megabyte
|
||||
* base64 screenshots; serializing them on every recompute would block the main
|
||||
* thread, so each payload object is measured exactly once.
|
||||
*/
|
||||
const payloadSizeCache = new WeakMap<object, { isImage: boolean; length: number }>();
|
||||
|
||||
function estimatePayloadSize(payload: any): { isImage: boolean; length: number } {
|
||||
if (typeof payload === 'string') {
|
||||
return {
|
||||
isImage: payload.includes('data:image/') || payload.includes('base64,'),
|
||||
length: payload.length
|
||||
};
|
||||
}
|
||||
if (payload === null || typeof payload !== 'object') {
|
||||
return { isImage: false, length: String(payload).length };
|
||||
}
|
||||
const cached = payloadSizeCache.get(payload);
|
||||
if (cached) return cached;
|
||||
let payloadStr = '';
|
||||
try {
|
||||
payloadStr = JSON.stringify(payload) || '';
|
||||
} catch {
|
||||
payloadStr = String(payload);
|
||||
}
|
||||
const result = {
|
||||
isImage: payloadStr.includes('data:image/') || payloadStr.includes('base64,'),
|
||||
length: payloadStr.length
|
||||
};
|
||||
payloadSizeCache.set(payload, result);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Safely extract and aggregate token usage for a step block
|
||||
*/
|
||||
@@ -345,11 +378,11 @@ export function extractBlockTokens(block: StepBlock): { total: number; prompt: n
|
||||
if (data.generic_tools && Array.isArray(data.generic_tools)) {
|
||||
data.generic_tools.forEach((t: any) => {
|
||||
if (t.payload) {
|
||||
const payloadStr = typeof t.payload === 'string' ? t.payload : JSON.stringify(t.payload);
|
||||
if (payloadStr.includes('data:image/') || payloadStr.includes('base64,')) {
|
||||
const estimate = estimatePayloadSize(t.payload);
|
||||
if (estimate.isImage) {
|
||||
numImages += 1;
|
||||
} else {
|
||||
textLen += payloadStr.length;
|
||||
textLen += estimate.length;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -18,11 +18,27 @@ import { ActionParam } from '../core/models/stream.model';
|
||||
import { isAndroidAction, isReportStatusAction } from './action-formatter.util';
|
||||
import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates } from './image-overlay.util';
|
||||
|
||||
// Tool objects are replaced (not mutated) when a trace is updated, so a
|
||||
// WeakMap keyed on the tool is a safe memo for the parsed-args lookup that
|
||||
// templates would otherwise re-run (including JSON.parse) on every render.
|
||||
const toolArgsCache = new WeakMap<object, any>();
|
||||
|
||||
/**
|
||||
* Extract arguments from tool payload or direct args field
|
||||
*/
|
||||
export function getToolArgs(tool: any): any {
|
||||
if (!tool) return {};
|
||||
if (typeof tool === 'object') {
|
||||
const cached = toolArgsCache.get(tool);
|
||||
if (cached !== undefined) return cached;
|
||||
const args = computeToolArgs(tool);
|
||||
toolArgsCache.set(tool, args);
|
||||
return args;
|
||||
}
|
||||
return computeToolArgs(tool);
|
||||
}
|
||||
|
||||
function computeToolArgs(tool: any): any {
|
||||
if (tool.payload) {
|
||||
let payloadObj = tool.payload;
|
||||
if (typeof payloadObj === 'string') {
|
||||
|
||||
@@ -11,5 +11,13 @@
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"extendedDiagnostics": {
|
||||
"checks": {
|
||||
"nullishCoalescingNotNullable": "suppress",
|
||||
"optionalChainNotNullable": "suppress"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,13 @@
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"extendedDiagnostics": {
|
||||
"checks": {
|
||||
"nullishCoalescingNotNullable": "suppress",
|
||||
"optionalChainNotNullable": "suppress"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user