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
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 {}
|
||||
Reference in new issue
Block a user