feat: introduce MCP server, cross-platform runtime, and Showcase UI reorganization

- Showcase UI Reorganization:
  * Move web UI dashboard from root directory into apps/showcase_ui/ for cleaner project structure
  * Add 'artemis ui' CLI command (artemis/interfaces/cli/commands/ui.py) and launcher scripts for the interactive showcase console
- Standalone MCP Server & Notifiers (mcp_server/):
  * Implement dedicated MCP server package with mobile automation tools (device_state, inspect_trace, task_manager, task_runner)
  * Add multi-channel notifiers (AgentAPI, Desktop notifications, Webhooks, File output, and Composite routing)
  * Introduce background task runner and trace store for autonomous mobile execution monitoring
- Cross-Platform Runtime & Toolchain Support:
  * Add artemis/platform/ with OS-specific implementations for Linux, macOS (Darwin), and Windows
  * Add artemis/runtime/supervisor.py for resilient process and agent runtime supervision
  * Add artemis/toolchain/ with dependency resolution and SDK descriptors
  * Add cross-platform launcher scripts (start.sh, start.bat) and installer scripts (scripts/install_deps.*)
- Core Enhancements & Diagnostics:
  * Enhance coordinate normalization, OCR API fallback, and perception pipeline
  * Add credential validation (credentials_validator.py) and system diagnostics
  * Update bilingual documentation (README.md, README_CN.md) and architecture diagrams
- Test Suite Expansion:
  * Add integration tests for cloud gateway, remote ADB, and Artemis login
  * Add unit tests covering core diagnostics, platform detection, toolchain, credentials validator, optional OCR, and MCP tools
This commit is contained in:
somew1nd committed 2026-08-18 13:59:27 -07:00
1 parent 78e478f502
commit 9a5aaf99b7
249 files changed
+37226 -9339

No files matched your search

