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

@@ -0,0 +1,265 @@
<!--
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="chat-interface-container">
<!-- Task Queue & History Header -->
<header class="chat-header">
<!-- Tab Selector Header inside row -->
<div class="tab-selector-bar">
<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'"
(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>
</div>
</header>
<!-- Scrollable Messages/Content Area -->
<main class="chat-messages">
<!-- Tasks Tab Content -->
<ng-container *ngIf="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-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running'"
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>
<!-- 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-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running'"
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>
</details>
</ng-container>
<!-- Notes Tab Content -->
<ng-container *ngIf="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>
</div>
</div>
<div *ngIf="noteKeys().length === 0" 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>
<!-- 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>
</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>
</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>
@@ -0,0 +1,987 @@
/**
* 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.
*/
$border-color: rgba(0, 0, 0, 0.08);
$text-primary: #1f1f1f;
$text-secondary: #71717a;
$text-muted: #a1a1aa;
:host {
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
overflow: hidden;
}
.chat-interface-container {
container-type: inline-size;
display: flex;
flex-direction: column;
height: 100%;
background: #ffffff;
box-sizing: border-box;
.chat-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
height: 60px;
padding: 0 20px;
border-bottom: 1px solid $border-color;
background: #ffffff;
box-sizing: border-box;
.tab-selector-bar {
display: flex;
height: 36px;
box-sizing: border-box;
background: rgba(241, 245, 249, 0.72);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.85);
border-radius: 18px;
padding: 3px;
gap: 3px;
align-items: center;
flex-shrink: 0;
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.8);
.tab-selector-btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 30px;
padding: 0 14px;
box-sizing: border-box;
border: none;
background: transparent;
border-radius: 15px;
font-size: 13px;
font-weight: 600;
color: #64748b;
cursor: pointer;
line-height: 1;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
white-space: nowrap;
.tab-icon {
font-size: 16px;
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
}
&:hover:not(.active) {
color: #0f172a;
background: rgba(255, 255, 255, 0.7);
}
&.active {
background: #ffffff;
color: #2563eb;
font-weight: 700;
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02);
}
}
}
.header-right {
display: flex;
align-items: center;
justify-content: flex-end;
flex: 1 1 0;
min-width: max-content;
flex-shrink: 0;
.btn-resume-task {
padding: 6px 14px;
background: linear-gradient(135deg, rgba(37, 99, 235, 0.92), rgba(26, 115, 232, 0.85));
backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.35);
border-radius: 16px;
font-size: 13.5px;
font-weight: 600;
color: #ffffff;
cursor: pointer;
display: flex;
align-items: center;
gap: 5px;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
margin-right: 8px;
box-shadow: 0 4px 14px rgba(37, 99, 235, 0.3), inset 0 1px 1px rgba(255, 255, 255, 0.45);
.material-symbols-outlined {
font-size: 16px;
color: inherit;
}
&:hover {
background: linear-gradient(135deg, rgba(37, 99, 235, 1), rgba(26, 115, 232, 0.95));
border-color: rgba(255, 255, 255, 0.6);
box-shadow: 0 6px 18px rgba(37, 99, 235, 0.42), inset 0 1px 1px rgba(255, 255, 255, 0.6);
transform: translateY(-0.5px);
}
&:active {
transform: scale(0.97);
}
}
.btn-clear-history {
width: 32px;
height: 32px;
padding: 0;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
border: 1px solid rgba(203, 213, 225, 0.85);
border-radius: 50%;
color: #64748b;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 6px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.9);
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
.material-symbols-outlined {
font-size: 16px;
color: inherit;
}
&:hover:not(:disabled) {
background: #fef2f2;
color: #ef4444;
border-color: #fca5a5;
box-shadow: 0 3px 10px rgba(239, 68, 68, 0.15);
transform: scale(1.05);
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
}
}
}
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px 12px 100px 12px;
display: flex;
flex-direction: column;
gap: 8px;
background: #ffffff;
&::-webkit-scrollbar {
width: 5px;
}
&::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.3);
border-radius: 4px;
}
.queue-section-header {
font-size: 11.5px;
font-weight: 700;
color: #475569;
margin: 6px 0 2px;
display: flex;
align-items: center;
gap: 6px;
.section-icon {
font-size: 14px;
color: inherit;
}
}
.empty-section-placeholder {
display: flex;
align-items: center;
justify-content: center;
background: #f1f5f9;
border: 1px dashed #cbd5e1;
border-radius: 8px;
padding: 12px;
color: #64748b;
font-size: 12px;
text-align: center;
}
.history-section {
margin-top: 12px;
border-top: 1px solid rgba(226, 232, 240, 0.8);
padding-top: 12px;
&[open] .history-summary {
margin-bottom: 8px;
}
.history-summary {
font-size: 11.5px;
font-weight: 700;
color: #475569;
cursor: pointer;
outline: none;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 6px;
padding: 4px 6px;
margin-left: -6px;
border-radius: 6px;
transition: background 0.2s;
.section-icon {
font-size: 14px;
color: inherit;
}
&::-webkit-details-marker {
display: none;
}
&:hover {
background: #f1f5f9;
color: #1e293b;
}
}
.history-list-wrapper {
display: flex;
flex-direction: column;
gap: 8px;
}
}
.task-card {
background: #ffffff;
border: 1px solid rgba(226, 232, 240, 0.8);
border-radius: 10px;
padding: 10px 12px;
display: flex;
flex-direction: column;
gap: 6px;
cursor: pointer;
position: relative;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.01);
&:hover {
transform: translateY(-1px);
border-color: rgba(203, 213, 225, 1);
box-shadow: 0 3px 8px rgba(148, 163, 184, 0.08);
.task-card-header .task-card-header-actions {
&:has(.btn-delete-card) .task-time {
opacity: 0;
}
.btn-delete-card {
opacity: 1;
pointer-events: auto;
}
}
}
&.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.08);
background: #ffffff;
}
&.running {
border-color: #10b981;
box-shadow: 0 0 0 1px #10b981, 0 4px 12px rgba(16, 185, 129, 0.08);
&.selected {
border-color: #3b82f6;
box-shadow: 0 0 0 1px #3b82f6, 0 4px 12px rgba(59, 130, 246, 0.08);
}
}
.task-card-header {
display: flex;
justify-content: space-between;
align-items: center;
.task-badge {
display: inline-flex;
align-items: center;
font-size: 10.5px;
font-weight: 700;
padding: 2px 5px;
border-radius: 4px;
letter-spacing: 0.05em;
border: 1px solid #e2e8f0;
background: #fafafa;
color: #64748b;
&::before {
content: '';
width: 4px;
height: 4px;
border-radius: 50%;
display: inline-block;
margin-right: 4px;
}
&.running {
background: #fafafa;
color: #1f1f1f;
border-color: #1f1f1f;
&::before {
background-color: #1f1f1f;
animation: pulse-black-dot-small 1.5s infinite ease-in-out;
}
}
&.completed {
background: #fafafa;
color: #1f1f1f;
border-color: #e2e8f0;
&::before {
background-color: #1f1f1f;
}
}
&.pending {
background: #fafafa;
color: #64748b;
border-color: #e2e8f0;
&::before {
background-color: #94a3b8;
}
}
&.failed {
background: #fafafa;
color: #ef4444;
border-color: #fee2e2;
&::before {
background-color: #ef4444;
}
}
&.cancelled {
background: #fafafa;
color: #d97706;
border-color: #fef3c7;
&::before {
background-color: #f59e0b;
}
}
.status-dot {
display: none;
}
}
.task-card-header-actions {
display: flex;
align-items: center;
position: relative;
.task-time {
font-size: 11.5px;
color: #94a3b8;
transition: opacity 0.15s ease;
}
.btn-delete-card {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
width: 20px;
height: 20px;
padding: 0;
border: none;
background: transparent;
border-radius: 4px;
color: #94a3b8;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
opacity: 0;
pointer-events: none;
transition: all 0.15s ease;
.material-symbols-outlined {
font-size: 14px;
line-height: 1;
}
&:hover:not(:disabled) {
color: #ef4444;
background: #fee2e2;
}
&:disabled {
opacity: 0.3;
cursor: not-allowed;
}
}
}
}
.task-goal {
font-size: 12.5px;
font-weight: 500;
color: #1e293b;
line-height: 1.4;
word-break: break-word;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.task-card-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
border-top: 1px solid rgba(241, 245, 249, 0.8);
padding-top: 6px;
font-size: 11.5px;
color: #94a3b8;
.task-id {
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
font-size: 11px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
min-width: 0;
flex: 1;
transition: color 0.15s ease;
&:hover {
color: #475569;
}
}
.task-date {
flex-shrink: 0;
font-size: 11px;
color: #94a3b8;
}
}
}
}
// Notes tab content
.notes-tab-content {
display: flex;
flex-direction: column;
gap: 16px;
height: 100%;
.notes-selector-container {
display: flex;
flex-direction: column;
gap: 8px;
.selector-title {
font-size: 11px;
font-weight: 700;
color: #94a3b8;
letter-spacing: 0.05em;
}
.notes-selector-list {
display: flex;
flex-wrap: wrap;
gap: 8px;
.note-selector-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
border: 1px solid #e2e8f0;
background: #ffffff;
border-radius: 20px;
font-size: 13px;
font-weight: 500;
color: #475569;
cursor: pointer;
transition: all 0.15s ease;
.pill-icon {
font-size: 15px;
}
&:hover {
background: #f8fafc;
border-color: #cbd5e1;
color: #1e293b;
}
&.active {
background: #eff6ff;
border-color: #bfdbfe;
color: #1d4ed8;
font-weight: 600;
}
}
}
}
.empty-notes-container {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 48px 24px;
margin: auto 0;
text-align: center;
user-select: none;
animation: fadeIn 0.25s cubic-bezier(0.16, 1, 0.3, 1);
.empty-notes-icon {
width: 52px;
height: 52px;
border-radius: 50%;
background: #f1f5f9;
border: 1px solid #e2e8f0;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 14px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.02);
.material-symbols-outlined {
font-size: 26px;
color: #94a3b8;
}
}
.empty-notes-title {
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
font-weight: 600;
color: #1e293b;
margin: 0 0 6px 0;
}
.empty-notes-subtitle {
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 12.5px;
color: #64748b;
line-height: 1.5;
max-width: 240px;
margin: 0;
}
}
.note-viewer-cards {
display: flex;
flex-direction: column;
flex: 1;
padding: 16px 8px;
overflow-y: auto;
.note-title {
font-size: 14px;
font-weight: 700;
color: #1f1f1f;
margin-top: 0;
margin-bottom: 20px;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
}
.code-segment {
font-family: 'Fira Code', 'Roboto Mono', Consolas, monospace;
background-color: #f1f3f4;
color: #1967d2;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
margin: 0 2px;
word-break: break-all;
font-weight: normal;
}
.checked {
.code-segment {
color: #a1a1aa;
background-color: #f8f9fa;
}
}
.milestones-container {
display: flex;
flex-direction: column;
gap: 6px;
margin-bottom: 24px;
}
.milestone-block {
display: flex;
flex-direction: column;
&.checked {
.milestone-title-row {
.milestone-status-icon.checked {
opacity: 0.7;
}
.milestone-text {
color: #a1a1aa;
}
}
}
.milestone-title-row {
display: flex;
align-items: flex-start;
gap: 10px;
.milestone-status-icon {
font-size: 20px;
user-select: none;
margin-top: 3px;
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
&.checked {
color: #1a73e8;
}
&.progress {
color: #1a73e8;
}
&.unchecked {
color: #cbd5e1;
}
}
.milestone-text {
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
color: #1f1f1f;
font-size: 14px;
line-height: 1.8;
font-weight: 400;
word-break: break-word;
flex: 1;
span {
color: inherit;
}
.bold-segment {
font-weight: inherit;
color: inherit;
}
}
}
.milestone-substeps {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 10px;
padding-left: 28px;
.substep-row {
display: flex;
align-items: flex-start;
gap: 10px;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
color: #1f1f1f;
font-size: 14px;
line-height: 1.8;
span {
color: inherit;
}
&.checked {
color: #a1a1aa;
.check-icon.checked {
opacity: 0.7;
}
}
.check-icon {
font-size: 20px;
user-select: none;
margin-top: 3px;
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
&.checked {
color: #1a73e8;
}
&.progress {
color: #1a73e8;
}
&.unchecked {
color: #cbd5e1;
}
}
.bullet {
color: #9ca3af;
font-weight: bold;
user-select: none;
padding-left: 2px;
}
.line-text {
word-break: break-word;
color: inherit;
span {
color: inherit;
}
.bold-segment {
font-weight: inherit;
color: inherit;
}
}
}
}
}
.other-lines-container {
display: flex;
flex-direction: column;
padding: 8px 4px;
.md-line {
display: flex;
align-items: flex-start;
gap: 10px;
margin-bottom: 14px;
min-height: 22px;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', sans-serif;
color: #1f1f1f;
font-size: 14px;
line-height: 1.6;
&.empty {
height: 12px;
}
&.h1 {
font-size: 14px;
font-weight: 700;
margin-top: 24px;
margin-bottom: 12px;
border-bottom: 1px solid #e5e7eb;
padding-bottom: 6px;
}
&.h2 {
font-size: 14px;
font-weight: 700;
margin-top: 20px;
margin-bottom: 10px;
}
&.h3 {
font-size: 14px;
font-weight: 700;
margin-top: 14px;
margin-bottom: 8px;
}
&.checked {
color: #a1a1aa;
.check-icon {
color: #1a73e8;
opacity: 0.7;
}
}
&.progress {
.check-icon {
color: #1a73e8;
}
}
&.unchecked {
.check-icon {
color: #cbd5e1;
}
}
&.list-item {
.bullet {
color: #9ca3af;
font-weight: bold;
user-select: none;
}
}
span {
color: inherit;
}
.check-icon {
font-size: 20px;
user-select: none;
margin-top: 1px;
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
}
.line-text {
word-break: break-word;
color: inherit;
span {
color: inherit;
}
.bold-segment {
font-weight: inherit;
color: inherit;
}
}
}
}
}
}
}
@keyframes pulse {
0% { transform: scale(0.85); opacity: 0.6; }
50% { transform: scale(1.15); opacity: 1; }
100% { transform: scale(0.85); opacity: 0.6; }
}
@keyframes bannerSlideIn {
from { transform: translateY(5px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: translateY(0); }
}
@keyframes pulse-black-dot-small {
0% {
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0.4);
}
70% {
box-shadow: 0 0 0 4px rgba(0, 0, 0, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(0, 0, 0, 0);
}
}
@keyframes pulse-blue-dot {
0% {
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0.4);
}
70% {
box-shadow: 0 0 0 5px rgba(37, 99, 235, 0);
}
100% {
box-shadow: 0 0 0 0 rgba(37, 99, 235, 0);
}
}
@container (max-width: 380px) {
.chat-header {
padding: 0 8px;
gap: 6px;
height: 36px;
.header-left {
display: none;
}
.tab-selector-bar {
padding: 2px;
gap: 1px;
.tab-selector-btn {
padding: 5px 8px;
font-size: 12px;
gap: 4px;
.tab-icon {
font-size: 14px;
}
}
}
.header-right {
.btn-clear-history {
height: 24px;
width: 30px;
padding: 0;
font-size: 12px;
gap: 0;
border-radius: 16px;
box-sizing: border-box;
display: inline-flex;
align-items: center;
justify-content: center;
.material-symbols-outlined {
font-size: 14px;
line-height: 1;
}
.btn-text {
display: none;
}
}
}
}
}
@container (max-width: 340px) {
.chat-header {
.tab-selector-bar {
.tab-selector-btn {
padding: 5px 6px;
.tab-text {
display: none;
}
}
}
.header-right {
.btn-clear-history {
width: 26px;
}
}
}
}
@@ -0,0 +1,234 @@
/**
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, inject, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { AgentService } from '../../services/agent.service';
import { Session } from '../../core/models/session.model';
import { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote } from '../../core/models/markdown.model';
import { parseNote, parseNoteLines } from '../../utils/markdown-parser.util';
export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
@Component({
selector: 'app-chat-interface',
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './chat-interface.component.html',
styleUrl: './chat-interface.component.scss'
})
export class ChatInterfaceComponent {
public agentService = inject(AgentService);
private http = inject(HttpClient);
public taskInput: string = '';
public isSubmitting: boolean = false;
public errorMessage: string | null = null;
/**
* Filtered computed list of active tasks (running or pending) sorted by status and submission order
*/
public activeQueue = computed(() => {
const list = this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return status === 'running' || status === 'pending';
});
return list.sort((a, b) => {
const statusA = this.getTaskStatus(a);
const statusB = this.getTaskStatus(b);
if (statusA === 'running') return -1;
if (statusB === 'running') return 1;
return a.start_time - b.start_time; // FIFO order
});
});
/**
* Filtered computed list of historical/completed tasks (completed, failed, or cancelled)
*/
public historyTasks = computed(() => {
return this.agentService.sessions().filter((s) => {
const status = this.getTaskStatus(s);
return status === 'completed' || status === 'failed' || status === 'cancelled';
});
});
/**
* Submit a new task goal to the backend
*/
public submitTask(): void {
const goal = this.taskInput.trim();
if (!goal) {
return;
}
this.isSubmitting = true;
this.errorMessage = null;
this.agentService.clearUserPinnedSession();
this.http.post<any>('/api/run', { goal }).subscribe({
next: (res) => {
this.taskInput = '';
this.isSubmitting = false;
if (res && res.tasks && res.tasks.length > 0) {
const firstTask = res.tasks[0];
if (firstTask && firstTask.session_id) {
// Only auto-select immediately if no task is currently executing/running
const isCurrentlyRunning =
this.agentService.agentStatus() === 'running' ||
this.agentService.sessions().some((s) => s.status === 'running');
if (!isCurrentlyRunning) {
this.agentService.selectSession(firstTask.session_id, 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.';
// Auto-dismiss error banner after 5 seconds
setTimeout(() => {
this.errorMessage = null;
}, 5000);
}
});
}
/**
* Stop the currently running task and optionally clear the backend queue
*/
public stopTasks(stopAll: boolean = false): void {
this.isSubmitting = true;
this.errorMessage = null;
this.agentService.stopTask(stopAll);
setTimeout(() => {
this.isSubmitting = false;
}, 400);
}
/**
* Clear all database data/history to start fresh
*/
public clearHistory(): void {
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.agentService.clearAllHistory().subscribe({
next: () => {
this.isSubmitting = false;
},
error: (err: any) => {
console.error('Failed to clear history:', err);
this.isSubmitting = false;
this.errorMessage = err.error?.detail || 'Failed to clear history.';
}
});
}
/**
* Delete an individual task / session
*/
public deleteTask(sessionId: string, event: MouseEvent): void {
event.stopPropagation();
if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) {
return;
}
this.isSubmitting = true;
this.errorMessage = null;
this.agentService.deleteSession(sessionId).subscribe({
next: () => {
this.isSubmitting = false;
},
error: (err: any) => {
console.error(`Failed to delete task ${sessionId}:`, err);
this.isSubmitting = false;
this.errorMessage = err.error?.detail || 'Failed to delete task.';
}
});
}
/**
* Determine the current task execution status
*/
public getTaskStatus(session: Session): 'running' | 'completed' | 'pending' | 'failed' | 'cancelled' {
if (session.session_id === this.agentService.runningSessionId() && this.agentService.agentStatus() === 'running') {
return 'running';
}
if (session.status) {
const s = session.status.toLowerCase();
if (s === 'running' || s === 'completed' || s === 'pending' || s === 'failed' || s === 'cancelled') {
return s as any;
}
}
return 'completed';
}
/**
* Select a session in the UI to monitor its steps
*/
public selectTask(sessionId: string): void {
this.agentService.selectSession(sessionId, true);
}
// Notes Computed Properties
public currentNoteContent = computed(() => {
const notes = this.agentService.currentNotes();
const key = this.agentService.selectedNoteKey();
return notes[key] || '';
});
public noteKeys = computed(() => {
return Object.keys(this.agentService.currentNotes()).filter(key => key.toLowerCase().endsWith('.md'));
});
public parsedNote = computed<ParsedNote>(() => {
return this.getParsedNote(this.currentNoteContent());
});
public getParsedNote(content: string): ParsedNote {
return parseNote(content);
}
public getParsedNoteLines(content: string): MarkdownLine[] {
return parseNoteLines(content);
}
public selectNote(key: string): void {
this.agentService.selectedNoteKey.set(key);
}
public trackSession(index: number, session: Session): string {
return session.session_id;
}
public trackNoteKey(index: number, key: string): string {
return key;
}
public trackMilestone(index: number, milestone: NoteMilestone): number {
return milestone.index;
}
public trackMarkdownLine(index: number, line: MarkdownLine): number {
return index;
}
}