feat: enable remote SDK and resilient device orchestration

This commit is contained in:
somew1nd committed 2026-08-30 00:41:07 -07:00
1 parent 3cc711df51
commit ddfa420945
110 files changed
+14450 -12954

No files matched your search

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