File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,766 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, signal, computed, effect, inject, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
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 {
AppReference,
SmartSuggestion,
SuggestionCategory
} from '../../core/data/smart-tasks.data';
import { TaskRecommendationService } from '../../core/services/task-recommendation.service';
export type { AppReference, SmartSuggestion, SuggestionCategory };
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './home.component.html',
styleUrl: './home.component.scss'
})
export class HomeComponent implements OnInit, OnDestroy {
public agentService = inject(AgentService);
public systemService = inject(SystemService);
public taskRecService = inject(TaskRecommendationService);
private router = inject(Router);
// 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');
public emulatorSetupMode = signal<'studio' | 'cli'>('studio');
// Interactive guide tab for LLM / OCR credentials: 'gemini' | 'ocr'
public modelSetupMode = signal<'gemini' | 'custom'>('gemini');
public showOcrConfig = signal<boolean>(false);
public showFullConfigFile = signal<boolean>(false);
// Model & Environment configuration from backend
public modelConfigEnv = computed(() => this.systemService.modelConfigEnv());
// Google Gemini API Key State
public geminiKeyInput = signal<string>('');
public showGeminiKey = signal<boolean>(false);
public isSavingGeminiKey = signal<boolean>(false);
public isTestingGeminiKey = signal<boolean>(false);
public geminiSaveMessage = signal<string | null>(null);
public geminiSaveError = signal<string | null>(null);
public isGeminiKeyEdited = signal<boolean>(false);
// Vision OCR API Key State
public ocrKeyInput = signal<string>('');
public showOcrKey = signal<boolean>(false);
public isSavingOcrKey = signal<boolean>(false);
public isTestingOcrKey = signal<boolean>(false);
public ocrSaveMessage = signal<string | null>(null);
public ocrSaveError = signal<string | null>(null);
public isOcrKeyEdited = signal<boolean>(false);
// Clipboard copy state tracker for interactive feedback
public copiedId = signal<string | null>(null);
// Diagnostic re-check state
public isRefreshingDiagnostics = signal<boolean>(false);
// Wireless ADB Interactive connection signals
public wifiHost = signal<string>('192.168.1.100');
public wifiPort = signal<string>('5555');
public isConnectingWifi = signal<boolean>(false);
public wifiConnectMessage = signal<string | null>(null);
public wifiConnectError = signal<string | null>(null);
public adbRestartFeedback = signal<string | null>(null);
public wifiCommand = computed(() => {
const h = this.wifiHost().trim() || '<phone-ip>';
const p = this.wifiPort().trim() || '5555';
return `adb connect ${h}:${p}`;
});
// Task execution parameters
public selectedProfile = signal<'flash' | 'pro'>('flash');
public taskGoal = signal<string>('');
public isSubmitting = signal<boolean>(false);
public errorMessage = signal<string | null>(null);
// Pro Mode Outputter & Structured Output Configuration
public expectedOutput = signal<string>('');
public enableOutputter = signal<boolean>(true);
public showOutputterDrawer = signal<boolean>(false);
public toggleOutputterDrawer(): void {
this.showOutputterDrawer.update((v) => !v);
}
public applyOutputPreset(preset: string): void {
if (this.expectedOutput() === preset) {
this.expectedOutput.set('');
} else {
this.expectedOutput.set(preset);
this.showOutputterDrawer.set(true);
}
}
// Smart intent detection for model recommendation
public isIntentSuggestingPro = computed<boolean>(() => {
const text = this.taskGoal().toLowerCase();
if (!text.trim()) return false;
const keywords = [
'monitor', 'polling', 'poll', 'wait until', 'loop', 'keep watching',
'crash', 'logcat', 'troubleshoot', 'diagnose', 'debug', 'investigate',
'compare', 'extract', 'summarize', 'report',
'监控', '轮询', '等待', '一直', '直到', '崩溃', '闪退', '排查', '分析日志', '对比', '总结'
];
return keywords.some(k => text.includes(k));
});
public showIntentSuggestion = computed<boolean>(() => {
return this.isIntentSuggestingPro() && this.selectedProfile() === 'flash';
});
// Computed helper states delegating to SystemService
public isReady = computed(() => this.systemService.isReady());
public isLoading = computed(() => this.systemService.isLoading());
public isRestartingAdb = computed(() => this.systemService.isRestartingAdb());
public launchingAvd = computed(() => this.systemService.launchingAvd());
public emulatorLaunchState = computed(() => this.systemService.emulatorLaunchState());
public isEmulatorLaunching = computed(() => this.systemService.isEmulatorLaunching());
public showLaunchLogs = signal<boolean>(false);
// Probes
public pythonProbe = computed(() => this.systemService.pythonProbe());
public configProbe = computed(() => this.systemService.configProbe());
public adbProbe = computed(() => this.systemService.adbProbe());
public llmProbe = computed(() => this.systemService.llmProbe());
public geminiProbe = computed(() => this.systemService.geminiProbe());
public ocrProbe = computed(() => this.systemService.ocrProbe());
public toolchainProbe = computed(() => this.systemService.toolchainProbe());
// Step-level readiness
public isEnvironmentReady = computed(() => this.systemService.isEnvironmentReady());
public isCredentialsReady = computed(() => this.systemService.isCredentialsReady());
public isSkipCredentialsCheck = computed(() => this.systemService.isSkipCredentialsCheck());
public isDeviceReady = computed(() => this.systemService.isDeviceReady());
// Device information
public activeDevice = computed(() => this.systemService.activeDevice());
public connectedDevices = computed(() => this.systemService.connectedDevices());
public installedAvds = computed(() => this.systemService.installedAvds());
public emulatorPath = computed(() => this.systemService.emulatorPath());
public isEmulatorInPath = computed(() => this.systemService.isEmulatorInPath());
public totalStepCount = computed(() => this.systemService.totalStepCount());
public passedStepCount = computed(() => this.systemService.passedStepCount());
public blockerCount = computed(() => this.systemService.blockerCount());
public passedBlockerCount = computed(() => this.systemService.passedBlockerCount());
// Configured LLM providers from probe metadata
public configuredLlmProviders = computed<any[]>(() => {
const meta = this.llmProbe()?.metadata;
if (meta && Array.isArray(meta['providers'])) {
return meta['providers'];
}
return [];
});
// Multi-OS detection & active OS selection
public selectedOs = signal<'linux' | 'darwin' | 'windows' | null>(null);
public effectiveOs = computed<'linux' | 'darwin' | 'windows'>(() => {
return this.selectedOs() || this.systemService.osType();
});
public oneClickSetupCmd = computed(() => {
const os = this.effectiveOs();
if (os === 'windows') {
return 'powershell -ExecutionPolicy Bypass -File scripts/install_deps.ps1';
}
return 'bash scripts/install_deps.sh';
});
public adbInstallCmd = computed(() => {
const os = this.effectiveOs();
if (os === 'windows') {
return 'winget install Google.PlatformTools';
}
if (os === 'darwin') {
return 'brew install android-platform-tools';
}
return 'sudo apt-get install -y adb';
});
public toolchainInstallCmd = computed(() => {
const os = this.effectiveOs();
if (os === 'windows') {
return 'winget install Gyan.FFmpeg Genymobile.scrcpy';
}
if (os === 'darwin') {
return 'brew install ffmpeg scrcpy';
}
return 'sudo apt-get install -y ffmpeg scrcpy';
});
public emuHypervisorTitle = computed(() => {
const os = this.effectiveOs();
if (os === 'windows') return 'Enable Windows Hypervisor Platform (WHPX)';
if (os === 'darwin') return 'Verify macOS Hypervisor / Install Tools';
return 'Enable KVM Hardware Acceleration (Linux)';
});
public emuHypervisorDesc = computed(() => {
const os = this.effectiveOs();
if (os === 'windows') return 'Enable Windows Hypervisor Platform in PowerShell (Run as Administrator):';
if (os === 'darwin') return 'macOS uses native Hypervisor.framework. Install SDK tools via Homebrew (or Studio):';
return 'Ensure virtualization permissions are granted to your user account:';
});
public emuHypervisorCmd = computed(() => {
const os = this.effectiveOs();
if (os === 'windows') return 'Enable-WindowsOptionalFeature -Online -FeatureName HypervisorPlatform';
if (os === 'darwin') return 'brew install --cask android-commandlinetools';
return 'sudo apt-get install -y qemu-kvm libvirt-daemon-system && sudo adduser $USER kvm';
});
public emuSdkInstallCmd = computed(() => {
const os = this.effectiveOs();
if (os === 'darwin') {
return 'sdkmanager --install "system-images;android-34;google_apis;arm64-v8a" "emulator" "platform-tools"';
}
return 'sdkmanager --install "system-images;android-34;google_apis;x86_64" "emulator" "platform-tools"';
});
public emuCreateAvdCmd = computed(() => {
const os = this.effectiveOs();
if (os === 'darwin') {
return 'avdmanager create avd -n Pixel_8_API_34 -k "system-images;android-34;google_apis;arm64-v8a" --device "pixel_8"';
}
return 'avdmanager create avd -n Pixel_8_API_34 -k "system-images;android-34;google_apis;x86_64" --device "pixel_8"';
});
// Flag indicating whether Google Cloud Vision OCR is configured
public isOcrConfigured = computed<boolean>(() => {
const meta = this.ocrProbe()?.metadata;
return meta?.['configured'] === true;
});
public currentApiKey = computed<string>(() => this.systemService.currentApiKey());
public apiKeysMap = computed<Record<string, string>>(() => this.systemService.apiKeysMap());
public savedGeminiKey = computed<string>(() => {
const keys = this.apiKeysMap();
return keys['google'] || this.currentApiKey() || '';
});
public isGeminiModified = computed<boolean>(() => {
return this.geminiKeyInput().trim() !== this.savedGeminiKey().trim();
});
public savedOcrKey = computed<string>(() => {
const keys = this.apiKeysMap();
return keys['ocr'] || '';
});
public isOcrModified = computed<boolean>(() => {
return this.ocrKeyInput().trim() !== this.savedOcrKey().trim();
});
// Rich Smart Suggestions Library (Device-Aware, Flash vs Pro Tailored)
public readonly allSuggestions = this.taskRecService.allTasks;
// Suggestion category filter & shuffle state
public selectedCategory = signal<SuggestionCategory>('all');
public shuffleOffset = signal<number>(0);
// Set of installed package strings from active device
public installedPackages = computed<Set<string>>(() => {
const pkgs = this.activeDevice()?.installed_packages;
if (pkgs && Array.isArray(pkgs)) {
return new Set(pkgs);
}
return new Set();
});
public filteredSuggestions = computed<SmartSuggestion[]>(() => {
return this.taskRecService.filterAndRankTasks(
this.installedPackages(),
this.selectedCategory(),
this.shuffleOffset()
);
});
private focusListener = () => {
// Silently re-check environment when user returns to the browser tab
this.systemService.fetchReadiness().subscribe();
};
constructor() {
effect(() => {
const keys = this.systemService.apiKeysMap();
const current = this.systemService.currentApiKey();
const googleKey = keys['google'] || current || '';
const ocrKey = keys['ocr'] || '';
if (!this.isGeminiKeyEdited()) {
this.geminiKeyInput.set(googleKey);
}
if (!this.isOcrKeyEdited()) {
this.ocrKeyInput.set(ocrKey);
}
});
}
ngOnInit(): void {
// Initial fetch of system readiness & model configuration
this.systemService.fetchReadiness().subscribe();
this.systemService.fetchModelConfigEnv().subscribe();
window.addEventListener('focus', this.focusListener);
}
ngOnDestroy(): void {
window.removeEventListener('focus', this.focusListener);
}
public setTab(tab: 'diagnostics' | 'launcher'): void {
this.activeTab.set(tab);
if (tab === 'diagnostics') {
this.systemService.fetchReadiness().subscribe();
this.systemService.fetchModelConfigEnv().subscribe();
}
}
public setSelectedOs(os: 'linux' | 'darwin' | 'windows'): void {
this.selectedOs.set(os);
}
public setAdbGuideTab(tab: 'emulator' | 'usb' | 'wifi'): void {
this.activeAdbGuideTab.set(tab);
}
public setEmulatorSetupMode(mode: 'studio' | 'cli'): void {
this.emulatorSetupMode.set(mode);
}
public setModelSetupMode(mode: 'gemini' | 'custom'): void {
this.modelSetupMode.set(mode);
if (mode === 'custom') {
this.systemService.setSkipCredentialsCheck(true);
this.systemService.fetchModelConfigEnv().subscribe();
} else {
this.systemService.setSkipCredentialsCheck(false);
}
}
public toggleFullConfigFile(): void {
this.showFullConfigFile.update(v => !v);
}
public toggleGeminiKeyVisibility(): void {
this.showGeminiKey.update(v => !v);
}
public toggleOcrKeyVisibility(): void {
this.showOcrKey.update(v => !v);
}
public toggleOcrConfig(): void {
this.showOcrConfig.update(v => !v);
}
public onGeminiKeyChange(val: string): void {
this.geminiKeyInput.set(val);
this.isGeminiKeyEdited.set(true);
this.geminiSaveError.set(null);
this.geminiSaveMessage.set(null);
}
public onOcrKeyChange(val: string): void {
this.ocrKeyInput.set(val);
this.isOcrKeyEdited.set(true);
this.ocrSaveError.set(null);
this.ocrSaveMessage.set(null);
}
public saveGeminiKey(): void {
const key = this.geminiKeyInput().trim();
if (!key) return;
this.isSavingGeminiKey.set(true);
this.geminiSaveError.set(null);
this.geminiSaveMessage.set(null);
this.systemService.updateApiKey('google', key, true).subscribe({
next: (res) => {
this.isSavingGeminiKey.set(false);
this.isGeminiKeyEdited.set(false);
this.geminiSaveMessage.set(res?.message || '✓ Gemini API key verified & saved successfully.');
setTimeout(() => this.geminiSaveMessage.set(null), 5000);
},
error: (err) => {
this.isSavingGeminiKey.set(false);
this.geminiSaveError.set(err?.error?.detail || err?.message || 'Failed to update Gemini API key.');
}
});
}
public clearGeminiKey(): void {
this.geminiKeyInput.set('');
this.isGeminiKeyEdited.set(false);
this.geminiSaveError.set(null);
this.geminiSaveMessage.set(null);
if (this.savedGeminiKey().trim()) {
this.isSavingGeminiKey.set(true);
this.systemService.updateApiKey('google', '', true).subscribe({
next: (res) => {
this.isSavingGeminiKey.set(false);
this.geminiSaveMessage.set(res?.message || '✓ Gemini API key cleared.');
setTimeout(() => this.geminiSaveMessage.set(null), 5000);
},
error: (err) => {
this.isSavingGeminiKey.set(false);
this.geminiSaveError.set(err?.error?.detail || err?.message || 'Failed to clear Gemini API key.');
}
});
} else {
this.geminiSaveMessage.set('✓ Gemini API key cleared.');
setTimeout(() => this.geminiSaveMessage.set(null), 3000);
}
}
public saveOcrKey(): void {
const key = this.ocrKeyInput().trim();
if (!key) return;
this.isSavingOcrKey.set(true);
this.ocrSaveError.set(null);
this.ocrSaveMessage.set(null);
this.systemService.updateApiKey('ocr', key, true).subscribe({
next: (res) => {
this.isSavingOcrKey.set(false);
this.isOcrKeyEdited.set(false);
this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key verified & saved.');
setTimeout(() => this.ocrSaveMessage.set(null), 5000);
},
error: (err) => {
this.isSavingOcrKey.set(false);
this.ocrSaveError.set(err?.error?.detail || err?.message || 'Failed to update Vision OCR key.');
}
});
}
public clearOcrKey(): void {
this.ocrKeyInput.set('');
this.isOcrKeyEdited.set(false);
this.ocrSaveError.set(null);
this.ocrSaveMessage.set(null);
if (this.savedOcrKey().trim()) {
this.isSavingOcrKey.set(true);
this.systemService.updateApiKey('ocr', '', true).subscribe({
next: (res) => {
this.isSavingOcrKey.set(false);
this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key cleared.');
setTimeout(() => this.ocrSaveMessage.set(null), 5000);
},
error: (err) => {
this.isSavingOcrKey.set(false);
this.ocrSaveError.set(err?.error?.detail || err?.message || 'Failed to clear Vision OCR key.');
}
});
} else {
this.ocrSaveMessage.set('✓ Vision OCR API key cleared.');
setTimeout(() => this.ocrSaveMessage.set(null), 3000);
}
}
public testGeminiKey(): void {
const key = this.geminiKeyInput().trim();
if (!key) return;
this.isTestingGeminiKey.set(true);
this.geminiSaveError.set(null);
this.geminiSaveMessage.set(null);
this.systemService.testApiKey('google', key).subscribe({
next: (res) => {
this.isTestingGeminiKey.set(false);
if (res?.valid) {
this.geminiSaveMessage.set(res?.message || '✓ Gemini API key is valid!');
} else {
this.geminiSaveError.set(res?.message || 'Gemini API key verification failed.');
}
setTimeout(() => this.geminiSaveMessage.set(null), 5000);
},
error: (err) => {
this.isTestingGeminiKey.set(false);
this.geminiSaveError.set(err?.error?.detail || err?.message || 'Gemini API key test failed.');
}
});
}
public testOcrKey(): void {
const key = this.ocrKeyInput().trim();
if (!key) return;
this.isTestingOcrKey.set(true);
this.ocrSaveError.set(null);
this.ocrSaveMessage.set(null);
this.systemService.testApiKey('ocr', key).subscribe({
next: (res) => {
this.isTestingOcrKey.set(false);
if (res?.valid) {
this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key is valid!');
} else {
this.ocrSaveError.set(res?.message || 'Vision OCR API key verification failed.');
}
setTimeout(() => this.ocrSaveMessage.set(null), 5000);
},
error: (err) => {
this.isTestingOcrKey.set(false);
this.ocrSaveError.set(err?.error?.detail || err?.message || 'Vision OCR API key test failed.');
}
});
}
public getProviderDisplayName(tab: string): string {
switch (tab) {
case 'gemini': return 'Gemini';
case 'ocr': return 'Vision OCR';
default: return tab;
}
}
public getProviderEnvVar(tab: string): string {
switch (tab) {
case 'gemini': return 'GEMINI_API_KEY';
case 'ocr': return 'GOOGLE_VISION_API_KEY';
default: return 'API_KEY';
}
}
public getProviderHint(tab: string): string {
switch (tab) {
case 'gemini':
return 'For a quick start, Google Gemini provides a free API key. Artemis also supports other models (OpenAI, Claude, OpenRouter, etc.)—you can configure your own API keys directly in .env or your environment.';
case 'ocr':
return 'Google Cloud Vision API key for on-screen OCR text detection and UI grounding.';
default:
return 'Configure your API key or use environment definitions.';
}
}
public skipCredentialsCheck(): void {
this.systemService.skipCredentialsCheck();
}
public getApiKeyPlaceholder(tab: string): string {
switch (tab) {
case 'gemini': return 'Enter Gemini API Key (e.g. AIzaSy...)';
case 'ocr': return 'Enter Vision OCR API Key (e.g. AIzaSy...)';
default: return 'Enter API Key...';
}
}
public isTabProviderActive(tab: string): boolean {
if (tab === 'ocr') {
return this.isOcrConfigured();
}
const targetProvider = tab === 'gemini' ? 'google' : tab;
const providers = this.configuredLlmProviders();
return providers.some(p => p.provider === targetProvider);
}
public refreshReadiness(): void {
this.isRefreshingDiagnostics.set(true);
this.systemService.fetchReadiness().subscribe({
next: () => {
this.systemService.fetchModelConfigEnv().subscribe({
next: () => {
setTimeout(() => this.isRefreshingDiagnostics.set(false), 450);
},
error: () => this.isRefreshingDiagnostics.set(false)
});
},
error: () => this.isRefreshingDiagnostics.set(false)
});
}
public restartAdbServer(): void {
this.adbRestartFeedback.set(null);
this.systemService.restartAdb().subscribe({
next: (res) => {
this.adbRestartFeedback.set('ADB Refreshed ✓');
setTimeout(() => this.adbRestartFeedback.set(null), 2500);
},
error: () => {
this.adbRestartFeedback.set('Restart Failed');
setTimeout(() => this.adbRestartFeedback.set(null), 3000);
}
});
}
public connectWifiDevice(): void {
const host = this.wifiHost().trim();
const portStr = this.wifiPort().trim() || '5555';
const port = parseInt(portStr, 10) || 5555;
if (!host) {
this.wifiConnectError.set('Please enter a valid IP address.');
return;
}
this.isConnectingWifi.set(true);
this.wifiConnectError.set(null);
this.wifiConnectMessage.set(null);
this.systemService.connectWirelessAdb(host, port).subscribe({
next: (res) => {
this.isConnectingWifi.set(false);
const cr = res?.connect_result;
if (cr?.success) {
this.wifiConnectMessage.set(`Connected to ${host}:${port}!`);
setTimeout(() => this.wifiConnectMessage.set(null), 4000);
} else {
this.wifiConnectError.set(cr?.message || 'Connection failed. Please check phone IP & Wi-Fi.');
}
},
error: (err) => {
this.isConnectingWifi.set(false);
this.wifiConnectError.set(err?.error?.detail || 'Failed to connect. Please check adb connection.');
}
});
}
public launchAvdEmulator(avdName: string): void {
this.systemService.launchEmulator(avdName).subscribe();
}
public toggleLaunchLogs(): void {
this.showLaunchLogs.update(v => !v);
}
public stopEmulator(): void {
this.systemService.stopEmulator().subscribe();
}
public dismissEmulatorStatus(): void {
this.systemService.dismissEmulatorStatus().subscribe();
}
public selectTargetDevice(serial: string): void {
this.systemService.selectDevice(serial).subscribe();
}
public getEmulatorCommand(avdName: string): string {
const p = this.emulatorPath();
const cmd = this.isEmulatorInPath() ? 'emulator' : (p || '~/Android/Sdk/emulator/emulator');
return `${cmd} -avd ${avdName}`;
}
public copyToClipboard(text: string, id: string): void {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(() => {
this.copiedId.set(id);
setTimeout(() => {
if (this.copiedId() === id) {
this.copiedId.set(null);
}
}, 2000);
});
}
}
public setProfile(profile: 'flash' | 'pro'): void {
this.selectedProfile.set(profile);
}
public setCategory(cat: SuggestionCategory): void {
this.selectedCategory.set(cat);
}
public shuffleSuggestions(): void {
this.shuffleOffset.update(v => v + 3);
}
public getAppNamesDisplay(apps: AppReference[]): string {
return apps.map(a => a.name).join(' + ');
}
public applySuggestion(item: SmartSuggestion): void {
this.taskGoal.set(item.goal);
this.selectedProfile.set(item.profile);
this.errorMessage.set(null);
}
public applyQuickPrompt(promptGoal: string, profile?: 'flash' | 'pro'): void {
this.taskGoal.set(promptGoal);
if (profile) {
this.selectedProfile.set(profile);
}
this.errorMessage.set(null);
}
public proceedToLauncher(): void {
this.activeTab.set('launcher');
}
public runTask(): void {
const goal = this.taskGoal().trim();
if (!goal) {
this.errorMessage.set('Please enter a task goal before running.');
return;
}
if (!this.isReady()) {
this.errorMessage.set('System prerequisites are not satisfied. Please review System Setup first.');
this.activeTab.set('diagnostics');
return;
}
this.isSubmitting.set(true);
this.errorMessage.set(null);
this.agentService
.runTask(
goal,
this.selectedProfile(),
this.selectedProfile() === 'pro' && this.expectedOutput().trim()
? this.expectedOutput().trim()
: undefined,
this.selectedProfile() === 'pro' ? this.enableOutputter() : undefined
)
.subscribe({
next: () => {
this.isSubmitting.set(false);
this.router.navigate(['/workspace']);
},
error: (err) => {
console.error('Failed to submit task from home page:', err);
this.isSubmitting.set(false);
this.errorMessage.set(
err?.error?.detail || 'Failed to submit task. Please check server connection.'
);
}
});
}
}
@@ -0,0 +1,39 @@
<!--
Copyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="workspace-container" [class.is-resizing]="isDragging">
<!-- Aurora Background Glow Elements -->
<div class="aurora-background">
<div class="glow glow-1"></div>
<div class="glow glow-2"></div>
<div class="glow glow-3"></div>
</div>
<!-- Left Main Panel: Legacy Card-based Agent Log Stream -->
<main class="left-panel glass-panel">
<app-legacy-agent-stream></app-legacy-agent-stream>
</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>
</div>
@@ -0,0 +1,151 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
$bg-base: #e2e5ea;
$glass-bg: rgba(255, 255, 255, 0.45);
$glass-border: rgba(255, 255, 255, 0.6);
.workspace-container {
position: relative;
display: flex;
height: 100vh;
width: 100vw;
background-color: $bg-base;
overflow: hidden;
&.is-resizing {
cursor: col-resize;
user-select: none;
}
/* --- Dynamic Aurora Background --- */
.aurora-background {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 0;
overflow: hidden;
background: linear-gradient(135deg, #e2e5ea 0%, #cfd6df 100%);
.glow {
position: absolute;
border-radius: 50%;
filter: blur(90px);
opacity: 0.5;
animation: aurora-drift 22s infinite alternate ease-in-out;
}
.glow-1 {
width: 55vw;
height: 55vh;
background: rgba(94, 114, 228, 0.25);
top: -15%;
left: -5%;
animation-delay: 0s;
}
.glow-2 {
width: 45vw;
height: 45vh;
background: rgba(45, 206, 137, 0.2);
bottom: -10%;
left: 30%;
animation-delay: -5s;
}
.glow-3 {
width: 50vw;
height: 60vh;
background: rgba(206, 104, 172, 0.15);
top: 20%;
right: -10%;
animation-delay: -10s;
}
}
/* --- Glassmorphism Panel --- */
.glass-panel {
position: relative;
z-index: 1;
background: $glass-bg;
backdrop-filter: blur(24px) saturate(120%);
-webkit-backdrop-filter: blur(24px) saturate(120%);
border: 1px solid $glass-border;
box-shadow: 0 8px 32px rgba(31, 38, 135, 0.05);
}
.left-panel {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: rgba(255, 255, 255, 0.35);
border-right: 1px solid rgba(255, 255, 255, 0.8);
border-left: none;
border-top: none;
border-bottom: none;
}
.right-panel {
min-width: 300px;
flex-shrink: 0;
padding: 0;
border: none;
background-color: #ffffff;
box-shadow: -6px 0 24px rgba(0, 0, 0, 0.03);
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
z-index: 2;
}
.resizer {
width: 12px;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: background 0.2s ease;
margin: 0 -6px;
&:hover, .workspace-container.is-resizing & {
background: rgba(255, 255, 255, 0.15);
.resizer-handle {
background: rgba(94, 114, 228, 0.8);
height: 60px;
}
}
.resizer-handle {
width: 4px;
height: 40px;
background: rgba(0, 0, 0, 0.15);
border-radius: 4px;
transition: all 0.2s ease;
}
}
}
@keyframes aurora-drift {
0% { transform: translate(0, 0) scale(1); }
50% { transform: translate(5%, 3%) scale(1.05); }
100% { transform: translate(-2%, -5%) scale(0.95); }
}
@@ -0,0 +1,57 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
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],
templateUrl: './legacy-workspace.component.html',
styleUrl: './legacy-workspace.component.scss'
})
export class LegacyWorkspaceComponent {
public rightPanelWidth = typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450;
public isDragging = false;
public onDragStart(event: MouseEvent): void {
this.isDragging = true;
event.preventDefault();
}
@HostListener('document:mousemove', ['$event'])
public onMouseMove(event: MouseEvent): void {
if (!this.isDragging) {
return;
}
const newWidth = window.innerWidth - event.clientX;
const minWidth = 300;
const maxWidth = window.innerWidth - 300;
if (newWidth >= minWidth && newWidth <= maxWidth) {
this.rightPanelWidth = newWidth;
}
}
@HostListener('document:mouseup')
public onMouseUp(): void {
this.isDragging = false;
}
}
@@ -0,0 +1,189 @@
<!--
Copyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<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>
<!-- Bottom Floating Command Bar Dock -->
<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>
<!-- Dock Row (Contains the Main Card and the Separate Stop Button when running) -->
<div class="floating-dock-row">
<!-- Floating Dock Main Card -->
<div class="floating-dock-card" (click)="onCardClick($event)"
(mouseenter)="isHoveringCard.set(true)" (mouseleave)="isHoveringCard.set(false)">
<!-- Ambient Liquid Wave / Aurora Background Layer (Active in both Dormant & Expanded) -->
<div class="dock-wave-container" [class.is-pro]="selectedProfile() === 'pro'">
<div class="wave-glow-ambient"></div>
<div class="wave-tracks-wrapper">
<!-- Back Fluid Wave -->
<svg class="liquid-wave wave-back" viewBox="0 0 2400 80" preserveAspectRatio="none">
<defs>
<linearGradient id="waveGradBack" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" class="wave-stop-1" />
<stop offset="25%" class="wave-stop-2" />
<stop offset="50%" class="wave-stop-3" />
<stop offset="75%" class="wave-stop-4" />
<stop offset="100%" class="wave-stop-1" />
</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>
</svg>
<!-- Mid Fluid Wave -->
<svg class="liquid-wave wave-mid" viewBox="0 0 2400 80" preserveAspectRatio="none">
<defs>
<linearGradient id="waveGradMid" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" class="wave-stop-3" />
<stop offset="35%" class="wave-stop-1" />
<stop offset="70%" class="wave-stop-4" />
<stop offset="100%" class="wave-stop-3" />
</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>
</svg>
<!-- Front Fluid Wave -->
<svg class="liquid-wave wave-front" viewBox="0 0 2400 80" preserveAspectRatio="none">
<defs>
<linearGradient id="waveGradFront" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" class="wave-stop-2" />
<stop offset="30%" class="wave-stop-4" />
<stop offset="65%" class="wave-stop-1" />
<stop offset="100%" class="wave-stop-2" />
</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>
</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">
<!-- Textarea Container with Dynamic English Placeholder -->
<div class="input-content-area">
<textarea #dockInput [(ngModel)]="taskInput"
(input)="onTextareaInput($event)"
(keydown)="onKeyDown($event)"
(focus)="onInputFocus()"
(blur)="onInputBlur()"
rows="1"
[disabled]="isSubmitting"
[placeholder]="selectedProfile() === 'pro'
? 'Pro: Best for complex long-running tasks, deep reasoning & video analysis'
: 'Flash: Fast execution for direct actions and tasks within 25 steps'"
class="dock-textarea"
spellcheck="false"
></textarea>
</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>
<!-- Stop Button (Visible when task is running) -->
<button *ngIf="isTaskRunning()" type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting"
(click)="stopTask($event)"
title="Stop running task"
aria-label="Stop running task"
>
<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"
(click)="submitTask()"
[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>
</button>
</div>
<!-- Bottom Segmented Controls Row -->
<div class="card-footer-row">
<div class="footer-left-group">
<!-- Segmented Model Toggle (Flash / Pro) -->
<div class="model-profile-toggle" role="group" aria-label="Select AI Model">
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'flash'"
(mousedown)="$event.preventDefault()" (click)="setProfile('flash', $event)"
title="Flash: Fast execution for standard app workflows">
<span class="material-symbols-outlined pill-icon icon-flash">bolt</span>
<span class="pill-label">Flash</span>
</button>
<button type="button" class="profile-toggle-pill" [class.active]="selectedProfile() === 'pro'"
(mousedown)="$event.preventDefault()" (click)="setProfile('pro', $event)"
title="Pro: Deep multimodal reasoning for complex multi-step tasks">
<span class="material-symbols-outlined pill-icon icon-pro">psychology</span>
<span class="pill-label">Pro</span>
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Separate Standalone Stop Button Beside the Capsule (Visible when task is running) -->
<button *ngIf="isTaskRunning()" type="button" class="standalone-stop-btn"
[disabled]="isSubmitting"
(click)="stopTask($event)"
title="Stop running task"
aria-label="Stop running task"
>
<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>
</div>
@@ -0,0 +1,865 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
$bg-base: #e2e5ea;
$font-sans: 'Google Sans Text', 'Google Sans', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
$primary: #0f172a;
$primary-hover: #1e293b;
$rose: #ef4444;
$rose-dark: #dc2626;
:host {
display: block;
height: 100vh;
width: 100vw;
overflow: hidden;
font-family: $font-sans;
}
.workspace-container {
position: relative;
display: flex;
height: 100vh;
width: 100vw;
background-color: $bg-base;
overflow: hidden;
&.is-resizing {
cursor: col-resize;
user-select: none;
}
/* --- Glassmorphism Panel --- */
.glass-panel {
position: relative;
z-index: 1;
background: #f4fafb;
border: none;
box-shadow: none;
}
.left-panel {
flex: 1 0 500px;
display: flex;
flex-direction: column;
overflow: hidden;
background: #f4fafb;
border-right: 1px solid #e2e8f0;
border-left: none;
border-top: none;
border-bottom: none;
min-width: 450px;
height: 100%;
}
.right-panel {
flex: 0 1 auto;
min-width: 250px;
max-width: 100%;
padding: 0;
border: none;
background-color: #ffffff;
box-shadow: -6px 0 24px rgba(0, 0, 0, 0.03);
height: 100%;
display: flex;
flex-direction: column;
box-sizing: border-box;
z-index: 2;
}
.resizer {
width: 12px;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: background 0.2s ease;
margin: 0 -6px;
&:hover, .workspace-container.is-resizing & {
background: rgba(255, 255, 255, 0.15);
.resizer-handle {
background: rgba(94, 114, 228, 0.8);
height: 60px;
}
}
.resizer-handle {
width: 4px;
height: 40px;
background: rgba(0, 0, 0, 0.15);
border-radius: 4px;
transition: all 0.2s ease;
}
}
/* ========================================================================= */
/* BOTTOM FLOATING COMMAND BAR (Proximity Reveal & Minimalist Glassmorphism) */
/* ========================================================================= */
.workspace-floating-bar-wrapper {
position: absolute;
bottom: 18px;
left: 50%;
transform: translateX(-50%);
width: 100%;
max-width: min(680px, calc(100% - 36px));
z-index: 50;
display: flex;
flex-direction: column;
align-items: center;
pointer-events: none;
transition: all 0.25s cubic-bezier(0.16, 1, 0.3, 1);
/* Floating Toast / Error Notification */
.floating-dock-toast {
pointer-events: auto;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
padding: 7px 14px;
border-radius: 12px;
font-size: 12.5px;
font-weight: 500;
box-shadow: 0 8px 24px -4px rgba(239, 68, 68, 0.15);
animation: slideUpFade 0.2s ease-out;
max-width: 90%;
&.error {
background: rgba(254, 242, 242, 0.88);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(254, 202, 202, 0.9);
color: #991b1b;
.toast-icon {
font-size: 16px;
color: #ef4444;
flex-shrink: 0;
}
.toast-text {
flex: 1;
line-height: 1.4;
}
.btn-toast-close {
background: transparent;
border: none;
padding: 2px;
cursor: pointer;
color: #991b1b;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
opacity: 0.7;
transition: opacity 0.15s ease;
&:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.05);
}
.material-symbols-outlined {
font-size: 15px;
}
}
}
}
/* Dock Row: Flex container holding Main Card + Standalone Stop Button */
.floating-dock-row {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
width: 100%;
pointer-events: none;
transition: gap 0.32s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Standalone Floating Stop Button (Separated from Capsule) */
.standalone-stop-btn {
pointer-events: auto;
display: flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
min-width: 34px;
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.85);
background: rgba(239, 68, 68, 0.9);
backdrop-filter: blur(24px) saturate(190%) brightness(105%);
-webkit-backdrop-filter: blur(24px) saturate(190%) brightness(105%);
color: #ffffff;
box-shadow: 0 6px 18px -2px rgba(239, 68, 68, 0.4), inset 0 1px 1.5px rgba(255, 255, 255, 0.7);
cursor: pointer;
outline: none;
flex-shrink: 0;
transition: opacity 0.28s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.28s cubic-bezier(0.16, 1, 0.3, 1),
width 0.28s cubic-bezier(0.16, 1, 0.3, 1),
min-width 0.28s cubic-bezier(0.16, 1, 0.3, 1),
margin 0.28s cubic-bezier(0.16, 1, 0.3, 1),
background 0.2s ease,
box-shadow 0.2s ease;
animation: popIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
.material-symbols-outlined {
font-size: 17px;
line-height: 1;
}
&:hover:not(:disabled) {
background: #dc2626;
transform: scale(1.1);
box-shadow: 0 8px 22px rgba(239, 68, 68, 0.55), inset 0 1px 1.5px rgba(255, 255, 255, 0.9);
}
&:active:not(:disabled) {
transform: scale(0.93);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
/* Floating Dock Card Container */
.floating-dock-card {
pointer-events: auto;
position: relative;
width: 100%;
border-radius: 18px;
box-sizing: border-box;
overflow: hidden;
transition: max-width 0.32s cubic-bezier(0.16, 1, 0.3, 1),
width 0.32s cubic-bezier(0.16, 1, 0.3, 1),
border-radius 0.28s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.28s ease,
background 0.28s ease,
transform 0.28s cubic-bezier(0.16, 1, 0.3, 1),
border-color 0.28s ease;
}
/* ========================================================================= */
/* AMBIENT LIQUID WAVE COLOR BLOCKS (Soft Diffused Aurora Glow Waves) */
/* ========================================================================= */
.dock-wave-container {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 100%;
pointer-events: none;
border-radius: inherit;
overflow: hidden;
z-index: 0;
opacity: 0.78;
transition: opacity 0.35s ease, filter 0.35s ease;
/* Flash Mode Color Stops (Soft Electric Blue, Violet, Amber, Cyan) */
.wave-stop-1 { stop-color: #3b82f6; stop-opacity: 0.65; }
.wave-stop-2 { stop-color: #8b5cf6; stop-opacity: 0.7; }
.wave-stop-3 { stop-color: #f59e0b; stop-opacity: 0.65; }
.wave-stop-4 { stop-color: #06b6d4; stop-opacity: 0.6; }
/* Pro Mode Color Stops (Soft Cosmic Nebula / Amethyst / Fuchsia) */
&.is-pro {
.wave-stop-1 { stop-color: #7c3aed; stop-opacity: 0.7; }
.wave-stop-2 { stop-color: #ec4899; stop-opacity: 0.75; }
.wave-stop-3 { stop-color: #6366f1; stop-opacity: 0.65; }
.wave-stop-4 { stop-color: #a855f7; stop-opacity: 0.7; }
}
/* Deep Ambient Aurora Glow Diffusion */
.wave-glow-ambient {
position: absolute;
bottom: -8px;
left: 5%;
right: 5%;
height: 30px;
border-radius: 50%;
background: radial-gradient(ellipse at center, rgba(99, 102, 241, 0.4) 0%, rgba(245, 158, 11, 0.2) 50%, transparent 80%);
filter: blur(16px);
opacity: 0.7;
animation: glowBreathe 5s ease-in-out infinite alternate;
}
&.is-pro .wave-glow-ambient {
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 {
position: absolute;
bottom: 0;
left: 0;
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%);
.liquid-wave {
position: absolute;
bottom: 0;
left: 0;
width: 200%;
height: 100%;
will-change: transform;
filter: blur(3px); /* Secondary soft layer blur */
&.wave-back {
opacity: 0.45;
animation: waveSlide 12s ease-in-out infinite alternate;
}
&.wave-mid {
opacity: 0.6;
animation: waveSlideReverse 9s ease-in-out infinite alternate;
}
&.wave-front {
opacity: 0.75;
animation: waveSlide 6.5s ease-in-out infinite alternate;
}
}
}
}
/* ======================================================================= */
/* 1. DORMANT / RESTING CAPSULE STATE (Subtle Frosted Peek) */
/* ======================================================================= */
&.is-dormant {
.floating-dock-card {
max-width: 160px;
width: 160px;
margin: 0;
background: rgba(255, 255, 255, 0.65);
backdrop-filter: blur(26px) saturate(190%) brightness(104%);
-webkit-backdrop-filter: blur(26px) saturate(190%) brightness(104%);
border: 1px solid rgba(255, 255, 255, 0.85);
border-radius: 17px;
box-shadow: 0 6px 18px -2px rgba(15, 23, 42, 0.07), inset 0 1px 1.5px rgba(255, 255, 255, 0.95), inset 0 -1px 1px rgba(255, 255, 255, 0.3);
cursor: pointer;
opacity: 0.92;
transform: translateY(2px);
.dock-wave-container {
opacity: 0.78;
.wave-tracks-wrapper {
height: 12px;
}
}
&:hover {
opacity: 1;
transform: translateY(0);
background: rgba(255, 255, 255, 0.82);
border-color: rgba(255, 255, 255, 1);
box-shadow: 0 8px 24px -2px rgba(15, 23, 42, 0.12), inset 0 1px 1.5px rgba(255, 255, 255, 1);
.dock-wave-container {
opacity: 0.95;
filter: drop-shadow(0 0 6px rgba(99, 102, 241, 0.35));
}
}
.capsule-peek-view {
display: flex;
opacity: 1;
}
.expanded-card-content {
display: none;
opacity: 0;
}
}
}
/* Capsule Peek View Layout */
.capsule-peek-view {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 6px 14px;
min-height: 34px;
box-sizing: border-box;
user-select: none;
.capsule-placeholder {
color: #475569;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.1px;
white-space: nowrap;
text-align: center;
}
}
/* ======================================================================= */
/* 2. EXPANDED / ACTIVE STATE (Rich Frosted Command Box) */
/* ======================================================================= */
&.is-expanded {
.floating-dock-row {
gap: 0;
}
.standalone-stop-btn {
opacity: 0;
width: 0;
min-width: 0;
margin: 0;
padding: 0;
border-width: 0;
transform: scale(0.5);
pointer-events: none;
}
.floating-dock-card {
max-width: 100%;
width: 100%;
background: rgba(255, 255, 255, 0.68);
backdrop-filter: blur(32px) saturate(210%) brightness(105%);
-webkit-backdrop-filter: blur(32px) saturate(210%) brightness(105%);
border: 1px solid rgba(255, 255, 255, 0.82);
box-shadow: 0 16px 44px -8px rgba(15, 23, 42, 0.12), 0 2px 8px rgba(0, 0, 0, 0.02), inset 0 1.5px 2px rgba(255, 255, 255, 0.95), inset 0 -1px 2px rgba(255, 255, 255, 0.3);
opacity: 1;
transform: translateY(0);
.dock-wave-container {
opacity: 0.88;
.wave-tracks-wrapper {
height: 22px;
}
}
.capsule-peek-view {
display: none;
opacity: 0;
}
.expanded-card-content {
display: block;
opacity: 1;
animation: cardFadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
}
}
/* Focused State Glow */
&.is-focused .floating-dock-card {
background: rgba(255, 255, 255, 0.78);
backdrop-filter: blur(36px) saturate(220%) brightness(106%);
-webkit-backdrop-filter: blur(36px) saturate(220%) brightness(106%);
border-color: rgba(59, 130, 246, 0.55);
box-shadow: 0 20px 48px -6px rgba(37, 99, 235, 0.16), 0 0 0 2.5px rgba(59, 130, 246, 0.18), inset 0 1.5px 2px rgba(255, 255, 255, 1);
.dock-wave-container {
opacity: 1;
filter: drop-shadow(0 0 8px rgba(59, 130, 246, 0.35));
}
}
/* Expanded Content Rows */
.expanded-card-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
width: 100%;
.card-main-input-row {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 10px 3px 14px;
min-height: 48px;
box-sizing: border-box;
.leading-status-icon {
width: 14px;
height: 14px;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
.pulse-indicator-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #ef4444;
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.6);
animation: pulseDot 1.4s infinite;
}
}
.input-content-area {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
.dock-textarea {
width: 100%;
border: none;
outline: none;
background: transparent;
resize: none;
font-family: inherit;
font-size: 13.5px;
font-weight: 450;
line-height: 1.5;
color: #0f172a;
padding: 3px 0;
max-height: 120px;
box-sizing: border-box;
display: block;
&::placeholder {
color: #64748b;
font-size: 12.5px;
font-weight: 400;
transition: color 0.15s ease;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
}
.dock-circle-btn {
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
outline: none;
flex-shrink: 0;
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
.material-symbols-outlined {
font-size: 18px;
line-height: 1;
}
.spinning-icon {
font-size: 17px;
animation: spin 1s linear infinite;
}
/* Clear Button */
&.btn-clear {
width: 26px;
height: 26px;
background: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.6);
color: #64748b;
&:hover {
background: rgba(255, 255, 255, 0.75);
color: #0f172a;
}
}
/* Stop Button (Frosted Red) */
&.btn-stop {
width: 36px;
height: 36px;
background: rgba(239, 68, 68, 0.9);
backdrop-filter: blur(8px);
color: #ffffff;
border: 1px solid rgba(255, 255, 255, 0.35);
box-shadow: 0 4px 12px rgba(239, 68, 68, 0.3);
animation: popIn 0.2s cubic-bezier(0.16, 1, 0.3, 1);
&:hover:not(:disabled) {
background: #dc2626;
transform: scale(1.05);
box-shadow: 0 6px 16px rgba(239, 68, 68, 0.4);
}
&:active:not(:disabled) {
transform: scale(0.95);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
/* Send Button */
&.btn-send {
width: 36px;
height: 36px;
background: rgba(15, 23, 42, 0.88);
backdrop-filter: blur(12px);
border: 1px solid rgba(255, 255, 255, 0.25);
color: #ffffff;
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.2), inset 0 1px 1px rgba(255, 255, 255, 0.25);
&:disabled {
background: rgba(226, 232, 240, 0.6);
color: #94a3b8;
cursor: not-allowed;
box-shadow: none;
border-color: rgba(255, 255, 255, 0.5);
transform: none;
}
&.has-content:not(:disabled) {
background: linear-gradient(135deg, rgba(30, 41, 59, 0.95), rgba(15, 23, 42, 0.98));
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.25), inset 0 1px 1px rgba(255, 255, 255, 0.3);
&:hover {
background: linear-gradient(135deg, #2563eb, #1d4ed8);
transform: scale(1.05);
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.35);
}
}
&:active:not(:disabled) {
transform: scale(0.95);
}
}
}
}
/* Model Profile Selector & Status Footer Row */
.card-footer-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 3px 14px 8px 14px;
box-sizing: border-box;
.footer-left-group {
display: flex;
align-items: center;
gap: 8px;
/* Segmented Model Toggle with Frosted Glass */
.model-profile-toggle {
display: inline-flex;
align-items: center;
background: rgba(255, 255, 255, 0.45);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
border: 1px solid rgba(255, 255, 255, 0.65);
box-shadow: inset 0 1px 1px rgba(255, 255, 255, 0.8), 0 1px 3px rgba(0, 0, 0, 0.03);
border-radius: 8px;
padding: 2px;
gap: 2px;
.profile-toggle-pill {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 3.5px;
width: 62px;
min-width: 62px;
height: 22px;
border-radius: 6px;
border: none;
background: transparent;
color: #64748b;
font-family: inherit;
font-size: 11px;
font-weight: 550;
cursor: pointer;
transition: all 0.16s cubic-bezier(0.16, 1, 0.3, 1);
outline: none;
user-select: none;
box-sizing: border-box;
.pill-icon {
font-size: 13px;
line-height: 1;
flex-shrink: 0;
}
.pill-label {
line-height: 1;
}
&:hover:not(.active) {
color: #1e293b;
background: rgba(255, 255, 255, 0.65);
}
&.active {
background: rgba(255, 255, 255, 0.92);
backdrop-filter: blur(8px);
color: #0f172a;
font-weight: 700;
box-shadow: 0 1.5px 4px rgba(0, 0, 0, 0.08), inset 0 1px 1.5px rgba(255, 255, 255, 1);
.icon-flash {
color: #d97706;
}
.icon-pro {
color: #7c3aed;
}
}
}
}
}
.footer-right-status {
.running-status-pill {
display: inline-flex;
align-items: center;
gap: 4.5px;
font-size: 10.5px;
font-weight: 600;
color: #dc2626;
background: rgba(254, 226, 226, 0.7);
padding: 2px 7px;
border-radius: 10px;
.running-pulse-dot {
width: 5.5px;
height: 5.5px;
border-radius: 50%;
background: #ef4444;
animation: pulseDot 1.2s infinite;
}
}
}
}
}
}
/* When viewport shrinks to approximately 2/3 of standard desktop width (<= 1150px), hide right panel and leave only agent-stream */
@media (max-width: 1150px) {
.right-panel, .resizer {
display: none !important;
}
.left-panel {
border-right: none !important;
width: 100% !important;
flex: 1 1 100% !important;
min-width: 0 !important;
}
}
}
/* Animations */
@keyframes popIn {
0% {
opacity: 0;
transform: scale(0.8);
}
100% {
opacity: 1;
transform: scale(1);
}
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
@keyframes slideUpFade {
from {
opacity: 0;
transform: translateY(8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes cardFadeIn {
from {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
@keyframes pulseDot {
0% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.7);
}
70% {
transform: scale(1);
box-shadow: 0 0 0 6px rgba(239, 68, 68, 0);
}
100% {
transform: scale(0.95);
box-shadow: 0 0 0 0 rgba(239, 68, 68, 0);
}
}
/* Fluid Wave Horizontal Slide (Seamless loop) */
@keyframes waveSlide {
0% {
transform: translate3d(0, 0, 0);
}
100% {
transform: translate3d(-50%, 0, 0);
}
}
@keyframes waveSlideReverse {
0% {
transform: translate3d(-50%, 0, 0);
}
100% {
transform: translate3d(0, 0, 0);
}
}
/* Ambient Radial Glow Breathing */
@keyframes glowBreathe {
0% {
opacity: 0.55;
transform: scaleX(0.92);
}
100% {
opacity: 0.95;
transform: scaleX(1.08);
}
}
@@ -0,0 +1,284 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, HostListener, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
import { FloatingVideoPlayerComponent } from '../../components/floating-video-player/floating-video-player.component';
import { AgentService } from '../../services/agent.service';
@Component({
selector: 'app-workspace',
standalone: true,
imports: [
CommonModule,
FormsModule,
AgentStreamComponent,
ChatInterfaceComponent,
FloatingVideoPlayerComponent
],
templateUrl: './workspace.component.html',
styleUrl: './workspace.component.scss'
})
export class WorkspaceComponent implements OnInit {
public agentService = inject(AgentService);
private http = inject(HttpClient);
// 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;
// Floating Command Bar State
public taskInput: string = '';
public isSubmitting: boolean = false;
public errorMessage: string | null = null;
public selectedProfile = signal<'flash' | 'pro'>('flash');
// Expand States (Signals for 0-latency reactivity)
public isHoveringCard = signal<boolean>(false);
public isInputFocused = signal<boolean>(false);
@ViewChild('dockInput') public dockInputRef?: ElementRef<HTMLTextAreaElement>;
ngOnInit(): void {
if (typeof localStorage !== 'undefined') {
const saved = localStorage.getItem('artemis_selected_profile');
if (saved === 'flash' || saved === 'pro') {
this.selectedProfile.set(saved);
}
}
}
/**
* Set model profile ('flash' vs 'pro')
*/
public setProfile(profile: 'flash' | 'pro', event?: MouseEvent): void {
if (event) {
event.stopPropagation();
}
this.selectedProfile.set(profile);
if (typeof localStorage !== 'undefined') {
localStorage.setItem('artemis_selected_profile', profile);
}
}
/**
* Computed boolean whether a task is currently executing or active
*/
public isTaskRunning = computed(() => {
return this.agentService.isRunningTask();
});
/**
* Computed boolean whether the command bar should be in its expanded state:
* - Mouse is hovering directly on the command card
* - Input textarea is focused
* - User has entered task text (drafting)
*/
public isBarExpanded = computed(() => {
return (
this.isHoveringCard() ||
this.isInputFocused() ||
this.taskInput.trim().length > 0
);
});
/**
* Focus input handler
*/
public onInputFocus(): void {
this.isInputFocused.set(true);
}
/**
* Blur input handler
*/
public onInputBlur(): void {
this.isInputFocused.set(false);
}
/**
* Click on the resting capsule or dock card to focus textarea
*/
public onCardClick(event: MouseEvent): void {
const target = event.target as HTMLElement;
// Don't steal focus if clicking action buttons or textarea directly
if (target.closest('button') || target.tagName.toLowerCase() === 'textarea') {
return;
}
this.focusInput();
}
/**
* Focus textarea programmatically
*/
public focusInput(): void {
setTimeout(() => {
this.dockInputRef?.nativeElement?.focus();
}, 30);
}
/**
* Clear typed task input
*/
public clearInput(event?: MouseEvent): void {
if (event) {
event.stopPropagation();
}
this.taskInput = '';
if (this.dockInputRef?.nativeElement) {
this.dockInputRef.nativeElement.style.height = 'auto';
this.dockInputRef.nativeElement.focus();
}
}
/**
* Auto-resize textarea as user types multi-line tasks
*/
public onTextareaInput(event: Event): void {
const textarea = event.target as HTMLTextAreaElement;
if (!textarea) return;
textarea.style.height = 'auto';
const newHeight = Math.min(Math.max(textarea.scrollHeight, 24), 120);
textarea.style.height = `${newHeight}px`;
}
/**
* Global keyboard shortcut (⌘K or Ctrl+K) to focus input bar from anywhere
*/
@HostListener('window:keydown', ['$event'])
public onGlobalKeyDown(event: KeyboardEvent): void {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
this.focusInput();
}
}
/**
* Handle enter key in floating dock textarea
*/
public onKeyDown(event: KeyboardEvent): void {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
this.submitTask();
}
}
/**
* Submit a new task instruction
*/
public submitTask(): void {
const goal = this.taskInput.trim();
if (!goal || this.isSubmitting) {
return;
}
this.isSubmitting = true;
this.errorMessage = null;
if (this.dockInputRef?.nativeElement) {
this.dockInputRef.nativeElement.blur();
}
this.isInputFocused.set(false);
this.agentService.clearUserPinnedSession();
this.http.post<any>('/api/run', { goal, profile: this.selectedProfile() }).subscribe({
next: (res) => {
this.taskInput = '';
if (this.dockInputRef?.nativeElement) {
this.dockInputRef.nativeElement.style.height = 'auto';
}
this.isSubmitting = false;
if (res && res.tasks && res.tasks.length > 0) {
const firstTask = res.tasks[0];
if (firstTask && firstTask.session_id) {
const isCurrentlyRunning =
this.agentService.agentStatus() === 'running' ||
this.agentService.sessions().some((s) => s.status === 'running');
if (!isCurrentlyRunning) {
this.agentService.selectSession(firstTask.session_id, 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.';
setTimeout(() => {
this.errorMessage = null;
}, 5000);
}
});
}
/**
* Stop currently running task
*/
public stopTask(event?: MouseEvent): void {
if (event) {
event.stopPropagation();
}
if (!this.isTaskRunning()) {
return;
}
this.isSubmitting = true;
this.errorMessage = null;
this.agentService.stopTask(false);
setTimeout(() => {
this.isSubmitting = false;
}, 400);
}
/**
* Handle mouse down on resizer bar to start dragging
*/
public onDragStart(event: MouseEvent): void {
this.isDragging = true;
event.preventDefault();
}
/**
* Listen to global mousemove events to adjust the width dynamically
*/
@HostListener('document:mousemove', ['$event'])
public onMouseMove(event: MouseEvent): void {
if (!this.isDragging) {
return;
}
const newWidth = window.innerWidth - event.clientX;
const minWidth = 250;
const maxWidth = window.innerWidth - 300;
// Apply boundary limits to prevent panels from shrinking too much
if (newWidth >= minWidth && newWidth <= maxWidth) {
this.rightPanelWidth = newWidth;
}
}
/**
* Listen to global mouseup events to stop resizing
*/
@HostListener('document:mouseup')
public onMouseUp(): void {
this.isDragging = false;
}
}