feat: enable remote SDK and resilient device orchestration

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

No files matched your search

+178
View File
@@ -0,0 +1,178 @@
# 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.
"""Same-origin security boundary for the Artemis console.
Artemis runs without user accounts: whoever can reach the TCP port is the
operator. For that model to hold for a server that browsers also talk to,
two browser-borne attack vectors must be closed even on a loopback bind:
- DNS rebinding: a page at ``attacker.example`` re-points its DNS record at
127.0.0.1 so the victim's browser reads API responses under the attacker's
origin. Blocked by validating the ``Host`` header.
- Cross-site request forgery: any web page can fire side-effectful requests
at ``http://127.0.0.1:8000`` from a visitor's browser. Blocked by requiring
the ``Origin`` header, when a browser sends one, to match the request host.
Non-browser clients (the SDK, curl, the CLI, MCP) send no ``Origin`` header
and pass through untouched. Remote access is expected to arrive over a
network-level tunnel (Tailscale, SSH port forward); a tunnel hostname that is
not an IP literal can be admitted via ``ARTEMIS_ALLOWED_HOSTS``.
"""
from __future__ import annotations
import ipaddress
import json
import os
from urllib.parse import urlsplit
_SECURITY_HEADERS: tuple[tuple[bytes, bytes], ...] = (
(b"x-content-type-options", b"nosniff"),
(b"referrer-policy", b"no-referrer"),
(b"x-frame-options", b"DENY"),
(b"cross-origin-opener-policy", b"same-origin"),
)
# Paths whose responses may hold task data, media, or diagnostics and must not
# land in shared caches or survive on disk after the session.
_NO_STORE_PREFIXES = ("/api/",)
def _hostname(netloc: str) -> str:
"""Extract the lowercase hostname from a ``host[:port]`` netloc."""
netloc = netloc.strip().lower()
if netloc.startswith("["): # IPv6 literal, e.g. [::1]:8000
return netloc.partition("]")[0].lstrip("[")
return netloc.rpartition(":")[0] if netloc.count(":") == 1 else netloc
def _is_ip_literal(host: str) -> bool:
"""An IP-literal Host cannot be forged through DNS rebinding: a browser
only sends ``Host: 192.168.1.5`` when it actually connected to that IP."""
try:
ipaddress.ip_address(host)
return True
except ValueError:
return False
def allowed_hostnames() -> frozenset[str]:
"""Hostnames (not IP literals) accepted in Host and Origin headers."""
names = {"localhost"}
extra = os.environ.get("ARTEMIS_ALLOWED_HOSTS", "")
names.update(part.strip().lower() for part in extra.split(",") if part.strip())
return frozenset(names)
def host_is_allowed(host_header: str) -> bool:
host = _hostname(host_header)
if not host:
return False
return _is_ip_literal(host) or host in allowed_hostnames()
def origin_is_allowed(origin_header: str, host_header: str) -> bool:
"""A browser request is acceptable when its page is same-origin with this
server, or was served from an explicitly allowed tunnel hostname."""
origin = origin_header.strip().lower()
if not origin or origin == "null":
return False
origin_netloc = urlsplit(origin).netloc
if not origin_netloc:
return False
if origin_netloc == host_header.strip().lower():
return True
return _hostname(origin_netloc) in allowed_hostnames()
class SameOriginBoundaryMiddleware:
"""Pure ASGI middleware enforcing the Host/Origin boundary and appending
baseline security headers. Pure ASGI (not BaseHTTPMiddleware) so future
WebSocket endpoints cannot slip past the Origin check."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] not in ("http", "websocket"):
await self.app(scope, receive, send)
return
if os.environ.get("ARTEMIS_DISABLE_ORIGIN_GUARD", "").lower() in {"1", "true", "yes"}:
await self._forward(scope, receive, send)
return
headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope["headers"]}
host_header = headers.get("host", "")
origin_header = headers.get("origin")
if not host_is_allowed(host_header):
await self._reject(
scope,
send,
"Unrecognized Host header. Access Artemis via localhost or an IP address, "
"or add your tunnel hostname to ARTEMIS_ALLOWED_HOSTS.",
)
return
if origin_header is not None and not origin_is_allowed(origin_header, host_header):
await self._reject(
scope,
send,
"Cross-origin browser requests are not accepted by the Artemis console.",
)
return
await self._forward(scope, receive, send)
async def _forward(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
async def send_with_headers(message):
if message["type"] == "http.response.start":
raw = list(message.get("headers", []))
existing = {name.lower() for name, _ in raw}
for name, value in _SECURITY_HEADERS:
if name not in existing:
raw.append((name, value))
if b"cache-control" not in existing and path.startswith(_NO_STORE_PREFIXES):
raw.append((b"cache-control", b"no-store"))
message = {**message, "headers": raw}
await send(message)
await self.app(scope, receive, send_with_headers)
@staticmethod
async def _reject(scope, send, detail: str):
if scope["type"] == "websocket":
await send({"type": "websocket.close", "code": 1008})
return
body = json.dumps({"detail": detail}).encode("utf-8")
await send(
{
"type": "http.response.start",
"status": 403,
"headers": [
(b"content-type", b"application/json"),
(b"content-length", str(len(body)).encode("latin-1")),
*_SECURITY_HEADERS,
],
}
)
await send({"type": "http.response.body", "body": body})
+2 -2
View File
@@ -51,7 +51,7 @@ class ServerState:
self.ipc_serve_task: asyncio.Task | None = None
self.ipc_port: int | None = None
self.port: int = 8000
self.host: str = "0.0.0.0"
self.host: str = "127.0.0.1"
self.is_shutting_down: bool = False
self.current_process: asyncio.subprocess.Process | None = None
@@ -151,7 +151,7 @@ class ServerState:
"""Device serials currently owned by an in-flight run."""
self.prune_finished_runs()
return {
str(run["device_id"])
str(run.get("lock_key") or run["device_id"])
for run in self.active_runs.values()
if run.get("device_id")
}
@@ -21,6 +21,18 @@ except ImportError:
from apps.admin_console.database.connection import db_session
def _canonicalize_status(row: dict[str, Any]) -> dict[str, Any]:
"""Map the legacy "success" terminal status to the canonical "completed".
Rows written before the vocabulary was unified may still carry "success";
API consumers must only ever see "completed". get_session_status is left
raw on purpose so the queue reconcile can detect and persist the rewrite.
"""
if row.get("status") == "success":
row["status"] = "completed"
return row
class SessionRepository:
"""Repository handling all database queries and updates for Sessions."""
@@ -42,7 +54,7 @@ class SessionRepository:
with db_session(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute("SELECT * FROM sessions ORDER BY start_time DESC")
return [dict(r) for r in cursor.fetchall()]
return [_canonicalize_status(dict(r)) for r in cursor.fetchall()]
def get_video_recordings_map(self) -> dict[str, str]:
with db_session(self.db_path) as conn:
@@ -240,7 +252,7 @@ class SessionRepository:
(str(session_id),),
)
row = cursor.fetchone()
return dict(row) if row else None
return _canonicalize_status(dict(row)) if row else None
except Exception:
return None
@@ -267,7 +279,13 @@ class SessionRepository:
conn.commit()
return count
def cleanup_orphans_on_startup(self) -> int:
def reconcile_orphaned_sessions(self) -> int:
"""Mark running sessions with no live worker process as failed.
This is safe to call both during startup and immediately after a
forced server stop: live workers, including workers owned by another
server instance, are left untouched.
"""
try:
with db_session(self.db_path) as conn:
cursor = conn.cursor()
@@ -301,6 +319,10 @@ class SessionRepository:
except Exception:
return 0
def cleanup_orphans_on_startup(self) -> int:
"""Backward-compatible startup entrypoint for orphan reconciliation."""
return self.reconcile_orphaned_sessions()
def get_latest_session(self) -> dict[str, Any] | None:
try:
with db_session(self.db_path) as conn:
@@ -309,7 +331,7 @@ class SessionRepository:
"SELECT session_id, status FROM sessions ORDER BY start_time DESC LIMIT 1"
)
row = cursor.fetchone()
return dict(row) if row else None
return _canonicalize_status(dict(row)) if row else None
except Exception:
return None
File diff suppressed because it is too large. Load diff
+58 -27
View File
@@ -12,11 +12,12 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
from pathlib import Path
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse, HTMLResponse
from artemis.config import IMAGES_DIR, WORKSPACE_ROOT
from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
try:
from admin_console.database.repositories.session_repository import session_repo
@@ -27,6 +28,48 @@ except ImportError:
router = APIRouter(tags=["media"])
_VIDEO_MEDIA_TYPES = {
".mp4": "video/mp4",
".webm": "video/webm",
".mkv": "video/x-matroska",
}
def _allowed_media_roots() -> list[Path]:
"""Directories the media endpoints are permitted to serve from."""
roots = []
for root in (WORKSPACE_ROOT, TRACES_PATH):
try:
roots.append(Path(root).resolve())
except OSError:
continue
return roots
def _resolve_media_path(raw_path: str, allowed_suffixes: set[str]) -> Path:
"""Resolve a client-supplied path strictly inside the allowed media roots.
Every candidate is fully resolved (symlinks, `..`, mixed separators) and
then re-checked against the allowed roots and an extension allowlist, so a
crafted URL can never address source code, databases, or dotfiles.
"""
roots = _allowed_media_roots()
candidate = Path(raw_path)
attempts = [candidate] if candidate.is_absolute() else [root / candidate for root in roots]
for attempt in attempts:
try:
resolved = attempt.resolve(strict=True)
except (OSError, ValueError):
continue
if not resolved.is_file():
continue
if not any(resolved.is_relative_to(root) for root in roots):
continue
if resolved.suffix.lower() not in allowed_suffixes:
raise HTTPException(status_code=403, detail="File type is not served by the media API.")
return resolved
raise HTTPException(status_code=404, detail="Media file not found")
@router.get("/admin", response_class=HTMLResponse)
@router.get("/debug", response_class=HTMLResponse)
@@ -47,8 +90,12 @@ async def get_admin_index():
async def get_image(image_name: str):
if not image_name.endswith(".jpg"):
image_name += ".jpg"
image_path = IMAGES_DIR / image_name
if not image_path.exists():
try:
images_root = IMAGES_DIR.resolve()
image_path = (images_root / image_name).resolve(strict=True)
except (OSError, ValueError):
raise HTTPException(status_code=404, detail="Image not found")
if not image_path.is_file() or not image_path.is_relative_to(images_root):
raise HTTPException(status_code=404, detail="Image not found")
return FileResponse(image_path, media_type="image/jpeg")
@@ -56,34 +103,18 @@ async def get_image(image_name: str):
@router.get("/videos/{video_path:path}")
async def get_video(video_path: str):
path = Path(video_path)
if not path.exists():
if not path.is_absolute():
candidate = WORKSPACE_ROOT / path
if candidate.exists():
path = candidate
else:
candidate_root = Path("/" + video_path.lstrip("/"))
if candidate_root.exists():
path = candidate_root
else:
path = candidate
if not path.exists():
raise HTTPException(status_code=404, detail=f"Video not found at {path}")
suffix = path.suffix.lower()
media_type = "video/mp4"
if suffix == ".webm":
media_type = "video/webm"
elif suffix == ".mkv":
media_type = "video/x-matroska"
return FileResponse(path, media_type=media_type)
path = _resolve_media_path(video_path, set(_VIDEO_MEDIA_TYPES))
return FileResponse(path, media_type=_VIDEO_MEDIA_TYPES[path.suffix.lower()])
@router.get("/api/sessions/{session_id}/video")
async def get_session_video(session_id: str):
# Blocking work (sqlite, filesystem scan, possible ffmpeg conversion) runs
# off the event loop.
return await asyncio.to_thread(_get_session_video_sync, session_id)
def _get_session_video_sync(session_id: str):
video_rec_map = session_repo.get_video_recordings_map()
video_idx = media_service.build_video_index()
row = session_repo.get_session_by_id(session_id)
+123 -114
View File
@@ -12,6 +12,7 @@
# See the License for the specific language governing permissions and
# limitations under the License.
import asyncio
import json
import time
from uuid import UUID
@@ -38,125 +39,133 @@ router = APIRouter(tags=["sessions"])
@router.get("/api/sessions")
async def list_sessions():
# The body does blocking work (sqlite queries, filesystem scans, ffmpeg
# conversion on first sight of a new recording), so run it off the event
# loop — the frontend polls this endpoint and it must not stall other
# requests.
try:
rows = session_repo.get_all_sessions()
video_rec_map = session_repo.get_video_recordings_map()
latest_recordings = session_repo.get_latest_video_recordings_map()
agent_names_by_session = session_repo.get_agent_trace_names_map()
video_idx = media_service.build_video_index()
default_model_info = model_service.get_active_model_info()
orphaned_ids = []
unresolved_profiles = []
result = []
try:
from artemis.runtime import DeviceExecutionLock
active_owners = DeviceExecutionLock.get_active_owners()
active_owner_sids = {
str(owner.session_id)
for owner in active_owners.values()
if owner.session_id
}
except Exception:
active_owner_sids = set()
for row_dict in rows:
s_id = str(row_dict.get("session_id"))
recording = latest_recordings.get(s_id)
d_info_raw = row_dict.get("device_info")
device_id = None
if d_info_raw:
try:
d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw
if isinstance(d_info, dict):
device_id = d_info.get("device_id") or d_info.get("device_serial")
except Exception:
pass
if not device_id and recording:
device_id = recording.get("device_id")
row_dict["device_id"] = device_id
row_dict["device_serial"] = device_id
if row_dict.get("status") == "running":
is_active = (
s_id in active_owner_sids
or s_id in state.active_connections
or (state.is_running and s_id == str(state.active_session_id))
)
worker_is_alive = session_repo.process_is_alive(row_dict.get("pid"))
if not is_active and not worker_is_alive:
row_dict["status"] = "failed"
row_dict["end_time"] = time.time()
orphaned_ids.append(s_id)
recording_status = str((recording or {}).get("status") or "unavailable")
resolved_v_url = (
media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
if recording_status != "recording"
else None
)
if resolved_v_url:
recording_status = "ready"
row_dict["video_url"] = resolved_v_url
else:
row_dict["video_url"] = None
row_dict["recording_status"] = recording_status
agent_names = agent_names_by_session.get(s_id, [])
sess_profile = model_service.resolve_session_profile(
row_dict, None, state.current_profile, agent_names=agent_names
)
row_dict["model_info"] = (
model_service.get_active_model_info(sess_profile)
if sess_profile
else default_model_info
)
if not sess_profile:
unresolved_profiles.append(row_dict)
result.append(row_dict)
# Most sessions carry a profile in device_info or an agent trace name.
# Only inspect the much larger LLM payload for legacy rows that remain
# ambiguous after those cheap checks.
if unresolved_profiles:
unresolved_ids = [str(row.get("session_id")) for row in unresolved_profiles]
llm_traces_by_session = session_repo.get_llm_traces_for_profiles_map(unresolved_ids)
for row_dict in unresolved_profiles:
s_id = str(row_dict.get("session_id"))
llm_traces = llm_traces_by_session.get(s_id)
if not llm_traces:
continue
sess_profile = model_service.resolve_session_profile(
row_dict, llm_traces, state.current_profile
)
if sess_profile:
row_dict["model_info"] = model_service.get_active_model_info(sess_profile)
if orphaned_ids:
try:
harvested = session_repo.harvest_orphaned_sessions(orphaned_ids)
print(f"[list_sessions] Auto-harvested {harvested} orphaned running session(s).")
try:
from mcp_server.utils import trace_store
for o_id in orphaned_ids:
if trace_store.read_status(str(o_id)):
trace_store.update_trace_status(
str(o_id),
"failed",
error="Process terminated unexpectedly (auto-harvested).",
)
except Exception:
pass
except Exception as e:
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
return result
return await asyncio.to_thread(_list_sessions_sync)
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def _list_sessions_sync():
rows = session_repo.get_all_sessions()
video_rec_map = session_repo.get_video_recordings_map()
latest_recordings = session_repo.get_latest_video_recordings_map()
agent_names_by_session = session_repo.get_agent_trace_names_map()
video_idx = media_service.build_video_index()
default_model_info = model_service.get_active_model_info()
orphaned_ids = []
unresolved_profiles = []
result = []
try:
from artemis.runtime import DeviceExecutionLock
active_owners = DeviceExecutionLock.get_active_owners()
active_owner_sids = {
str(owner.session_id)
for owner in active_owners.values()
if owner.session_id
}
except Exception:
active_owner_sids = set()
for row_dict in rows:
s_id = str(row_dict.get("session_id"))
recording = latest_recordings.get(s_id)
d_info_raw = row_dict.get("device_info")
device_id = None
if d_info_raw:
try:
d_info = json.loads(d_info_raw) if isinstance(d_info_raw, str) else d_info_raw
if isinstance(d_info, dict):
device_id = d_info.get("device_id") or d_info.get("device_serial")
except Exception:
pass
if not device_id and recording:
device_id = recording.get("device_id")
row_dict["device_id"] = device_id
row_dict["device_serial"] = device_id
if row_dict.get("status") == "running":
is_active = (
s_id in active_owner_sids
or s_id in state.active_connections
or (state.is_running and s_id == str(state.active_session_id))
)
worker_is_alive = session_repo.process_is_alive(row_dict.get("pid"))
if not is_active and not worker_is_alive:
row_dict["status"] = "failed"
row_dict["end_time"] = time.time()
orphaned_ids.append(s_id)
recording_status = str((recording or {}).get("status") or "unavailable")
resolved_v_url = (
media_service.resolve_video_url(row_dict, video_rec_map, video_idx)
if recording_status != "recording"
else None
)
if resolved_v_url:
recording_status = "ready"
row_dict["video_url"] = resolved_v_url
else:
row_dict["video_url"] = None
row_dict["recording_status"] = recording_status
agent_names = agent_names_by_session.get(s_id, [])
sess_profile = model_service.resolve_session_profile(
row_dict, None, state.current_profile, agent_names=agent_names
)
row_dict["model_info"] = (
model_service.get_active_model_info(sess_profile)
if sess_profile
else default_model_info
)
if not sess_profile:
unresolved_profiles.append(row_dict)
result.append(row_dict)
# Most sessions carry a profile in device_info or an agent trace name.
# Only inspect the much larger LLM payload for legacy rows that remain
# ambiguous after those cheap checks.
if unresolved_profiles:
unresolved_ids = [str(row.get("session_id")) for row in unresolved_profiles]
llm_traces_by_session = session_repo.get_llm_traces_for_profiles_map(unresolved_ids)
for row_dict in unresolved_profiles:
s_id = str(row_dict.get("session_id"))
llm_traces = llm_traces_by_session.get(s_id)
if not llm_traces:
continue
sess_profile = model_service.resolve_session_profile(
row_dict, llm_traces, state.current_profile
)
if sess_profile:
row_dict["model_info"] = model_service.get_active_model_info(sess_profile)
if orphaned_ids:
try:
harvested = session_repo.harvest_orphaned_sessions(orphaned_ids)
print(f"[list_sessions] Auto-harvested {harvested} orphaned running session(s).")
try:
from mcp_server.utils import trace_store
for o_id in orphaned_ids:
if trace_store.read_status(str(o_id)):
trace_store.update_trace_status(
str(o_id),
"failed",
error="Process terminated unexpectedly (auto-harvested).",
)
except Exception:
pass
except Exception as e:
print(f"[list_sessions] Failed to update orphaned sessions: {e}")
return result
@router.get("/api/sessions/{session_id}")
async def get_session_details(session_id: str):
"""Retrieve details for a single automation session."""
+174 -35
View File
@@ -14,15 +14,84 @@
"""System Readiness & Diagnostics Router for Artemis Admin Console."""
from fastapi import APIRouter, HTTPException
import ipaddress
import os
import secrets
from urllib.parse import urlsplit
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel, Field
from artemis.core.diagnostics import readiness_engine
from artemis.core.diagnostics.adb_server_connection import (
InvalidAdbServerEndpoint,
adb_server_connection,
)
from artemis.core.diagnostics.schema import SystemReadinessReport
router = APIRouter(prefix="/api/system", tags=["system"])
def _require_local_admin_request(request: Request) -> None:
"""Keep endpoint probing and mutation on the local administration boundary."""
client_host = request.client.host if request.client else None
allow_remote = os.getenv("ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION", "").lower() in {
"1",
"true",
"yes",
}
if client_host and not allow_remote:
try:
is_loopback = ipaddress.ip_address(client_host).is_loopback
except ValueError:
is_loopback = client_host.lower() == "localhost"
if not is_loopback:
raise HTTPException(
status_code=403,
detail=(
"ADB server settings are local-only. Set "
"ARTEMIS_ALLOW_REMOTE_ADB_CONFIGURATION=true to manage them from another "
"computer."
),
)
origin = request.headers.get("origin")
host = request.headers.get("host")
if not origin or not host:
return
origin_host = urlsplit(origin).netloc.lower()
if origin_host != host.lower():
raise HTTPException(
status_code=403,
detail="ADB server settings can only be changed from the Artemis console.",
)
def _require_loopback_request(request: Request, detail: str) -> None:
"""Reject requests whose TCP peer is not the local machine."""
client_host = request.client.host if request.client else None
try:
is_loopback = bool(client_host and ipaddress.ip_address(client_host).is_loopback)
except ValueError:
is_loopback = bool(client_host and client_host.lower() == "localhost")
if not is_loopback:
raise HTTPException(status_code=403, detail=detail)
def _require_local_lifecycle_request(request: Request) -> None:
"""Authorize a process-lifecycle request from the local CLI only."""
_require_loopback_request(request, "Server lifecycle controls are local-only.")
expected = getattr(request.app.state, "lifecycle_token", None)
supplied = request.headers.get("x-artemis-lifecycle-token")
if not (
isinstance(expected, str)
and isinstance(supplied, str)
and secrets.compare_digest(expected, supplied)
):
raise HTTPException(status_code=403, detail="Invalid server lifecycle token.")
class SelectDeviceRequest(BaseModel):
"""Payload to select an active target Android device."""
@@ -94,6 +163,66 @@ async def connect_wireless_adb(request: ConnectAdbRequest):
}
class ConnectAdbServerRequest(BaseModel):
"""Payload to select an ADB server endpoint accessible from this computer."""
host: str = Field(description="Host name or IP address of the ADB server")
port: int = Field(default=5037, ge=1, le=65535, description="ADB server port")
persist: bool = Field(default=True, description="Persist the endpoint for future launches")
@router.get("/adb/server")
async def get_adb_server_status():
"""Return the process-wide ADB server endpoint currently used by Artemis."""
return adb_server_connection.status()
@router.post("/adb/server/connect")
async def connect_adb_server(payload: ConnectAdbServerRequest, request: Request):
"""Validate and activate an ADB server endpoint."""
_require_local_admin_request(request)
try:
connection_result = await adb_server_connection.connect(
payload.host,
payload.port,
persist=payload.persist,
)
except InvalidAdbServerEndpoint as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
response: dict[str, object] = {"connection_result": connection_result}
if connection_result["success"]:
readiness_engine.set_active_device_serial(None)
readiness_engine.invalidate_cache()
response["report"] = await readiness_engine.run_all(force_refresh=True)
return response
@router.post("/adb/server/probe")
async def probe_adb_server(payload: ConnectAdbServerRequest, request: Request):
"""Test an ADB server endpoint without changing the active endpoint."""
_require_local_admin_request(request)
try:
connection_result = await adb_server_connection.probe(payload.host, payload.port)
except InvalidAdbServerEndpoint as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"connection_result": connection_result}
@router.post("/adb/server/local")
async def use_local_adb_server(request: Request, persist: bool = True):
"""Restore the standard local ADB server without touching a remote daemon."""
_require_local_admin_request(request)
connection_result = await adb_server_connection.use_local_server(persist=persist)
readiness_engine.set_active_device_serial(None)
readiness_engine.invalidate_cache()
updated_report = await readiness_engine.run_all(force_refresh=True)
return {
"connection_result": connection_result,
"report": updated_report,
}
class LaunchEmulatorRequest(BaseModel):
"""Payload to launch a local Android Virtual Device (AVD)."""
@@ -157,22 +286,18 @@ class ValidateCredentialsRequest(BaseModel):
@router.get("/credentials")
async def get_credentials():
"""Retrieve current configured API keys for management."""
"""Report which providers have an API key configured.
Secret values never leave the process: this endpoint intentionally returns
presence booleans only. Keys are written via POST /credentials and used
server-side.
"""
from artemis.config import settings
gemini_k = settings.get_api_key("google")
openai_k = settings.get_api_key("openai")
anthropic_k = settings.get_api_key("anthropic")
openrouter_k = settings.get_api_key("openrouter")
ocr_k = settings.get_api_key("ocr")
return {
"google": gemini_k.get_secret_value() if gemini_k else None,
"gemini": gemini_k.get_secret_value() if gemini_k else None,
"openai": openai_k.get_secret_value() if openai_k else None,
"anthropic": anthropic_k.get_secret_value() if anthropic_k else None,
"openrouter": openrouter_k.get_secret_value() if openrouter_k else None,
"ocr": ocr_k.get_secret_value() if ocr_k else None,
}
providers = ("google", "openai", "anthropic", "openrouter", "ocr")
status = {name: bool(settings.get_api_key(name)) for name in providers}
status["gemini"] = status["google"]
return {"providers": [{"name": name, "configured": configured} for name, configured in status.items()]}
@router.post("/credentials/test")
@@ -278,11 +403,13 @@ async def get_model_config_and_env():
return None
def mask_key(k: str | None) -> str | None:
# Expose only enough to recognize which key is active, never a usable
# fragment of the secret itself.
if not k:
return None
if len(k) <= 8:
return "****"
return f"{k[:4]}...{k[-4:]}"
return f"****{k[-4:]}"
gemini_real = get_real_env_value("GEMINI_API_KEY", "google") or get_real_env_value(
"GOOGLE_API_KEY", "google"
@@ -375,18 +502,30 @@ async def get_server_runtime_status():
port = getattr(state, "port", 8000)
status = get_server_status(port=port)
status["current_pid"] = os.getpid()
return status
# Explicit DTO: the raw metadata file additionally holds the lifecycle
# token, cmdline, and filesystem paths, none of which belong on the wire.
return {
"running": status["running"],
"port": status["port"],
"pids": status["pids"],
"active_pid": status["active_pid"],
"uptime_seconds": status["uptime_seconds"],
"url": status["url"],
"admin_url": status["admin_url"],
"current_pid": os.getpid(),
}
@router.post("/restart")
async def restart_server_endpoint():
async def restart_server_endpoint(request: Request):
"""Request a graceful restart of the Artemis server from thin clients/UI."""
import asyncio
import os
import sys
import threading
_require_loopback_request(request, "Server lifecycle controls are local-only.")
try:
from apps.admin_console.core.state import state
except ImportError:
@@ -423,33 +562,33 @@ async def restart_server_endpoint():
}
@router.post("/shutdown")
async def shutdown_server_endpoint():
@router.post("/shutdown", status_code=202)
async def shutdown_server_endpoint(request: Request):
"""Request a graceful shutdown of the Artemis server."""
import os
import signal
import threading
import asyncio
try:
from apps.admin_console.core.state import state
except ImportError:
from admin_console.core.state import state
current_pid = os.getpid()
_require_local_lifecycle_request(request)
server = getattr(request.app.state, "uvicorn_server", None)
if server is None:
raise HTTPException(status_code=503, detail="Server lifecycle controller is unavailable.")
def _shutdown_worker():
import time
async def _shutdown_after_response() -> None:
# Let Starlette flush the accepted response before Uvicorn leaves its
# main loop and invokes the FastAPI shutdown lifecycle.
await asyncio.sleep(0.05)
state.is_shutting_down = True
state.shutdown_event.set()
server.should_exit = True
time.sleep(0.5)
try:
os.kill(current_pid, signal.SIGTERM)
except Exception:
os._exit(0)
threading.Thread(target=_shutdown_worker, daemon=True).start()
asyncio.create_task(_shutdown_after_response())
return {
"status": "shutting_down",
"message": "Artemis server is shutting down.",
"pid": current_pid,
"pid": os.getpid(),
}
+56
View File
@@ -80,6 +80,62 @@ async def run_task(request: RunRequest):
detail="Either 'goal' or 'goals' list must be provided.",
)
# Idempotent SDK retries must never re-run device readiness checks. A task
# can hold the device while its admission response is lost in transit; in
# that state, probing the same device again may fail or block even though
# the original task was accepted successfully.
if request.session_id and len(incoming_goals) == 1:
requested_sid = str(request.session_id)
existing_item = next(
(
item
for item in state.queue_items
if isinstance(item, dict)
and str(item.get("session_id")) == requested_sid
),
None,
)
persisted_session = session_repo.get_session_by_id(requested_sid)
is_active = (
str(state.active_session_id) == requested_sid
or requested_sid in state.active_connections
)
if existing_item or persisted_session or is_active:
task_payload = dict(existing_item or persisted_session or {})
task_payload.setdefault("session_id", requested_sid)
task_payload.setdefault("goal", incoming_goals[0])
task_payload.setdefault("profile", request.profile or "flash")
task_payload.setdefault("device_serial", request.device_serial)
task_payload.setdefault("status", "running" if is_active else "queued")
return {
"status": task_payload["status"],
"tasks": [task_payload],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
}
# Reject an explicit unknown/offline target before running the more
# expensive readiness probe. Besides producing a stable SDK response,
# this avoids probing the currently active device for a serial that can
# never be selected. Only a successful, non-empty enumeration may reject:
# an indeterminate one (adb blip, startup) lets the submission queue and
# fail downstream with a clear error instead.
if request.device_serial:
try:
rejection = await device_pool.validate_explicit_serial_async(
request.device_serial
)
except Exception:
rejection = None
if rejection:
return {
"status": "rejected",
"error": rejection,
"tasks": [],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
}
# Re-check immediately before enqueueing so a device locked between UI
# polling intervals cannot start through a stale Ready state. Use the
# bounded submission probe: the full diagnostics path also scans packages,
+79 -22
View File
@@ -17,9 +17,11 @@
Modular entrypoint for full trace inspection, step replay, and task execution management.
"""
import argparse
import asyncio
import os
from pathlib import Path
import secrets
import signal
import sys
from types import FrameType
@@ -43,13 +45,13 @@ for _p in (str(_workspace_root), str(_apps_dir), str(_admin_console_dir), str(_c
sys.path.insert(0, _p)
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import FileResponse, HTMLResponse
import uvicorn
from artemis.runtime import (
DeviceExecutionLock,
clear_server_info,
device_pool,
shutdown_awake_service,
start_awake_service,
write_server_info,
@@ -67,6 +69,7 @@ from artemis.config import (
)
try:
from admin_console.core.security import SameOriginBoundaryMiddleware
from admin_console.core.state import ServerState, state
from admin_console.database.connection import db_session, get_db
from admin_console.database.repositories.session_repository import session_repo
@@ -79,6 +82,7 @@ try:
from admin_console.services.model_service import model_service
from admin_console.services.task_queue_service import task_queue_service
except ImportError:
from apps.admin_console.core.security import SameOriginBoundaryMiddleware
from apps.admin_console.core.state import state
from apps.admin_console.database.repositories.session_repository import session_repo
from apps.admin_console.routers import media, replay, sessions, steps, stream, system, tasks
@@ -92,15 +96,13 @@ init_ls_address()
# Initialize FastAPI application
app = FastAPI(title="Artemis Admin & Trace Console")
LIFECYCLE_TOKEN = os.environ.get("ARTEMIS_LIFECYCLE_TOKEN") or secrets.token_urlsafe(32)
app.state.lifecycle_token = LIFECYCLE_TOKEN
# Configure CORS
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# The console UI is served same-origin from this process, so no CORS grants
# exist at all; the boundary middleware rejects cross-origin browser traffic
# and unrecognized Host headers (DNS rebinding) instead.
app.add_middleware(SameOriginBoundaryMiddleware)
@app.on_event("startup")
@@ -108,11 +110,29 @@ async def on_startup():
"""Startup lifecycle hooks."""
state.is_shutting_down = False
state.shutdown_event.clear()
write_server_info(port=getattr(state, "port", 8000), host=getattr(state, "host", "0.0.0.0"))
write_server_info(
port=getattr(state, "port", 8000),
host=getattr(state, "host", "127.0.0.1"),
lifecycle_token=LIFECYCLE_TOKEN,
)
await asyncio.to_thread(start_awake_service)
cleaned_device_locks = DeviceExecutionLock.cleanup_stale_locks()
if cleaned_device_locks:
print(f"[ServerStartup] Removed {cleaned_device_locks} stale device lock(s).")
# Warm the adb server and complete one enumeration before requests are
# served, so submissions in the first seconds never race an adb cold start
# and get a false "no devices attached" rejection.
try:
adb_warmed = await device_pool.warm_up_async()
except Exception as exc:
adb_warmed = False
print(f"[ServerStartup] ADB warm-up failed: {exc}")
if not adb_warmed:
print(
"[ServerStartup] ADB warm-up did not confirm a responding adb server; "
"device enumeration will retry on demand."
)
asyncio.create_task(asyncio.to_thread(task_queue_service.archive_older_replays_on_launch))
asyncio.create_task(
asyncio.to_thread(task_queue_service.verify_chunks_exist_on_launch, replay_manager)
@@ -178,7 +198,10 @@ async def on_shutdown():
await ipc_service.stop_server()
state.ipc_subscribers.clear()
await asyncio.to_thread(shutdown_awake_service)
clear_server_info()
clear_server_info(
port=getattr(state, "port", 8000),
lifecycle_token=LIFECYCLE_TOKEN,
)
# Mount modular routers
@@ -222,6 +245,18 @@ def _get_showcase_dist() -> Path:
return base_dist / "frontend" / "browser"
def _resolve_static_file(root: Path, relative_path: str) -> Path | None:
"""Return the requested file only when it truly lives under ``root``."""
try:
root = root.resolve()
target = (root / relative_path).resolve()
except (OSError, ValueError):
return None
if target.is_file() and target.is_relative_to(root):
return target
return None
@app.get("/", include_in_schema=False)
async def serve_showcase_root():
"""Explicitly serve the Showcase UI at the root path by default."""
@@ -258,15 +293,18 @@ async def serve_showcase_spa(full_path: str):
showcase_dist = _get_showcase_dist()
# Exact static file match (js, css, images, fonts, favicon, logo)
# Exact static file match (js, css, images, fonts, favicon, logo).
# Resolve and re-anchor under the static root so encoded traversal
# sequences cannot address files outside the build directories.
if clean_path:
target_file = showcase_dist / clean_path
if target_file.is_file():
target_file = _resolve_static_file(showcase_dist, clean_path)
if target_file is not None:
return FileResponse(target_file)
# Fallback for icons/logos if showcase UI hasn't been built yet
public_file = _workspace_root / "apps" / "showcase_ui" / "public" / clean_path
if public_file.is_file():
public_root = _workspace_root / "apps" / "showcase_ui" / "public"
public_file = _resolve_static_file(public_root, clean_path)
if public_file is not None:
return FileResponse(public_file)
# Serve Showcase UI Angular SPA index.html
@@ -403,7 +441,7 @@ def run_ui_server(host: str, port: int, reload: bool = False) -> None:
"""Run the UI server with bounded, signal-aware graceful shutdown."""
state.host = host
state.port = port
write_server_info(port=port, host=host)
write_server_info(port=port, host=host, lifecycle_token=LIFECYCLE_TOKEN)
try:
if reload:
@@ -422,14 +460,33 @@ def run_ui_server(host: str, port: int, reload: bool = False) -> None:
port=port,
timeout_graceful_shutdown=5,
)
ArtemisUvicornServer(config).run()
server = ArtemisUvicornServer(config)
app.state.uvicorn_server = server
server.run()
finally:
clear_server_info()
app.state.uvicorn_server = None
clear_server_info(port=port, lifecycle_token=LIFECYCLE_TOKEN)
def main():
port = int(os.environ.get("ANTIGRAVITY_SIDECAR_WEB_PORT", 8000))
run_ui_server(host="0.0.0.0", port=port)
def main(argv: list[str] | None = None) -> None:
"""Parse the daemon/standalone server bind address and run Uvicorn."""
parser = argparse.ArgumentParser(description="Run the Artemis Admin Console server")
parser.add_argument(
"--host",
default=os.environ.get("ARTEMIS_SERVER_HOST", "127.0.0.1"),
help=(
"Address to bind the HTTP server to. Defaults to loopback; expose "
"remotely via a Tailscale/SSH tunnel rather than a wide bind."
),
)
parser.add_argument(
"--port",
type=int,
default=int(os.environ.get("ANTIGRAVITY_SIDECAR_WEB_PORT", "8000")),
help="TCP port for the HTTP server.",
)
args = parser.parse_args(argv)
run_ui_server(host=args.host, port=args.port)
if __name__ == "__main__":
+114 -57
View File
@@ -18,6 +18,7 @@ import json
import os
from pathlib import Path
import subprocess
import threading
from typing import Any
import urllib.parse
from fastapi import HTTPException
@@ -28,6 +29,12 @@ from artemis.config import IMAGES_DIR, TRACES_PATH, WORKSPACE_ROOT
class MediaService:
"""Service handling media indexing, local file security, payload unwrapping and notes/plans."""
# Conversion results keyed by source path -> (mtime, size, resolved path).
# Failed conversions are cached too (resolved path == source path) so a broken
# file doesn't re-run ffmpeg (up to 45s) on every /api/sessions poll.
_playable_cache: dict[str, tuple[float, int, str]] = {}
_playable_cache_lock = threading.Lock()
@classmethod
def ensure_browser_playable_video(cls, p: Path) -> Path:
"""Ensure the video file is in a browser-supported container (MP4).
@@ -41,59 +48,88 @@ class MediaService:
if suffix == ".mp4":
return p
if suffix in (".mkv", ".webm"):
src_stat = p.stat()
cache_key = str(p)
cached = cls._playable_cache.get(cache_key)
if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size:
return Path(cached[2])
mp4_cand = p.with_suffix(".mp4")
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
from artemis.utils.video import get_ffmpeg_path
ffmpeg = get_ffmpeg_path()
# 1. Attempt fast copy remux
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts",
"-i",
str(p),
"-c",
"copy",
"-movflags",
"+faststart",
cls._playable_cache[cache_key] = (
src_stat.st_mtime,
src_stat.st_size,
str(mp4_cand),
],
capture_output=True,
timeout=15,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
# 2. If copy remux failed, fallback to ultrafast re-encode
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts+discardcorrupt",
"-i",
str(p),
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(mp4_cand),
],
capture_output=True,
timeout=30,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
)
return mp4_cand
with cls._playable_cache_lock:
# Another thread may have converted this file while we waited.
cached = cls._playable_cache.get(cache_key)
if cached and cached[0] == src_stat.st_mtime and cached[1] == src_stat.st_size:
return Path(cached[2])
try:
result = cls._convert_to_mp4(p, mp4_cand)
except Exception:
result = p
cls._playable_cache[cache_key] = (
src_stat.st_mtime,
src_stat.st_size,
str(result),
)
return result
except Exception:
pass
return p
@staticmethod
def _convert_to_mp4(p: Path, mp4_cand: Path) -> Path:
from artemis.utils.video import get_ffmpeg_path
ffmpeg = get_ffmpeg_path()
# 1. Attempt fast copy remux
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts",
"-i",
str(p),
"-c",
"copy",
"-movflags",
"+faststart",
str(mp4_cand),
],
capture_output=True,
timeout=15,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
# 2. If copy remux failed, fallback to ultrafast re-encode
subprocess.run(
[
ffmpeg,
"-y",
"-fflags",
"+genpts+discardcorrupt",
"-i",
str(p),
"-c:v",
"libx264",
"-preset",
"ultrafast",
"-pix_fmt",
"yuv420p",
"-movflags",
"+faststart",
str(mp4_cand),
],
capture_output=True,
timeout=30,
)
if mp4_cand.exists() and mp4_cand.stat().st_size > 0:
return mp4_cand
return p
@staticmethod
def path_to_video_url(p: Path) -> str:
resolved = p.resolve()
@@ -287,30 +323,51 @@ class MediaService:
return [MediaService.unwrap_payload(x) for x in obj]
return obj
@staticmethod
def get_safe_local_file(path_str: str) -> tuple[Path, str]:
_LOCAL_FILE_MEDIA_TYPES = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".webp": "image/webp",
".mp4": "video/mp4",
".webm": "video/webm",
".mkv": "video/x-matroska",
}
@classmethod
def get_safe_local_file(cls, path_str: str) -> tuple[Path, str]:
"""Serve trace media only: resolved path must sit inside the workspace
or traces tree AND carry a media extension, so this endpoint can never
hand out .env, databases, or source files."""
decoded_path = urllib.parse.unquote(path_str)
if decoded_path.startswith("file://"):
decoded_path = decoded_path[len("file://") :]
p = Path(decoded_path).resolve()
try:
p = Path(decoded_path).resolve(strict=True)
except (OSError, ValueError):
raise HTTPException(status_code=404, detail="File not found")
if not p.is_file():
raise HTTPException(status_code=404, detail="File not found")
if not str(p).startswith(str(WORKSPACE_ROOT)):
allowed_roots = []
for root in (WORKSPACE_ROOT, TRACES_PATH):
try:
allowed_roots.append(Path(root).resolve())
except OSError:
continue
if not any(p.is_relative_to(root) for root in allowed_roots):
raise HTTPException(
status_code=403,
detail="Access denied. Path is outside workspace root.",
)
ext = p.suffix.lower()
media_type = "application/octet-stream"
if ext in (".jpg", ".jpeg"):
media_type = "image/jpeg"
elif ext == ".png":
media_type = "image/png"
elif ext == ".mp4":
media_type = "video/mp4"
media_type = cls._LOCAL_FILE_MEDIA_TYPES.get(p.suffix.lower())
if media_type is None:
raise HTTPException(
status_code=403,
detail="Access denied. Only media files are served.",
)
return p, media_type
+22 -5
View File
@@ -13,18 +13,27 @@
# limitations under the License.
import json
import time
from typing import Any
class ModelService:
"""Service handling agent architecture profile detection and metadata formatting."""
@staticmethod
def get_active_model_info(profile: str | None = None) -> dict[str, str]:
"""Return active architecture and underlying LLM model configuration."""
# 1. Determine underlying LLM model and provider from config
model_id = "gemini-3.7-flash"
# parse_llm_config re-reads and re-validates the config file on every call;
# /api/sessions calls this once per session row, so cache (provider, model_id)
# for a short TTL. Config edits take effect within _LLM_INFO_TTL seconds.
_LLM_INFO_TTL = 10.0
_llm_info_cache: tuple[float, str, str] | None = None
@classmethod
def _get_llm_provider_and_model(cls) -> tuple[str, str]:
cached = cls._llm_info_cache
now = time.monotonic()
if cached and now - cached[0] < cls._LLM_INFO_TTL:
return cached[1], cached[2]
provider = "google"
model_id = "gemini-3.7-flash"
try:
from artemis.config import parse_llm_config
@@ -37,6 +46,14 @@ class ModelService:
model_id = str(llm_cfg.default.model)
except Exception:
pass
cls._llm_info_cache = (now, provider, model_id)
return provider, model_id
@classmethod
def get_active_model_info(cls, profile: str | None = None) -> dict[str, str]:
"""Return active architecture and underlying LLM model configuration."""
# 1. Determine underlying LLM model and provider from config (cached)
provider, model_id = cls._get_llm_provider_and_model()
# 2. Determine agent architecture name (Flash vs Pro)
arch_name = "Flash"
@@ -16,6 +16,7 @@ import asyncio
import codecs
from datetime import datetime
import os
import psutil
import shutil
import subprocess
import sys
@@ -38,7 +39,13 @@ from artemis.config import (
TEST_OUTPUTS_DIR,
WORKSPACE_ROOT,
)
from artemis.runtime import DeviceExecutionLock, process_supervisor
from artemis.runtime import (
AdbEndpoint,
AdbTarget,
DeviceExecutionLock,
current_adb_endpoint,
process_supervisor,
)
class TaskQueueService:
@@ -50,6 +57,17 @@ class TaskQueueService:
# keeps weak references to running tasks).
_run_tasks: set[asyncio.Task] = set()
@staticmethod
def _task_target(task_item: dict[str, Any]) -> AdbTarget:
endpoint_data = task_item.get("adb_endpoint")
endpoint = (
AdbEndpoint.from_mapping(endpoint_data)
if isinstance(endpoint_data, dict)
else current_adb_endpoint()
)
serial = task_item.get("device_serial")
return AdbTarget(endpoint=endpoint, serial=str(serial) if serial else None)
@classmethod
def _broadcast_event(cls, event_type: str, data: Any):
"""Broadcasts an event safely to all registered subscribers."""
@@ -241,8 +259,6 @@ class TaskQueueService:
pid = getattr(proc, "pid", None)
if pid:
try:
import psutil
p = psutil.Process(pid)
if not p.is_running() or p.status() == psutil.STATUS_ZOMBIE:
return proc.returncode if proc.returncode is not None else -15
@@ -345,7 +361,7 @@ class TaskQueueService:
if isinstance(i, dict) and i.get("status") == "running"
]
busy_devices = state.busy_device_ids | {
str(i.get("device_serial")) for i in in_flight if i.get("device_serial")
cls._task_target(i).lock_key for i in in_flight if i.get("device_serial")
}
dispatched_any = False
loop = asyncio.get_running_loop()
@@ -358,19 +374,20 @@ class TaskQueueService:
continue
device = item.get("device_serial")
target = cls._task_target(item)
if mode == "per_device":
# A task without a resolved device may bind to any serial, so it
# only launches on an otherwise idle scheduler; the device lock
# then allocates freely without contending against active runs.
if device is None and (state.active_runs or in_flight or dispatched_any):
continue
if device is not None and str(device) in busy_devices:
if device is not None and target.lock_key in busy_devices:
continue
item["status"] = "running"
dispatched_any = True
if device is not None:
busy_devices.add(str(device))
busy_devices.add(target.lock_key)
run_task = loop.create_task(cls._execute_task_item(item))
# Hold a strong reference: asyncio keeps only weak refs to running
# tasks, and a collected run would strand its queue item forever.
@@ -389,6 +406,8 @@ class TaskQueueService:
proc: asyncio.subprocess.Process | None = None
output_task: asyncio.Task[None] | None = None
try:
if not isinstance(goal, str) or not goal.strip():
raise ValueError("Queued task must contain a non-empty string goal.")
expected_output = task_item.get("expected_output")
enable_outputter = task_item.get("enable_outputter")
locked_app = task_item.get("locked_app_package") or task_item.get("locked_app")
@@ -436,6 +455,9 @@ class TaskQueueService:
env["ARTEMIS_SESSION_ID"] = str(sess_id)
env["ARTEMIS_TASK_INGRESS"] = str(task_item.get("ingress", "frontend"))
env["ARTEMIS_TASK_WORKER"] = "1"
target = cls._task_target(task_item)
target.endpoint.apply_to_environment(env)
env[DeviceExecutionLock.LOCK_SCOPE_ENV] = target.lock_scope
queue_ticket = task_item.get("queue_ticket")
if queue_ticket:
env[DeviceExecutionLock.QUEUE_TICKET_ENV] = str(queue_ticket)
@@ -479,6 +501,8 @@ class TaskQueueService:
state.active_runs[run_key] = {
"process": proc,
"device_id": str(device_serial) if device_serial else None,
"lock_key": target.lock_key if device_serial else None,
"adb_endpoint": target.endpoint.to_dict(),
"goal": goal,
"profile": profile,
}
@@ -503,6 +527,7 @@ class TaskQueueService:
device_id=device_serial or "pending",
session_id=str(sess_id) if sess_id else None,
ingress=ingress_type,
lock_scope=target.lock_scope,
)
# Forward the worker's combined output and tee it into the trace's
# stdout.log so the log paths advertised by the MCP API exist.
@@ -667,6 +692,7 @@ class TaskQueueService:
enqueued_tasks = []
now = time.time()
endpoint = current_adb_endpoint()
# 1. Deduplication by session_id: if session_id is already running or queued, do not re-enqueue
if session_id and len(goals) == 1:
@@ -705,6 +731,7 @@ class TaskQueueService:
and item.get("status") == "pending"
and item.get("goal") == first_goal
and (not device_serial or item.get("device_serial") == device_serial)
and item.get("adb_endpoint", {}).get("identity") == endpoint.identity
and (now - float(item.get("created_at", 0))) < 1.0
),
None,
@@ -719,31 +746,19 @@ class TaskQueueService:
# Strict device binding: reject an explicitly requested serial that is not
# attached and authorized, instead of silently running on another device.
# The shared validator fails open on an indeterminate/empty enumeration so
# the task can proceed and fail downstream with a clear no-device error.
if device_serial:
try:
from artemis.runtime import device_pool
norm = DeviceExecutionLock._normalize_device_id
attached = {
norm(d.serial): d.state
for d in await device_pool.list_devices_async()
}
rejection = await device_pool.validate_explicit_serial_async(device_serial)
except Exception:
attached = None
# Empty enumeration (no adb / transient failure) skips validation so the
# task can proceed and fail downstream with a clear no-device error,
# mirroring mcp_server.tools.task_runner._validate_device_serial.
if attached and attached.get(norm(device_serial)) != "device":
dev_state = attached.get(norm(device_serial))
error_detail = (
f"Device '{device_serial}' is not connected. "
f"Attached devices: {sorted(attached) if attached else 'none'}."
if dev_state is None
else f"Device '{device_serial}' is attached but not ready (state: '{dev_state}')."
)
rejection = None
if rejection:
return {
"status": "rejected",
"error": error_detail,
"error": rejection,
"tasks": [],
"enqueued_count": 0,
"total_queued": len(state.queue_tasks),
@@ -772,6 +787,7 @@ class TaskQueueService:
device_id=assigned_serial or "pending",
session_id=sess_id,
ingress=ingress,
lock_scope=endpoint.identity,
)
task_item = {
"session_id": sess_id,
@@ -782,6 +798,7 @@ class TaskQueueService:
"locked_app_package": locked_app_package,
"app_path": app_path,
"device_serial": assigned_serial,
"adb_endpoint": endpoint.to_dict(),
"ingress": ingress,
"conversation_id": conversation_id,
"status": "pending",
+30 -4
View File
@@ -19,7 +19,7 @@
"prefix": "app",
"architect": {
"build": {
"builder": "@angular-devkit/build-angular:application",
"builder": "@angular/build:application",
"options": {
"outputPath": "dist/frontend",
"index": "src/index.html",
@@ -65,7 +65,7 @@
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular-devkit/build-angular:dev-server",
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
@@ -77,10 +77,10 @@
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular-devkit/build-angular:extract-i18n"
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular-devkit/build-angular:karma",
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
@@ -102,5 +102,31 @@
}
}
}
},
"schematics": {
"@schematics/angular:component": {
"type": "component"
},
"@schematics/angular:directive": {
"type": "directive"
},
"@schematics/angular:service": {
"type": "service"
},
"@schematics/angular:guard": {
"typeSeparator": "."
},
"@schematics/angular:interceptor": {
"typeSeparator": "."
},
"@schematics/angular:module": {
"typeSeparator": "."
},
"@schematics/angular:pipe": {
"typeSeparator": "."
},
"@schematics/angular:resolver": {
"typeSeparator": "."
}
}
}
+3538 -9640
View File
File diff suppressed because it is too large. Load diff
+13 -13
View File
@@ -10,29 +10,29 @@
},
"private": true,
"dependencies": {
"@angular/common": "^19.2.0",
"@angular/compiler": "^19.2.0",
"@angular/core": "^19.2.0",
"@angular/forms": "^19.2.0",
"@angular/platform-browser": "^19.2.0",
"@angular/platform-browser-dynamic": "^19.2.0",
"@angular/router": "^19.2.0",
"@angular/common": "^22.1.4",
"@angular/compiler": "^22.1.4",
"@angular/core": "^22.1.4",
"@angular/forms": "^22.1.4",
"@angular/platform-browser": "^22.1.4",
"@angular/platform-browser-dynamic": "^22.1.4",
"@angular/router": "^22.1.4",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular-devkit/build-angular": "^19.2.0",
"@angular/cli": "^19.2.0",
"@angular/compiler-cli": "^19.2.0",
"@angular/build": "^22.1.6",
"@angular/cli": "^22.1.6",
"@angular/compiler-cli": "^22.1.4",
"@types/jasmine": "~5.1.0",
"istanbul-lib-instrument": "^6.0.3",
"jasmine-core": "~5.6.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.7.2"
},
"packageManager": "pnpm@11.22.0+sha512.1ff870c4c6133dfd88fb2afc46dd13d47f09c9794b438c6fdb47ca98caf3bc16381ee0be93a091b8e3824cf01f889f46d7d9e20910fb0be1ab0fb5baa80dd621"
"typescript": "~6.0.3"
}
}
@@ -16,14 +16,14 @@
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClient, withXhr } from '@angular/common/http';
import { AppComponent } from './app.component';
describe('AppComponent', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AppComponent],
providers: [provideRouter([]), provideHttpClient()]
providers: [provideRouter([]), provideHttpClient(withXhr())]
}).compileComponents();
});
+2 -1
View File
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Component } from '@angular/core';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterOutlet } from '@angular/router';
import { NavSwitcherComponent } from './components/nav-switcher/nav-switcher.component';
@@ -22,6 +22,7 @@ import { NavSwitcherComponent } from './components/nav-switcher/nav-switcher.com
selector: 'app-root',
imports: [RouterOutlet, NavSwitcherComponent],
templateUrl: './app.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './app.component.scss'
})
export class AppComponent {
+2 -2
View File
@@ -16,7 +16,7 @@
import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';
import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClient, withXhr } from '@angular/common/http';
import { routes } from './app.routes';
@@ -24,6 +24,6 @@ export const appConfig: ApplicationConfig = {
providers: [
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient()
provideHttpClient(withXhr())
]
};
File diff suppressed because it is too large. Load diff
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Component, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit } from '@angular/core';
import { Component, ChangeDetectionStrategy, NgZone, signal, computed, effect, inject, untracked, DestroyRef, AfterViewInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { RouterLink } from '@angular/router';
@@ -202,16 +202,28 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './agent-stream.component.html',
styleUrl: './agent-stream.component.scss'
styleUrl: './agent-stream.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class AgentStreamComponent implements AfterViewInit {
public agentService = inject(AgentService);
private http = inject(HttpClient);
private destroyRef = inject(DestroyRef);
private zone = inject(NgZone);
// Auto-scroll state tracking
public isUserAtBottom = true;
private resizeObserver: ResizeObserver | null = null;
private streamLogsContainer: HTMLElement | null = null;
private autoScrollTimer: ReturnType<typeof setTimeout> | null = null;
private autoScrollStreamBoxes = false;
private onContainerScroll = () => {
const container = this.streamLogsContainer;
if (!container) return;
const threshold = 150;
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
this.isUserAtBottom = distanceToBottom <= threshold;
};
// Dynamic Planning Loader Game-style Phrases
public currentPlanningText = signal<string>(PLANNING_LOADER_PHRASES[0]);
@@ -230,9 +242,29 @@ export class AgentStreamComponent implements AfterViewInit {
public collapsedStreams = signal<Set<string>>(new Set<string>());
private scheduledCollapses = new Set<string>();
// Signals and maps for typewriter effect simulation
// Signals and maps for typewriter effect simulation. All active typewriters
// are driven by ONE requestAnimationFrame loop (run outside the Angular
// zone) that batches every text advance into a single signal update.
public typedTextsSignal = signal<Record<string, string>>({});
private typingTimers = new Map<string, any>();
private typingTargets = new Map<string, {
blockId: string;
isThinking: boolean;
execId?: string;
fallbackText: string;
}>();
private typingRafId: number | null = null;
private lastTypingTimestamp = 0;
// ≈ legacy pace of 8 characters every 12ms interval tick.
private static readonly TYPING_CHARS_PER_SECOND = 667;
private static readonly TYPING_MIN_FRAME_MS = 28;
// Rendered-markdown memo per template slot: returning the identical string
// instance for unchanged text lets the [innerHTML] binding skip re-sanitizing
// and re-parsing the whole fragment on every change-detection pass.
private markdownHtmlCache = new Map<string, { source: string; html: string }>();
// Thinking-text extraction memo (includes the JSON-detection heuristics).
private thinkingTextCache = new WeakMap<any, { native: string | null; raw: string | null }>();
private deviceSerialCache = new WeakMap<Session, string | null>();
// Set to track expanded and collapsed state of action cards
public expandedActionCards = signal<Set<string>>(new Set<string>());
@@ -285,6 +317,13 @@ export class AgentStreamComponent implements AfterViewInit {
return notes['output.md'] || null;
});
// Memoized so the Task Report card is not re-parsed (and its DOM rebuilt)
// on every change-detection pass while streams are typing.
public parsedOutputReport = computed<ParsedNote | null>(() => {
const report = this.outputterReport();
return report ? parseNote(report) : null;
});
public parsedNote = computed<ParsedNote>(() => {
return this.getParsedNote(this.currentNoteContent());
});
@@ -399,21 +438,24 @@ export class AgentStreamComponent implements AfterViewInit {
}
constructor() {
const retryClockInterval = setInterval(() => this.retryClock.set(Date.now()), 1000);
this.destroyRef.onDestroy(() => clearInterval(retryClockInterval));
// The retry clock only needs to tick while something on screen is timing
// (startup progress, LLM retry countdowns). An idle page stays quiet.
this.zone.runOutsideAngular(() => {
const retryClockInterval = setInterval(() => {
const status = this.agentService.agentStatus();
if (status === 'running' || status === 'paused' || this.agentService.isRetrying()) {
this.retryClock.set(Date.now());
}
}, 1000);
this.destroyRef.onDestroy(() => clearInterval(retryClockInterval));
});
// Auto scroll stream box during active text streaming
effect(() => {
const logs = this.filteredLogs();
const activeStream = logs.find(log => log.type === 'llm_stream' && !log.data?.isCompleted);
if (activeStream) {
setTimeout(() => {
const elements = document.getElementsByClassName('stream-box');
for (let i = 0; i < elements.length; i++) {
const el = elements[i] as HTMLElement;
el.scrollTop = el.scrollHeight;
}
}, 30);
this.scheduleAutoScroll(true);
}
});
@@ -421,13 +463,7 @@ export class AgentStreamComponent implements AfterViewInit {
effect(() => {
this.consolidatedBlocks();
this.typedTextsSignal();
setTimeout(() => {
const container = document.querySelector('.stream-logs-content');
if (container && this.isUserAtBottom) {
container.scrollTop = container.scrollHeight;
}
}, 50);
this.scheduleAutoScroll(false);
});
// Typewriter drive logic: triggers typing when block appears or expands
@@ -451,7 +487,7 @@ export class AgentStreamComponent implements AfterViewInit {
}
} else {
const currentVal = currentRecord[blockId] || '';
if (currentVal.length < rawText.length && !this.typingTimers.has(blockId)) {
if (currentVal.length < rawText.length && !this.typingTargets.has(blockId)) {
if (block.data?.isCompleted) {
this.typedTextsSignal.update(r => ({ ...r, [blockId]: rawText }));
} else {
@@ -479,7 +515,7 @@ export class AgentStreamComponent implements AfterViewInit {
}
} else {
const currentVal = currentRecord[nativeKey] || '';
if (currentVal.length < nativeText.length && !this.typingTimers.has(nativeKey)) {
if (currentVal.length < nativeText.length && !this.typingTargets.has(nativeKey)) {
if (block.data?.isCompleted) {
this.typedTextsSignal.update(r => ({ ...r, [nativeKey]: nativeText }));
this.collapsedStreams.update(set => {
@@ -501,9 +537,9 @@ export class AgentStreamComponent implements AfterViewInit {
effect(() => {
this.agentService.currentSessionId();
untracked(() => {
this.typingTimers.forEach((timer) => clearInterval(timer));
this.typingTimers.clear();
this.stopTypingLoop();
this.typedTextsSignal.set({});
this.markdownHtmlCache.clear();
this.scheduledCollapses.clear();
this.collapsedStreams.set(new Set<string>());
this.isUserAtBottom = true;
@@ -547,27 +583,65 @@ export class AgentStreamComponent implements AfterViewInit {
this.resizeObserver.disconnect();
this.resizeObserver = null;
}
this.stopTypingLoop();
if (this.autoScrollTimer) {
clearTimeout(this.autoScrollTimer);
this.autoScrollTimer = null;
}
if (this.streamLogsContainer) {
this.streamLogsContainer.removeEventListener('scroll', this.onContainerScroll);
this.streamLogsContainer = null;
}
});
}
public ngAfterViewInit(): void {
const container = document.querySelector('.stream-logs-content');
if (container && typeof ResizeObserver !== 'undefined') {
const container = document.querySelector('.stream-logs-content') as HTMLElement | null;
this.streamLogsContainer = container;
if (!container) return;
// The scroll listener lives outside the Angular zone: tracking the
// user's scroll position must not trigger change detection per frame.
this.zone.runOutsideAngular(() => {
container.addEventListener('scroll', this.onContainerScroll, { passive: true });
});
if (typeof ResizeObserver !== 'undefined') {
this.resizeObserver = new ResizeObserver(() => {
if (this.isUserAtBottom) {
container.scrollTop = container.scrollHeight;
this.scheduleAutoScroll(false);
}
});
this.resizeObserver.observe(container);
}
}
public onStreamScroll(event: Event): void {
const container = event.target as HTMLElement;
if (!container) return;
const threshold = 150;
const distanceToBottom = container.scrollHeight - container.scrollTop - container.clientHeight;
this.isUserAtBottom = distanceToBottom <= threshold;
/**
* Coalesce every auto-scroll request into one deferred pass that batches all
* DOM reads before all writes, instead of thrashing layout per update.
*/
private scheduleAutoScroll(includeStreamBoxes: boolean): void {
this.autoScrollStreamBoxes = this.autoScrollStreamBoxes || includeStreamBoxes;
if (this.autoScrollTimer) return;
this.zone.runOutsideAngular(() => {
this.autoScrollTimer = setTimeout(() => {
this.autoScrollTimer = null;
const includeBoxes = this.autoScrollStreamBoxes;
this.autoScrollStreamBoxes = false;
const boxes = includeBoxes
? (Array.from(document.getElementsByClassName('stream-box')) as HTMLElement[])
: [];
const boxTargets = boxes.map((el) => el.scrollHeight);
const container = this.streamLogsContainer;
const containerTarget = container && this.isUserAtBottom ? container.scrollHeight : null;
boxes.forEach((el, i) => { el.scrollTop = boxTargets[i]; });
if (container && containerTarget !== null) {
container.scrollTop = containerTarget;
}
}, 50);
});
}
// Top Nav Action Methods
@@ -619,22 +693,26 @@ export class AgentStreamComponent implements AfterViewInit {
* Determine the device serial number for the session
*/
public getDeviceSerial(session: Session): string | null {
if (this.deviceSerialCache.has(session)) {
return this.deviceSerialCache.get(session) ?? null;
}
let resolved: string | null = null;
const serial = session.device_serial || session.device_id;
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
return serial;
}
if (session.device_info) {
resolved = serial;
} else if (session.device_info) {
try {
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
const s = info?.device_id || info?.device_serial;
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
return s;
resolved = s;
}
} catch {
// ignore
}
}
return null;
this.deviceSerialCache.set(session, resolved);
return resolved;
}
public selectTask(sessionId: string, event?: Event): void {
@@ -675,40 +753,75 @@ export class AgentStreamComponent implements AfterViewInit {
// Typewriter & Delayed Collapse
private startTyping(key: string, targetText: string, isThinking: boolean = false, execId?: string) {
if (this.typingTimers.has(key)) {
clearInterval(this.typingTimers.get(key));
}
const blockId = key.endsWith('-native') ? key.replace('-native', '') : key;
this.typingTargets.set(key, { blockId, isThinking, execId, fallbackText: targetText });
this.ensureTypingLoop();
}
const interval = 12;
const timer = setInterval(() => {
const currentRecord = this.typedTextsSignal();
const current = currentRecord[key] || '';
const blocks = this.consolidatedBlocks();
const blockId = key.endsWith('-native') ? key.replace('-native', '') : key;
const block = blocks.find(b => b.id === blockId);
let target = targetText;
private ensureTypingLoop(): void {
if (this.typingRafId !== null) return;
this.lastTypingTimestamp = performance.now();
this.zone.runOutsideAngular(() => {
const step = (now: number) => {
this.typingRafId = null;
const elapsed = now - this.lastTypingTimestamp;
if (elapsed >= AgentStreamComponent.TYPING_MIN_FRAME_MS) {
this.lastTypingTimestamp = now;
this.advanceTyping(elapsed);
}
if (this.typingTargets.size > 0) {
this.typingRafId = requestAnimationFrame(step);
}
};
this.typingRafId = requestAnimationFrame(step);
});
}
private stopTypingLoop(): void {
this.typingTargets.clear();
if (this.typingRafId !== null) {
cancelAnimationFrame(this.typingRafId);
this.typingRafId = null;
}
}
/**
* Advance every active typewriter by the elapsed wall time and publish all
* changes as one signal update (one render pass per animation frame at most).
*/
private advanceTyping(elapsedMs: number): void {
const blocks = untracked(() => this.consolidatedBlocks());
const currentRecord = untracked(() => this.typedTextsSignal());
const chars = Math.max(
1,
Math.round(AgentStreamComponent.TYPING_CHARS_PER_SECOND * elapsedMs / 1000)
);
const updates: Record<string, string> = {};
let hasUpdates = false;
for (const [key, typing] of this.typingTargets) {
const block = blocks.find((b) => b.id === typing.blockId);
let target = typing.fallbackText;
if (block) {
target = isThinking ? (this.getNativeThinking(block) || targetText) : (this.getRawThinking(block) || targetText);
target = (typing.isThinking ? this.getNativeThinking(block) : this.getRawThinking(block))
|| typing.fallbackText;
}
const current = currentRecord[key] || '';
if (current.length < target.length) {
const nextLength = Math.min(current.length + 8, target.length);
const newText = target.slice(0, nextLength);
this.typedTextsSignal.update(r => ({ ...r, [key]: newText }));
} else {
if (block?.data?.isCompleted) {
clearInterval(timer);
this.typingTimers.delete(key);
if (isThinking && execId) {
this.triggerDelayedCollapse(execId);
}
updates[key] = target.slice(0, Math.min(current.length + chars, target.length));
hasUpdates = true;
} else if (block?.data?.isCompleted) {
this.typingTargets.delete(key);
if (typing.isThinking && typing.execId) {
this.triggerDelayedCollapse(typing.execId);
}
}
}, interval);
}
this.typingTimers.set(key, timer);
if (hasUpdates) {
this.typedTextsSignal.update((r) => ({ ...r, ...updates }));
}
}
private triggerDelayedCollapse(execId: string) {
@@ -886,6 +999,28 @@ export class AgentStreamComponent implements AfterViewInit {
}
// Generic Tool Delegation Methods
/**
* First save_note call per note key, derived once per blocks change instead
* of re-scanning every block for every rendered tool row.
*/
private firstSaveNoteByKey = computed<Map<string, any>>(() => {
const firstByKey = new Map<string, any>();
for (const block of this.consolidatedBlocks()) {
if (block.type === 'step' && block.data.generic_tools) {
for (const t of this.getUniqueGenericTools(block.data.generic_tools)) {
if (t.name && t.name.toLowerCase() === 'save_note') {
const key = this.getToolKey(t);
if (key && !firstByKey.has(key)) {
firstByKey.set(key, t);
}
}
}
}
}
return firstByKey;
});
public isFirstSaveNoteForKey(tool: any): boolean {
if (!tool || !tool.name || tool.name.toLowerCase() !== 'save_note') {
return false;
@@ -893,26 +1028,11 @@ export class AgentStreamComponent implements AfterViewInit {
const key = this.getToolKey(tool);
if (!key) return true;
const blocks = this.consolidatedBlocks();
const saveNoteCalls: any[] = [];
for (const block of blocks) {
if (block.type === 'step' && block.data.generic_tools) {
const uniqTools = this.getUniqueGenericTools(block.data.generic_tools);
for (const t of uniqTools) {
if (t.name && t.name.toLowerCase() === 'save_note' && this.getToolKey(t) === key) {
saveNoteCalls.push(t);
}
}
}
}
if (saveNoteCalls.length > 0) {
const firstCall = saveNoteCalls[0];
return (tool.trace_id && firstCall.trace_id)
? tool.trace_id === firstCall.trace_id
: tool === firstCall;
}
return true;
const firstCall = this.firstSaveNoteByKey().get(key);
if (!firstCall) return true;
return (tool.trace_id && firstCall.trace_id)
? tool.trace_id === firstCall.trace_id
: tool === firstCall;
}
public getToolDisplayLabel(tool: any): string {
@@ -1013,17 +1133,33 @@ export class AgentStreamComponent implements AfterViewInit {
return getSortedStepEvents(stepData, this.sortedEventsCache);
}
/**
* Memoized per block-data object: templates call these many times per
* change-detection pass, and the raw-thinking path runs JSON-detection
* heuristics over the full text.
*/
private getThinkingTexts(data: any): { native: string | null; raw: string | null } {
let entry = this.thinkingTextCache.get(data);
if (!entry) {
const nativeText = data.operator_native_thinking || (data.stream_type === 'thinking' ? data.text : null);
const rawText = data.operator_raw_thinking || (data.stream_type === 'text' ? data.text : null);
entry = {
native: nativeText && nativeText.trim() ? nativeText : null,
raw: rawText && rawText.trim() && this.isHumanThinking(rawText) ? rawText : null
};
this.thinkingTextCache.set(data, entry);
}
return entry;
}
public getNativeThinking(block: any): string | null {
if (!block || !block.data) return null;
const text = block.data.operator_native_thinking || (block.data.stream_type === 'thinking' ? block.data.text : null);
return text && text.trim() ? text : null;
return this.getThinkingTexts(block.data).native;
}
public getRawThinking(block: any): string | null {
if (!block || !block.data) return null;
const text = block.data.operator_raw_thinking || (block.data.stream_type === 'text' ? block.data.text : null);
if (!text || !text.trim() || !this.isHumanThinking(text)) return null;
return text;
return this.getThinkingTexts(block.data).raw;
}
public hasVisibleContent(block: any): boolean {
@@ -1272,8 +1408,23 @@ export class AgentStreamComponent implements AfterViewInit {
}
}
public renderMarkdown(text: string): string {
return renderMarkdownToHtml(text);
/**
* Render markdown with a per-slot memo. Returning the identical cached
* string instance for unchanged text means the [innerHTML] binding sees the
* same reference and skips DOM/sanitizer work entirely.
*/
public renderMarkdown(text: string, cacheKey?: string): string {
if (!text) return '';
if (!cacheKey) {
return renderMarkdownToHtml(text);
}
const cached = this.markdownHtmlCache.get(cacheKey);
if (cached && cached.source === text) {
return cached.html;
}
const html = renderMarkdownToHtml(text);
this.markdownHtmlCache.set(cacheKey, { source: text, html });
return html;
}
public onToolKeyClick(key: string): void {
@@ -1314,9 +1465,17 @@ export class AgentStreamComponent implements AfterViewInit {
return tool.trace_id || index.toString();
}
public trackStepEvent(index: number, item: { type: string; data: any }): string {
public trackStepEvent(index: number, item: { type: string; data: any; timestamp?: number }): string {
if (!item || !item.data) return index.toString();
return item.type + '-' + (item.data.timestamp || item.data.start_time || item.data.created_at || item.data.id || item.data.action || item.data.name || item.data.text?.length || index);
// A step has at most one thinking and one text event; keying them by type
// alone keeps their DOM stable while the streamed text grows (keying by
// text length would tear down and rebuild the node on every chunk).
if (item.type === 'thinking' || item.type === 'text') {
return item.type;
}
return item.type + '-' + (item.data.trace_id
|| item.data.timestamp || item.data.start_time || item.data.created_at || item.data.id
|| ((item.data.action || item.data.name || '') + '-' + index));
}
public trackParam(index: number, param: { key: string; value: string }): string {
@@ -1,17 +1,17 @@
<!--
Copyright 2026 Google LLC
Copyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="chat-interface-container">
@@ -19,267 +19,322 @@
<header class="chat-header">
<!-- Tab Selector Header inside row -->
<div class="tab-selector-bar">
<button
class="tab-selector-btn"
[class.active]="agentService.activeTab() === 'tasks'"
<button
class="tab-selector-btn"
[class.active]="agentService.activeTab() === 'tasks'"
(click)="agentService.activeTab.set('tasks')"
>
>
<span class="material-symbols-outlined tab-icon">list_alt</span>
<span class="tab-text">Task Queue</span>
</button>
<button
class="tab-selector-btn"
[class.active]="agentService.activeTab() === 'notes'"
<button
class="tab-selector-btn"
[class.active]="agentService.activeTab() === 'notes'"
(click)="agentService.activeTab.set('notes')"
>
>
<span class="material-symbols-outlined tab-icon">description</span>
<span class="tab-text">Notes & Plans</span>
</button>
</div>
<div class="header-right">
<button
*ngIf="agentService.sessions().length > 0 && agentService.activeTab() === 'tasks'"
class="btn-clear-history"
(click)="clearHistory()"
title="Clear all tasks and history"
aria-label="Clear all tasks and history"
[disabled]="isSubmitting"
>
<span class="material-symbols-outlined">delete</span>
</button>
@if (agentService.sessions().length > 0 && agentService.activeTab() === 'tasks') {
<button
class="btn-clear-history"
(click)="clearHistory()"
title="Clear all tasks and history"
aria-label="Clear all tasks and history"
[disabled]="isSubmitting()"
>
<span class="material-symbols-outlined">delete</span>
</button>
}
</div>
</header>
<!-- Scrollable Messages/Content Area -->
<main class="chat-messages">
<!-- Tasks Tab Content -->
<ng-container *ngIf="agentService.activeTab() === 'tasks'">
@if (agentService.activeTab() === 'tasks') {
<!-- Active Queue Section -->
<div class="queue-section-header">
<span class="material-symbols-outlined section-icon">bolt</span> Active Queue ({{ activeQueue().length }})
</div>
<div *ngIf="activeQueue().length === 0" class="empty-section-placeholder">
No active or queued tasks.
</div>
<div
*ngFor="let session of activeQueue(); trackBy: trackSession"
class="task-card"
[class.selected]="agentService.currentSessionId() === session.session_id"
[class.running]="getTaskStatus(session) === 'running'"
(click)="selectTask(session.session_id)"
>
<div class="task-card-header">
<span class="task-badge" [ngClass]="getTaskStatus(session)">
<span class="status-dot" *ngIf="getTaskStatus(session) === 'running'"></span>
{{ getTaskStatus(session) | uppercase }}
</span>
<div class="task-card-header-actions">
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
@if (activeQueue().length === 0) {
<div class="empty-section-placeholder">
No active or queued tasks.
</div>
}
@for (session of activeQueue(); track trackSession($index, session)) {
<div
class="task-card"
[class.selected]="agentService.currentSessionId() === session.session_id"
[class.running]="getTaskStatus(session) === 'running'"
(click)="selectTask(session.session_id)"
>
<div class="task-card-header">
<span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span>
}
{{ getTaskStatus(session) | uppercase }}
</span>
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused'"
type="button"
class="btn-stop-card"
(click)="stopTask(session.session_id, $event)"
title="Stop this task"
aria-label="Stop this task"
>
<span class="material-symbols-outlined">stop_circle</span>
</button>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
title="Delete this task"
aria-label="Delete this task"
[disabled]="isSubmitting"
>
<span class="material-symbols-outlined">delete</span>
</button>
<div class="task-card-header-actions">
@if (getDeviceSerial(session)) {
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
}
@if (getDeviceSerial(session)) {
<span class="task-time-divider">·</span>
}
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') {
<button
type="button"
class="btn-stop-card"
(click)="stopTask(session.session_id, $event)"
title="Stop this task"
aria-label="Stop this task"
>
<span class="material-symbols-outlined">stop_circle</span>
</button>
}
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
<button
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
title="Delete this task"
aria-label="Delete this task"
[disabled]="isSubmitting()"
>
<span class="material-symbols-outlined">delete</span>
</button>
}
</div>
</div>
<div class="task-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || 'Unnamed Task' }}
</div>
<div class="task-card-footer">
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
</div>
</div>
<div class="task-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || 'Unnamed Task' }}
</div>
<div class="task-card-footer">
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
</div>
</div>
}
<!-- Collapsible History Section -->
<details class="history-section" open>
<summary class="history-summary">
<span class="material-symbols-outlined section-icon">history</span> Past Runs & History ({{ historyTasks().length }})
</summary>
<div class="history-list-wrapper">
<div *ngIf="historyTasks().length === 0" class="empty-section-placeholder">
No past tasks.
</div>
<div
*ngFor="let session of historyTasks(); trackBy: trackSession"
class="task-card"
[class.selected]="agentService.currentSessionId() === session.session_id"
[class.running]="getTaskStatus(session) === 'running'"
(click)="selectTask(session.session_id)"
>
<div class="task-card-header">
<span class="task-badge" [ngClass]="getTaskStatus(session)">
<span class="status-dot" *ngIf="getTaskStatus(session) === 'running'"></span>
{{ getTaskStatus(session) | uppercase }}
</span>
<div class="task-card-header-actions">
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
@if (historyTasks().length === 0) {
<div class="empty-section-placeholder">
No past tasks.
</div>
}
@for (session of historyTasks(); track trackSession($index, session)) {
<div
class="task-card"
[class.selected]="agentService.currentSessionId() === session.session_id"
[class.running]="getTaskStatus(session) === 'running'"
(click)="selectTask(session.session_id)"
>
<div class="task-card-header">
<span class="task-badge" [ngClass]="getTaskStatus(session)">
@if (getTaskStatus(session) === 'running') {
<span class="status-dot"></span>
}
{{ getTaskStatus(session) | uppercase }}
</span>
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
<button
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
title="Delete this task"
aria-label="Delete this task"
[disabled]="isSubmitting"
>
<span class="material-symbols-outlined">delete</span>
</button>
<div class="task-card-header-actions">
@if (getDeviceSerial(session)) {
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
<span class="material-symbols-outlined device-icon">phone_android</span>
<span class="device-name">{{ getDeviceSerial(session) }}</span>
</span>
}
@if (getDeviceSerial(session)) {
<span class="task-time-divider">·</span>
}
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
<button
type="button"
class="btn-delete-card"
(click)="deleteTask(session.session_id, $event)"
title="Delete this task"
aria-label="Delete this task"
[disabled]="isSubmitting()"
>
<span class="material-symbols-outlined">delete</span>
</button>
}
</div>
</div>
<div class="task-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || 'Unnamed Task' }}
</div>
<div class="task-card-footer">
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
</div>
</div>
<div class="task-goal" title="{{ session.initial_goal }}">
{{ session.initial_goal || 'Unnamed Task' }}
</div>
<div class="task-card-footer">
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
</div>
</div>
}
</div>
</details>
</ng-container>
}
<!-- Notes Tab Content -->
<ng-container *ngIf="agentService.activeTab() === 'notes'">
@if (agentService.activeTab() === 'notes') {
<div class="notes-tab-content">
<!-- Notes Selector / Pills (Only shown if there are multiple markdown notes) -->
<div class="notes-selector-container" *ngIf="noteKeys().length > 1">
<div class="notes-selector-list">
<button
*ngFor="let key of noteKeys(); trackBy: trackNoteKey"
class="note-selector-pill"
[class.active]="agentService.selectedNoteKey() === key"
(click)="selectNote(key)"
>
<span class="material-symbols-outlined pill-icon">draft</span>
{{ key }}
</button>
@if (noteKeys().length > 1) {
<div class="notes-selector-container">
<div class="notes-selector-list">
@for (key of noteKeys(); track trackNoteKey($index, key)) {
<button
class="note-selector-pill"
[class.active]="agentService.selectedNoteKey() === key"
(click)="selectNote(key)"
>
<span class="material-symbols-outlined pill-icon">draft</span>
{{ key }}
</button>
}
</div>
</div>
</div>
<div *ngIf="noteKeys().length === 0" class="empty-notes-container">
<div class="empty-notes-icon">
<span class="material-symbols-outlined">description</span>
}
@if (noteKeys().length === 0) {
<div class="empty-notes-container">
<div class="empty-notes-icon">
<span class="material-symbols-outlined">description</span>
</div>
<h4 class="empty-notes-title">No Notes & Plans</h4>
<p class="empty-notes-subtitle">No notes or structured plans were recorded for this session.</p>
</div>
<h4 class="empty-notes-title">No Notes & Plans</h4>
<p class="empty-notes-subtitle">No notes or structured plans were recorded for this session.</p>
</div>
}
<!-- Notes Content Viewer (Clean card blocks matching the second screenshot style) -->
<div class="note-viewer-cards" *ngIf="noteKeys().length > 0">
<!-- Render Note Title if present -->
<h2 class="note-title" *ngIf="parsedNote().title">{{ parsedNote().title }}</h2>
<!-- Render Milestones list as Text Blocks -->
<div class="milestones-container" *ngIf="parsedNote().milestones.length > 0">
<div
*ngFor="let milestone of parsedNote().milestones; trackBy: trackMilestone"
class="milestone-block"
[ngClass]="milestone.type"
>
<!-- Milestone Title Row with status icon -->
<div class="milestone-title-row">
<ng-container [ngSwitch]="milestone.type">
<span *ngSwitchCase="'checked'" class="material-symbols-outlined milestone-status-icon checked">check_box</span>
<span *ngSwitchCase="'progress'" class="material-symbols-outlined milestone-status-icon progress">indeterminate_check_box</span>
<span *ngSwitchCase="'unchecked'" class="material-symbols-outlined milestone-status-icon unchecked">check_box_outline_blank</span>
</ng-container>
<div class="milestone-text">
<span
*ngFor="let seg of milestone.segments"
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
</div>
@if (noteKeys().length > 0) {
<div class="note-viewer-cards">
<!-- Render Note Title if present -->
@if (parsedNote().title) {
<h2 class="note-title">{{ parsedNote().title }}</h2>
}
<!-- Render Milestones list as Text Blocks -->
@if (parsedNote().milestones.length > 0) {
<div class="milestones-container">
@for (milestone of parsedNote().milestones; track trackMilestone($index, milestone)) {
<div
class="milestone-block"
[ngClass]="milestone.type"
>
<!-- Milestone Title Row with status icon -->
<div class="milestone-title-row">
@switch (milestone.type) {
@case ('checked') {
<span class="material-symbols-outlined milestone-status-icon checked">check_box</span>
}
@case ('progress') {
<span class="material-symbols-outlined milestone-status-icon progress">indeterminate_check_box</span>
}
@case ('unchecked') {
<span class="material-symbols-outlined milestone-status-icon unchecked">check_box_outline_blank</span>
}
}
<div class="milestone-text">
@for (seg of milestone.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</div>
</div>
<!-- Sub-steps list if any -->
@if (milestone.subSteps.length > 0) {
<div class="milestone-substeps">
@for (step of milestone.subSteps; track trackMarkdownLine($index, step)) {
<div
class="substep-row"
[ngClass]="step.type"
[style.padding-left.px]="(step.indent > 2 ? (step.indent - 2) * 12 : 0)"
>
@switch (step.type) {
@case ('checked') {
<span class="material-symbols-outlined check-icon checked">check_box</span>
}
@case ('progress') {
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
}
@case ('unchecked') {
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
}
@case ('list-item') {
<span class="bullet">•</span>
}
}
@if (step.type !== 'empty') {
<span class="line-text">
@for (seg of step.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</span>
}
</div>
}
</div>
}
</div>
}
</div>
<!-- Sub-steps list if any -->
<div class="milestone-substeps" *ngIf="milestone.subSteps.length > 0">
<div
*ngFor="let step of milestone.subSteps; trackBy: trackMarkdownLine"
class="substep-row"
[ngClass]="step.type"
[style.padding-left.px]="(step.indent > 2 ? (step.indent - 2) * 12 : 0)"
>
<ng-container [ngSwitch]="step.type">
<span *ngSwitchCase="'checked'" class="material-symbols-outlined check-icon checked">check_box</span>
<span *ngSwitchCase="'progress'" class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
<span *ngSwitchCase="'unchecked'" class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
<span *ngSwitchCase="'list-item'" class="bullet">•</span>
</ng-container>
<span class="line-text" *ngIf="step.type !== 'empty'">
<span
*ngFor="let seg of step.segments"
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
</span>
</div>
}
<!-- Render any other lines (e.g. text/markdown blocks at the end) -->
@if (parsedNote().otherLines.length > 0) {
<div class="other-lines-container">
@for (line of parsedNote().otherLines; track trackMarkdownLine($index, line)) {
<div
class="md-line"
[ngClass]="line.type"
[style.margin-left.px]="line.indent * 12"
>
@switch (line.type) {
@case ('checked') {
<span class="material-symbols-outlined check-icon checked">check_box</span>
}
@case ('progress') {
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
}
@case ('unchecked') {
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
}
@case ('list-item') {
<span class="bullet">•</span>
}
}
@if (line.type !== 'empty') {
<span class="line-text">
@for (seg of line.segments; track seg) {
<span
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
}
</span>
}
</div>
}
</div>
</div>
}
</div>
<!-- Render any other lines (e.g. text/markdown blocks at the end) -->
<div class="other-lines-container" *ngIf="parsedNote().otherLines.length > 0">
<div
*ngFor="let line of parsedNote().otherLines; trackBy: trackMarkdownLine"
class="md-line"
[ngClass]="line.type"
[style.margin-left.px]="line.indent * 12"
>
<ng-container [ngSwitch]="line.type">
<span *ngSwitchCase="'checked'" class="material-symbols-outlined check-icon checked">check_box</span>
<span *ngSwitchCase="'progress'" class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
<span *ngSwitchCase="'unchecked'" class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
<span *ngSwitchCase="'list-item'" class="bullet">•</span>
</ng-container>
<span class="line-text" *ngIf="line.type !== 'empty'">
<span
*ngFor="let seg of line.segments"
[class.bold-segment]="seg.bold"
[class.code-segment]="seg.code"
>{{ seg.text }}</span>
</span>
</div>
</div>
</div>
}
</div>
</ng-container>
}
</main>
</div>
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Component, inject, computed } from '@angular/core';
import { Component, ChangeDetectionStrategy, inject, computed, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AgentService } from '../../services/agent.service';
@@ -29,14 +29,20 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './chat-interface.component.html',
styleUrl: './chat-interface.component.scss'
styleUrl: './chat-interface.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class ChatInterfaceComponent {
public agentService = inject(AgentService);
public taskInput: string = '';
public isSubmitting: boolean = false;
public errorMessage: string | null = null;
// Signals so async completion handlers refresh this OnPush view.
public isSubmitting = signal<boolean>(false);
public errorMessage = signal<string | null>(null);
// Device-serial resolution can require a JSON.parse of device_info; memoize
// it per session object so template re-evaluation stays cheap.
private deviceSerialCache = new WeakMap<Session, string | null>();
/**
* Filtered computed list of active tasks (running or pending) sorted by status and submission order
@@ -76,23 +82,23 @@ export class ChatInterfaceComponent {
return;
}
this.isSubmitting = true;
this.errorMessage = null;
this.isSubmitting.set(true);
this.errorMessage.set(null);
this.agentService.runTask(goal).subscribe({
next: (res) => {
this.taskInput = '';
this.isSubmitting = false;
this.isSubmitting.set(false);
// Fetch status to refresh sessions list and select new session
this.agentService.fetchStatus();
},
error: (err) => {
console.error('Failed to submit task:', err);
this.isSubmitting = false;
this.errorMessage = err.error?.detail || 'The runner is busy. Please wait for the current task to finish.';
this.isSubmitting.set(false);
this.errorMessage.set(err.error?.detail || 'The runner is busy. Please wait for the current task to finish.');
// Auto-dismiss error banner after 5 seconds
setTimeout(() => {
this.errorMessage = null;
this.errorMessage.set(null);
}, 5000);
}
});
@@ -103,11 +109,11 @@ export class ChatInterfaceComponent {
*/
public stopTask(sessionId: string, event: MouseEvent): void {
event.stopPropagation();
this.isSubmitting = true;
this.errorMessage = null;
this.isSubmitting.set(true);
this.errorMessage.set(null);
this.agentService.stopTask(sessionId, false);
setTimeout(() => {
this.isSubmitting = false;
this.isSubmitting.set(false);
}, 400);
}
@@ -115,11 +121,11 @@ export class ChatInterfaceComponent {
* Stop the currently running task and optionally clear the backend queue
*/
public stopTasks(stopAll: boolean = false): void {
this.isSubmitting = true;
this.errorMessage = null;
this.isSubmitting.set(true);
this.errorMessage.set(null);
this.agentService.stopTask(stopAll);
setTimeout(() => {
this.isSubmitting = false;
this.isSubmitting.set(false);
}, 400);
}
@@ -130,16 +136,16 @@ export class ChatInterfaceComponent {
if (!confirm('Are you sure you want to clear all tasks and history? This cannot be undone.')) {
return;
}
this.isSubmitting = true;
this.errorMessage = null;
this.isSubmitting.set(true);
this.errorMessage.set(null);
this.agentService.clearAllHistory().subscribe({
next: () => {
this.isSubmitting = false;
this.isSubmitting.set(false);
},
error: (err: any) => {
console.error('Failed to clear history:', err);
this.isSubmitting = false;
this.errorMessage = err.error?.detail || 'Failed to clear history.';
this.isSubmitting.set(false);
this.errorMessage.set(err.error?.detail || 'Failed to clear history.');
}
});
}
@@ -152,16 +158,16 @@ export class ChatInterfaceComponent {
if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) {
return;
}
this.isSubmitting = true;
this.errorMessage = null;
this.isSubmitting.set(true);
this.errorMessage.set(null);
this.agentService.deleteSession(sessionId).subscribe({
next: () => {
this.isSubmitting = false;
this.isSubmitting.set(false);
},
error: (err: any) => {
console.error(`Failed to delete task ${sessionId}:`, err);
this.isSubmitting = false;
this.errorMessage = err.error?.detail || 'Failed to delete task.';
this.isSubmitting.set(false);
this.errorMessage.set(err.error?.detail || 'Failed to delete task.');
}
});
}
@@ -189,22 +195,26 @@ export class ChatInterfaceComponent {
* Determine the device serial number for the session
*/
public getDeviceSerial(session: Session): string | null {
if (this.deviceSerialCache.has(session)) {
return this.deviceSerialCache.get(session) ?? null;
}
let resolved: string | null = null;
const serial = session.device_serial || session.device_id;
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
return serial;
}
if (session.device_info) {
resolved = serial;
} else if (session.device_info) {
try {
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
const s = info?.device_id || info?.device_serial;
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
return s;
resolved = s;
}
} catch {
// ignore
}
}
return null;
this.deviceSerialCache.set(session, resolved);
return resolved;
}
/**
@@ -1,430 +1,433 @@
<!--
Copyright 2026 Google LLC
Copyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<!-- Backdrop overlay only when in theater / modal mode -->
<div
class="theater-backdrop"
*ngIf="agentService.isVideoWindowOpen() && isTheaterMode()"
(click)="isTheaterMode.set(false)"
></div>
@if (agentService.isVideoWindowOpen() && isTheaterMode()) {
<div
class="theater-backdrop"
(click)="isTheaterMode.set(false)"
></div>
}
<div
class="floating-video-wrapper"
*ngIf="agentService.isVideoWindowOpen()"
[class.minimized]="agentService.isVideoMinimized()"
[class.theater]="isTheaterMode()"
[style.left.px]="!isTheaterMode() ? posX() : null"
[style.top.px]="!isTheaterMode() ? posY() : null"
>
<!-- Window Header (Draggable Handle) -->
<header class="window-header" (mousedown)="startDrag($event)">
<div class="header-left">
<span class="material-symbols-outlined header-icon">
{{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.playerMode() === 'steps' ? 'slideshow' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display')) }}
</span>
<span class="header-title" [title]="agentService.activeVideoTitle()">
{{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.playerMode() === 'steps' ? ('Step Replay (' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording'))) }}
</span>
</div>
<div class="header-actions" (mousedown)="$event.stopPropagation()">
<!-- Mode Toggle Group: Video vs. Step Replay -->
<div class="mode-toggle-group" *ngIf="agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()">
<button
class="mode-btn"
[class.active]="agentService.playerMode() === 'video'"
(click)="agentService.setPlayerMode('video')"
title="Video Recording"
>
<span class="material-symbols-outlined mode-icon">videocam</span>
<span>Video</span>
</button>
<button
class="mode-btn"
[class.active]="agentService.playerMode() === 'steps'"
(click)="agentService.setPlayerMode('steps')"
title="Step Screenshot Replay"
>
<span class="material-symbols-outlined mode-icon">slideshow</span>
<span>Steps</span>
</button>
@if (agentService.isVideoWindowOpen()) {
<div
class="floating-video-wrapper"
[class.minimized]="agentService.isVideoMinimized()"
[class.theater]="isTheaterMode()"
[style.left.px]="!isTheaterMode() ? posX() : null"
[style.top.px]="!isTheaterMode() ? posY() : null"
>
<!-- Window Header (Draggable Handle) -->
<header class="window-header" (mousedown)="startDrag($event)">
<div class="header-left">
<span class="material-symbols-outlined header-icon">
{{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.playerMode() === 'steps' ? 'slideshow' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display')) }}
</span>
<span class="header-title" [title]="agentService.activeVideoTitle()">
{{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.playerMode() === 'steps' ? ('Step Replay (' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording'))) }}
</span>
</div>
<!-- Minimize / Restore Toggle -->
<button
class="header-btn"
(click)="toggleMinimize()"
[title]="agentService.isVideoMinimized() ? 'Restore' : 'Minimize'"
aria-label="Minimize"
>
<span class="material-symbols-outlined">{{ agentService.isVideoMinimized() ? 'expand_less' : 'remove' }}</span>
</button>
<!-- Theater / Fullscreen Toggle -->
<button
class="header-btn"
(click)="toggleTheater()"
[title]="isTheaterMode() ? 'Exit Theater' : 'Theater Mode'"
*ngIf="!agentService.isVideoMinimized()"
aria-label="Theater"
>
<span class="material-symbols-outlined">{{ isTheaterMode() ? 'close_fullscreen' : 'open_in_full' }}</span>
</button>
<!-- Close Window -->
<button
class="header-btn close-btn"
(click)="agentService.closeVideoPlayer()"
title="Close"
aria-label="Close"
>
<span class="material-symbols-outlined">close</span>
</button>
</div>
</header>
<!-- Video Main Content Area -->
<div class="video-body" *ngIf="!agentService.isVideoMinimized()">
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
<ng-container *ngIf="agentService.recordingPlaybackStatus() === 'live'">
<div class="live-stream-container" *ngIf="!liveStreamError()">
<img
class="live-screen-img"
[src]="liveStreamUrl()"
(error)="onLiveStreamError()"
alt="Live Device Screen"
/>
<div class="live-status-overlay">
<div class="live-badge">
<span class="pulse-dot"></span>
<span>LIVE</span>
<div class="header-actions" (mousedown)="$event.stopPropagation()">
<!-- Mode Toggle Group: Video vs. Step Replay -->
@if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
<div class="mode-toggle-group">
<button
class="mode-btn"
[class.active]="agentService.playerMode() === 'video'"
(click)="agentService.setPlayerMode('video')"
title="Video Recording"
>
<span class="material-symbols-outlined mode-icon">videocam</span>
<span>Video</span>
</button>
<button
class="mode-btn"
[class.active]="agentService.playerMode() === 'steps'"
(click)="agentService.setPlayerMode('steps')"
title="Step Screenshot Replay"
>
<span class="material-symbols-outlined mode-icon">slideshow</span>
<span>Steps</span>
</button>
</div>
<button class="live-refresh-btn" (click)="retryLiveStream()" title="Refresh Live Stream">
<span class="material-symbols-outlined">refresh</span>
</button>
</div>
</div>
<div class="empty-video-fallback" *ngIf="liveStreamError()">
<div class="fallback-icon-wrap">
<span class="material-symbols-outlined fallback-icon">phonelink_off</span>
</div>
<h4 class="fallback-title">Device Stream Connecting</h4>
<p class="fallback-desc">Waiting for ADB device screen stream...</p>
<button class="retry-btn" (click)="retryLiveStream()">
<span class="material-symbols-outlined">refresh</span> Reconnect
</button>
</div>
</ng-container>
<!-- 2. RECORDING PLAYBACK / STEP REPLAY (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
<ng-container *ngIf="agentService.recordingPlaybackStatus() !== 'live'">
<!-- Loading State (when in video mode and processing) -->
<div class="video-loading-state" *ngIf="agentService.playerMode() === 'video' && (agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading())">
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
<span>{{ agentService.recordingPlaybackMessage() || 'Preparing screen recording...' }}</span>
</div>
<!-- Option A: STEP REPLAY VIEW (ACTIVE IN STEP MODE) -->
<ng-container *ngIf="agentService.playerMode() === 'steps' && totalStepFrames() > 0">
<div class="step-replay-container">
<!-- Fallback info banner if recording had failed -->
<div class="step-replay-notice" *ngIf="agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()">
<span class="material-symbols-outlined notice-icon">info</span>
<span>Screen recording unavailable. Replaying step-by-step screenshots.</span>
</div>
<!-- Step Image Viewport with Action Coordinates Overlay -->
<div
class="step-image-viewport"
(mouseenter)="onViewportMouseEnter()"
(mouseleave)="onViewportMouseLeave()"
}
<!-- Minimize / Restore Toggle -->
<button
class="header-btn"
(click)="toggleMinimize()"
[title]="agentService.isVideoMinimized() ? 'Restore' : 'Minimize'"
aria-label="Minimize"
>
<!-- Loading Skeleton (Clean Light Spinner) -->
<div class="step-image-loading-state" *ngIf="isStepImageLoading()">
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
<span>Loading screenshot...</span>
</div>
<!-- Error / Missing Image Fallback -->
<div class="step-image-error-state" *ngIf="stepImageError() && !isStepImageLoading()">
<span class="material-symbols-outlined broken-icon">broken_image</span>
<span>Screenshot preview unavailable for this step</span>
</div>
<!-- Device Screen Frame & Action Coordinates Overlay -->
<div class="step-image-frame-wrap" *ngIf="!stepImageError()">
<img
#stepImgRef
class="step-frame-img"
[src]="currentStepImageUrl()"
[alt]="currentStepFrame()?.title"
(load)="onStepImageLoad()"
(error)="onStepImageError()"
(click)="openImageInNewTab()"
title="Click to view image in new tab"
/>
<!-- Action Visual Coordinates Overlay (Touch point, swipe arrow, sequence path) -->
<div
#stepOverlayRef
class="overlay-layer"
[style.display]="viewMode() === 'post' ? 'none' : 'block'"
></div>
</div>
<!-- Hideable Floating Detail Card (Appears on hover or when pinned) -->
<div
class="step-frame-badge"
*ngIf="currentStepFrame() as frame"
[class.visible]="isCardVisible()"
(click)="$event.stopPropagation()"
<span class="material-symbols-outlined">{{ agentService.isVideoMinimized() ? 'expand_less' : 'remove' }}</span>
</button>
<!-- Theater / Fullscreen Toggle -->
@if (!agentService.isVideoMinimized()) {
<button
class="header-btn"
(click)="toggleTheater()"
[title]="isTheaterMode() ? 'Exit Theater' : 'Theater Mode'"
aria-label="Theater"
>
<div class="badge-content-left">
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
<span class="badge-action-desc" [title]="frame.actionText || frame.title">
<span class="material-symbols-outlined action-type-icon" *ngIf="frame.action">
{{ getStepActionIcon(frame.action) }}
</span>
{{ frame.actionText || frame.title }}
</span>
</div>
<div class="badge-actions-right">
<!-- Pre/Post Screenshot Switcher (Only if post-screenshot is available) -->
<div class="pre-post-toggle" *ngIf="frame.postImageUrl">
<button
class="toggle-btn"
[class.active]="viewMode() === 'pre'"
(click)="setStepViewMode('pre', $event)"
title="View screen before action (with touch coordinates)"
>
Pre
</button>
<button
class="toggle-btn"
[class.active]="viewMode() === 'post'"
(click)="setStepViewMode('post', $event)"
title="View resulting screen after action executed"
>
Post
<span class="material-symbols-outlined">{{ isTheaterMode() ? 'close_fullscreen' : 'open_in_full' }}</span>
</button>
}
<!-- Close Window -->
<button
class="header-btn close-btn"
(click)="agentService.closeVideoPlayer()"
title="Close"
aria-label="Close"
>
<span class="material-symbols-outlined">close</span>
</button>
</div>
</header>
<!-- Video Main Content Area -->
@if (!agentService.isVideoMinimized()) {
<div class="video-body">
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
@if (agentService.recordingPlaybackStatus() === 'live') {
@if (!liveStreamError()) {
<div class="live-stream-container">
<img
class="live-screen-img"
[src]="liveStreamUrl()"
(error)="onLiveStreamError()"
alt="Live Device Screen"
/>
<div class="live-status-overlay">
<div class="live-badge">
<span class="pulse-dot"></span>
<span>LIVE</span>
</div>
<button class="live-refresh-btn" (click)="retryLiveStream()" title="Refresh Live Stream">
<span class="material-symbols-outlined">refresh</span>
</button>
</div>
<!-- Pin Card Button -->
<button
class="pin-card-btn"
[class.pinned]="isCardPinned()"
(click)="toggleCardPin($event)"
[title]="isCardPinned() ? 'Unpin details card (auto-hide on mouse leave)' : 'Pin details card (keep always visible)'"
>
<span class="material-symbols-outlined">{{ isCardPinned() ? 'push_pin' : 'keep' }}</span>
</div>
}
@if (liveStreamError()) {
<div class="empty-video-fallback">
<div class="fallback-icon-wrap">
<span class="material-symbols-outlined fallback-icon">phonelink_off</span>
</div>
<h4 class="fallback-title">Device Stream Connecting</h4>
<p class="fallback-desc">Waiting for ADB device screen stream...</p>
<button class="retry-btn" (click)="retryLiveStream()">
<span class="material-symbols-outlined">refresh</span> Reconnect
</button>
</div>
</div>
</div>
<!-- Step Replay Controls Footer -->
<div class="video-controls-footer">
<!-- Timeline Scrubber Bar with Step Ticks -->
<div class="scrubber-bar-container">
<input
type="range"
class="scrubber-range"
[min]="0"
[max]="totalStepFrames() - 1"
[step]="1"
[value]="activeStepIndex()"
(input)="onStepScrub($event)"
/>
<div class="scrubber-progress-fill" [style.width.%]="stepProgressPercent()"></div>
<!-- Step Tick Markers -->
<div class="step-ticks-wrapper" *ngIf="totalStepFrames() > 1">
<span
*ngFor="let f of stepFrames(); let i = index"
class="step-tick"
[class.active]="i === activeStepIndex()"
[class.passed]="i < activeStepIndex()"
[style.left.%]="(i / (totalStepFrames() - 1)) * 100"
(click)="seekStepFrame(i); $event.stopPropagation()"
[title]="'Step ' + (i + 1) + ': ' + (f.actionText || f.title)"
></span>
}
}
<!-- 2. RECORDING PLAYBACK / STEP REPLAY (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
@if (agentService.recordingPlaybackStatus() !== 'live') {
<!-- Loading State (when in video mode and processing) -->
@if (agentService.playerMode() === 'video' && (agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading())) {
<div class="video-loading-state">
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
<span>{{ agentService.recordingPlaybackMessage() || 'Preparing screen recording...' }}</span>
</div>
</div>
<!-- Bottom Controls Row -->
<div class="controls-row">
<div class="controls-left">
<!-- Play / Pause Button -->
<button class="ctrl-btn play-btn" (click)="toggleStepPlay()" [title]="isStepPlaying() ? 'Pause' : 'Play Slideshow'">
<span class="material-symbols-outlined">{{ isStepPlaying() ? 'pause' : 'play_arrow' }}</span>
</button>
<!-- Previous Frame Button -->
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Step">
<span class="material-symbols-outlined">skip_previous</span>
</button>
<!-- Next Frame Button -->
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Step">
<span class="material-symbols-outlined">skip_next</span>
</button>
<!-- Step Replay / Reset -->
<button class="ctrl-btn" (click)="restartStep()" title="Replay from start">
<span class="material-symbols-outlined">replay</span>
</button>
<!-- Step Counter Display -->
<span class="time-display">
Step {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
</span>
}
<!-- Option A: STEP REPLAY VIEW (ACTIVE IN STEP MODE) -->
@if (agentService.playerMode() === 'steps' && totalStepFrames() > 0) {
<div class="step-replay-container">
<!-- Fallback info banner if recording had failed -->
@if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) {
<div class="step-replay-notice">
<span class="material-symbols-outlined notice-icon">info</span>
<span>Screen recording unavailable. Replaying step-by-step screenshots.</span>
</div>
}
<!-- Step Image Viewport with Action Coordinates Overlay -->
<div
class="step-image-viewport"
(mouseenter)="onViewportMouseEnter()"
(mouseleave)="onViewportMouseLeave()"
>
<!-- Loading Skeleton (Clean Light Spinner) -->
@if (isStepImageLoading()) {
<div class="step-image-loading-state">
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
<span>Loading screenshot...</span>
</div>
}
<!-- Error / Missing Image Fallback -->
@if (stepImageError() && !isStepImageLoading()) {
<div class="step-image-error-state">
<span class="material-symbols-outlined broken-icon">broken_image</span>
<span>Screenshot preview unavailable for this step</span>
</div>
}
<!-- Device Screen Frame & Action Coordinates Overlay -->
@if (!stepImageError()) {
<div class="step-image-frame-wrap">
<img
#stepImgRef
class="step-frame-img"
[src]="currentStepImageUrl()"
[alt]="$safeNavigationMigration(currentStepFrame()?.title)"
(load)="onStepImageLoad()"
(error)="onStepImageError()"
(click)="openImageInNewTab()"
title="Click to view image in new tab"
/>
<!-- Action Visual Coordinates Overlay (Touch point, swipe arrow, sequence path) -->
<div
#stepOverlayRef
class="overlay-layer"
[style.display]="viewMode() === 'post' ? 'none' : 'block'"
></div>
</div>
}
<!-- Hideable Floating Detail Card (Appears on hover or when pinned) -->
@if (currentStepFrame(); as frame) {
<div
class="step-frame-badge"
[class.visible]="isCardVisible()"
(click)="$event.stopPropagation()"
>
<div class="badge-content-left">
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
<span class="badge-action-desc" [title]="frame.actionText || frame.title">
@if (frame.action) {
<span class="material-symbols-outlined action-type-icon">
{{ getStepActionIcon(frame.action) }}
</span>
}
{{ frame.actionText || frame.title }}
</span>
</div>
<div class="badge-actions-right">
<!-- Pre/Post Screenshot Switcher (Only if post-screenshot is available) -->
@if (frame.postImageUrl) {
<div class="pre-post-toggle">
<button
class="toggle-btn"
[class.active]="viewMode() === 'pre'"
(click)="setStepViewMode('pre', $event)"
title="View screen before action (with touch coordinates)"
>
Pre
</button>
<button
class="toggle-btn"
[class.active]="viewMode() === 'post'"
(click)="setStepViewMode('post', $event)"
title="View resulting screen after action executed"
>
Post
</button>
</div>
}
<!-- Pin Card Button -->
<button
class="pin-card-btn"
[class.pinned]="isCardPinned()"
(click)="toggleCardPin($event)"
[title]="isCardPinned() ? 'Unpin details card (auto-hide on mouse leave)' : 'Pin details card (keep always visible)'"
>
<span class="material-symbols-outlined">{{ isCardPinned() ? 'push_pin' : 'keep' }}</span>
</button>
</div>
</div>
}
</div>
<!-- Step Replay Controls Footer -->
<div class="video-controls-footer">
<!-- Timeline Scrubber Bar with Step Ticks -->
<div class="scrubber-bar-container">
<input
type="range"
class="scrubber-range"
[min]="0"
[max]="totalStepFrames() - 1"
[step]="1"
[value]="activeStepIndex()"
(input)="onStepScrub($event)"
/>
<div class="scrubber-progress-fill" [style.width.%]="stepProgressPercent()"></div>
<!-- Step Tick Markers -->
@if (totalStepFrames() > 1) {
<div class="step-ticks-wrapper">
@for (f of stepFrames(); track trackStepFrame(i, f); let i = $index) {
<span
class="step-tick"
[class.active]="i === activeStepIndex()"
[class.passed]="i < activeStepIndex()"
[style.left.%]="(i / (totalStepFrames() - 1)) * 100"
(click)="seekStepFrame(i); $event.stopPropagation()"
[title]="'Step ' + (i + 1) + ': ' + (f.actionText || f.title)"
></span>
}
</div>
}
</div>
<!-- Bottom Controls Row -->
<div class="controls-row">
<div class="controls-left">
<!-- Play / Pause Button -->
<button class="ctrl-btn play-btn" (click)="toggleStepPlay()" [title]="isStepPlaying() ? 'Pause' : 'Play Slideshow'">
<span class="material-symbols-outlined">{{ isStepPlaying() ? 'pause' : 'play_arrow' }}</span>
</button>
<!-- Previous Frame Button -->
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Step">
<span class="material-symbols-outlined">skip_previous</span>
</button>
<!-- Next Frame Button -->
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Step">
<span class="material-symbols-outlined">skip_next</span>
</button>
<!-- Step Replay / Reset -->
<button class="ctrl-btn" (click)="restartStep()" title="Replay from start">
<span class="material-symbols-outlined">replay</span>
</button>
<!-- Step Counter Display -->
<span class="time-display">
Step {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
</span>
</div>
<div class="controls-right">
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
<button
class="ctrl-btn speed-pill-btn"
(click)="cycleSpeed()"
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
>
<span class="speed-label">{{ playbackRate() }}x</span>
</button>
<!-- Loop Toggle -->
<button
class="ctrl-btn"
[class.active]="isLooping()"
(click)="toggleLoop()"
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
>
<span class="material-symbols-outlined">repeat</span>
</button>
<!-- Open in new tab -->
<button class="ctrl-btn" (click)="openImageInNewTab()" title="Open image in new tab">
<span class="material-symbols-outlined">open_in_new</span>
</button>
</div>
</div>
</div>
</div>
}
<!-- Option B: ACTIVE VIDEO PLAYBACK VIEW -->
@if (agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl(); as videoUrl) {
@if (!videoLoadError()) {
<div class="video-container">
<video
#videoRef
[src]="currentVideoUrl()"
playsinline
(loadedmetadata)="onLoadedMetadata()"
(timeupdate)="onTimeUpdate()"
(play)="onPlay()"
(pause)="onPause()"
(ended)="onEnded()"
(error)="onError()"
(click)="togglePlay()"
></video>
</div>
}
<!-- Dedicated Bottom Controls Bar for Video -->
@if (!videoLoadError()) {
<div class="video-controls-footer">
<!-- Timeline Scrubber Bar -->
<div class="scrubber-bar-container">
<input
type="range"
class="scrubber-range"
[min]="0"
[max]="duration() || 100"
[step]="0.1"
[value]="currentTime()"
(input)="onScrub($event)"
/>
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
</div>
<!-- Bottom Controls Row -->
<div class="controls-row">
<div class="controls-left">
<!-- Play / Pause Button -->
<button class="ctrl-btn play-btn" (click)="togglePlay()" [title]="isPlaying() ? 'Pause' : 'Play'">
<span class="material-symbols-outlined">{{ isPlaying() ? 'pause' : 'play_arrow' }}</span>
</button>
<!-- Replay / Reset -->
<button class="ctrl-btn" (click)="restart()" title="Replay from start">
<span class="material-symbols-outlined">replay</span>
</button>
<!-- Time Display -->
<span class="time-display">
{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}
</span>
</div>
<div class="controls-right">
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
<button
class="ctrl-btn speed-pill-btn"
(click)="cycleSpeed()"
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
>
<span class="speed-label">{{ playbackRate() }}x</span>
</button>
<!-- Loop Toggle -->
<button
class="ctrl-btn"
[class.active]="isLooping()"
(click)="toggleLoop()"
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
>
<span class="material-symbols-outlined">repeat</span>
</button>
<!-- Mute / Unmute Toggle -->
<button class="ctrl-btn" (click)="toggleMute()" [title]="isMuted() ? 'Unmute' : 'Mute'">
<span class="material-symbols-outlined">{{ isMuted() ? 'volume_off' : 'volume_up' }}</span>
</button>
<!-- Open in new tab -->
<button class="ctrl-btn" (click)="openInNewTab()" title="Open video in new tab">
<span class="material-symbols-outlined">open_in_new</span>
</button>
</div>
</div>
</div>
}
}
<!-- Option C: EMPTY / FALLBACK STATE (Only when no video AND no step frames) -->
@if (!agentService.isVideoLoading() && (agentService.playerMode() === 'video' || totalStepFrames() === 0) && (agentService.recordingPlaybackStatus() === 'failed' || agentService.recordingPlaybackStatus() === 'unavailable' || videoLoadError()) && totalStepFrames() === 0) {
<div class="empty-video-fallback">
<div class="fallback-icon-wrap">
<span class="material-symbols-outlined fallback-icon">videocam_off</span>
</div>
<h4 class="fallback-title">
{{ agentService.recordingPlaybackStatus() === 'failed' || videoLoadError() ? 'Recording Playback Failed' : 'No Video Recording Available' }}
</h4>
<p class="fallback-desc">
{{ videoLoadError() ? 'The finalized recording could not be played.' : (agentService.recordingPlaybackMessage() || 'No screen recording was found for this task.') }}
</p>
@if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) {
<button class="retry-btn" (click)="videoLoadError.set(false); agentService.retryVideoRecording()">
<span class="material-symbols-outlined">refresh</span> Retry
</button>
}
</div>
}
}
</div>
<div class="controls-right">
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
<button
class="ctrl-btn speed-pill-btn"
(click)="cycleSpeed()"
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
>
<span class="speed-label">{{ playbackRate() }}x</span>
</button>
<!-- Loop Toggle -->
<button
class="ctrl-btn"
[class.active]="isLooping()"
(click)="toggleLoop()"
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
>
<span class="material-symbols-outlined">repeat</span>
</button>
<!-- Open in new tab -->
<button class="ctrl-btn" (click)="openImageInNewTab()" title="Open image in new tab">
<span class="material-symbols-outlined">open_in_new</span>
</button>
</div>
</div>
}
</div>
</div>
</ng-container>
<!-- Option B: ACTIVE VIDEO PLAYBACK VIEW -->
<ng-container *ngIf="agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl() as videoUrl">
<div class="video-container" *ngIf="!videoLoadError()">
<video
#videoRef
[src]="currentVideoUrl()"
playsinline
(loadedmetadata)="onLoadedMetadata()"
(timeupdate)="onTimeUpdate()"
(play)="onPlay()"
(pause)="onPause()"
(ended)="onEnded()"
(error)="onError()"
(click)="togglePlay()"
></video>
</div>
<!-- Dedicated Bottom Controls Bar for Video -->
<div class="video-controls-footer" *ngIf="!videoLoadError()">
<!-- Timeline Scrubber Bar -->
<div class="scrubber-bar-container">
<input
type="range"
class="scrubber-range"
[min]="0"
[max]="duration() || 100"
[step]="0.1"
[value]="currentTime()"
(input)="onScrub($event)"
/>
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
</div>
<!-- Bottom Controls Row -->
<div class="controls-row">
<div class="controls-left">
<!-- Play / Pause Button -->
<button class="ctrl-btn play-btn" (click)="togglePlay()" [title]="isPlaying() ? 'Pause' : 'Play'">
<span class="material-symbols-outlined">{{ isPlaying() ? 'pause' : 'play_arrow' }}</span>
</button>
<!-- Replay / Reset -->
<button class="ctrl-btn" (click)="restart()" title="Replay from start">
<span class="material-symbols-outlined">replay</span>
</button>
<!-- Time Display -->
<span class="time-display">
{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}
</span>
</div>
<div class="controls-right">
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
<button
class="ctrl-btn speed-pill-btn"
(click)="cycleSpeed()"
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
>
<span class="speed-label">{{ playbackRate() }}x</span>
</button>
<!-- Loop Toggle -->
<button
class="ctrl-btn"
[class.active]="isLooping()"
(click)="toggleLoop()"
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
>
<span class="material-symbols-outlined">repeat</span>
</button>
<!-- Mute / Unmute Toggle -->
<button class="ctrl-btn" (click)="toggleMute()" [title]="isMuted() ? 'Unmute' : 'Mute'">
<span class="material-symbols-outlined">{{ isMuted() ? 'volume_off' : 'volume_up' }}</span>
</button>
<!-- Open in new tab -->
<button class="ctrl-btn" (click)="openInNewTab()" title="Open video in new tab">
<span class="material-symbols-outlined">open_in_new</span>
</button>
</div>
</div>
</div>
</ng-container>
<!-- Option C: EMPTY / FALLBACK STATE (Only when no video AND no step frames) -->
<div class="empty-video-fallback" *ngIf="!agentService.isVideoLoading() && (agentService.playerMode() === 'video' || totalStepFrames() === 0) && (agentService.recordingPlaybackStatus() === 'failed' || agentService.recordingPlaybackStatus() === 'unavailable' || videoLoadError()) && totalStepFrames() === 0">
<div class="fallback-icon-wrap">
<span class="material-symbols-outlined fallback-icon">videocam_off</span>
</div>
<h4 class="fallback-title">
{{ agentService.recordingPlaybackStatus() === 'failed' || videoLoadError() ? 'Recording Playback Failed' : 'No Video Recording Available' }}
</h4>
<p class="fallback-desc">
{{ videoLoadError() ? 'The finalized recording could not be played.' : (agentService.recordingPlaybackMessage() || 'No screen recording was found for this task.') }}
</p>
<button class="retry-btn" *ngIf="agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()" (click)="videoLoadError.set(false); agentService.retryVideoRecording()">
<span class="material-symbols-outlined">refresh</span> Retry
</button>
</div>
</ng-container>
</div>
</div>
}
@@ -16,6 +16,8 @@
import {
Component,
ChangeDetectionStrategy,
NgZone,
inject,
signal,
computed,
@@ -25,7 +27,7 @@ import {
effect,
OnDestroy
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AgentService } from '../../services/agent.service';
import { StepReplayFrame } from '../../core/models/stream.model';
@@ -35,12 +37,14 @@ import { getActionIcon } from '../../utils/action-formatter.util';
@Component({
selector: 'app-floating-video-player',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [FormsModule],
templateUrl: './floating-video-player.component.html',
styleUrl: './floating-video-player.component.scss'
styleUrl: './floating-video-player.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class FloatingVideoPlayerComponent implements OnDestroy {
public agentService = inject(AgentService);
private zone = inject(NgZone);
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
@ViewChild('stepImgRef') stepImgRef?: ElementRef<HTMLImageElement>;
@@ -217,7 +221,9 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
});
/**
* Start dragging the window from header
* Start dragging the window from header. The move/up listeners are attached
* only for the duration of the drag and live outside the Angular zone, so
* ordinary mouse movement over the page never touches change detection.
*/
public startDrag(event: MouseEvent): void {
if (this.isTheaterMode()) return;
@@ -227,10 +233,14 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
this.initialPosX = this.posX();
this.initialPosY = this.posY();
event.preventDefault();
this.zone.runOutsideAngular(() => {
document.addEventListener('mousemove', this.onDrag);
document.addEventListener('mouseup', this.stopDrag);
});
}
@HostListener('window:mousemove', ['$event'])
public onDrag(event: MouseEvent): void {
private onDrag = (event: MouseEvent): void => {
if (!this.isDragging) return;
const deltaX = event.clientX - this.dragStartX;
const deltaY = event.clientY - this.dragStartY;
@@ -245,12 +255,13 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
this.posX.set(targetX);
this.posY.set(targetY);
}
};
@HostListener('window:mouseup')
public stopDrag(): void {
private stopDrag = (): void => {
this.isDragging = false;
}
document.removeEventListener('mousemove', this.onDrag);
document.removeEventListener('mouseup', this.stopDrag);
};
@HostListener('window:resize')
public onResize(): void {
@@ -593,10 +604,16 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
}
}
public trackStepFrame(index: number, frame: StepReplayFrame): string {
return frame.stepId || String(index);
}
public ngOnDestroy(): void {
if (this.stepTimer) {
clearTimeout(this.stepTimer);
this.stepTimer = null;
}
document.removeEventListener('mousemove', this.onDrag);
document.removeEventListener('mouseup', this.stopDrag);
}
}
@@ -1,17 +1,17 @@
<!--
Copyright 2026 Google LLC
Copyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="agent-stream-container">
@@ -29,14 +29,15 @@
[value]="agentService.currentSessionId() || ''"
(change)="onSessionChange($event)"
class="session-dropdown"
>
<option value="" disabled selected>-- Select a Session --</option>
<option
*ngFor="let session of agentService.sessions()"
[value]="session.session_id"
>
{{ session.initial_goal || 'Unnamed Goal' }} ({{ session.device_serial ? session.device_serial + ' · ' : '' }}{{ session.start_time * 1000 | date:'short' }})
</option>
<option value="" disabled selected>-- Select a Session --</option>
@for (session of agentService.sessions(); track session) {
<option
[value]="session.session_id"
>
{{ session.initial_goal || 'Unnamed Goal' }} ({{ session.device_serial ? session.device_serial + ' · ' : '' }}{{ session.start_time * 1000 | date:'short' }})
</option>
}
</select>
<button class="btn-refresh" (click)="agentService.fetchSessions()">🔄 Refresh</button>
</div>
@@ -44,73 +45,86 @@
<!-- Event Logs Section -->
<main class="stream-logs-content">
<div *ngIf="agentService.sessionLogs().length === 0" class="empty-state">
<div class="empty-icon">📡</div>
<h3>No Stream Activity Selected</h3>
<p>Please select a running session from the dropdown above to view real-time log payloads.</p>
</div>
<div *ngIf="agentService.sessionLogs().length > 0" class="logs-list">
<div
*ngFor="let log of agentService.sessionLogs(); let i = index"
class="log-card"
[ngClass]="['type-' + log.type, isStepEvent(log.type) ? 'step-event-card' : 'raw-event-card']"
>
<div class="log-card-header">
<div class="badge-group">
<span class="index-badge">#{{ i + 1 }}</span>
<span class="type-badge" [ngClass]="log.type">{{ log.type }}</span>
</div>
<span class="timestamp">{{ log.timestamp | date:'HH:mm:ss.SSS' }}</span>
</div>
<!-- Render Step Events in a beautiful, structured layout -->
<div *ngIf="isStepEvent(log.type)" class="log-card-body step-body">
<!-- Step Summary (Message to User) -->
<div class="step-summary-section" *ngIf="log.data.summary">
<div class="section-title">💬 Message to User / Summary:</div>
<div class="summary-bubble">{{ log.data.summary }}</div>
</div>
<!-- Model thinking process (collapsible) -->
<details class="thinking-details" *ngIf="log.data.operator_native_thinking">
<summary class="details-summary">🧠 Model Thinking Process (Click to expand)</summary>
<div class="details-content thinking-text">{{ log.data.operator_native_thinking }}</div>
</details>
<!-- Raw thinking process (collapsible) -->
<details class="planning-details" *ngIf="log.data.operator_raw_thinking">
<summary class="details-summary">📝 Raw Plan / Thoughts (Click to expand)</summary>
<div class="details-content planning-text">{{ log.data.operator_raw_thinking }}</div>
</details>
<!-- Tool Call (collapsible) -->
<details class="tool-details" *ngIf="log.data.action_taken" [open]="isShortContent(log.data.action_taken)">
<summary class="details-summary">🛠️ Tool Call Details (Click to expand)</summary>
<div class="details-content">
<pre class="json-code"><code>{{ formatJson(log.data.action_taken) }}</code></pre>
</div>
</details>
<!-- Tool Call Result (collapsible) -->
<details class="result-details" *ngIf="log.data.last_execution_result">
<summary class="details-summary">✓ Execution Result (Click to expand)</summary>
<div class="details-content">
<pre class="json-code"><code>{{ formatJson(log.data.last_execution_result) }}</code></pre>
</div>
</details>
</div>
<!-- Render other event types as raw JSON (collapsible) -->
<div *ngIf="!isStepEvent(log.type)" class="log-card-body raw-body">
<details class="raw-details" [open]="isShortContent(log.data)">
<summary class="details-summary">📄 Raw Payload Data (Click to expand)</summary>
<div class="details-content">
<pre class="json-code"><code>{{ formatJson(log.data) }}</code></pre>
</div>
</details>
</div>
@if (agentService.sessionLogs().length === 0) {
<div class="empty-state">
<div class="empty-icon">📡</div>
<h3>No Stream Activity Selected</h3>
<p>Please select a running session from the dropdown above to view real-time log payloads.</p>
</div>
</div>
}
@if (agentService.sessionLogs().length > 0) {
<div class="logs-list">
@for (log of agentService.sessionLogs(); track log; let i = $index) {
<div
class="log-card"
[ngClass]="['type-' + log.type, isStepEvent(log.type) ? 'step-event-card' : 'raw-event-card']"
>
<div class="log-card-header">
<div class="badge-group">
<span class="index-badge">#{{ i + 1 }}</span>
<span class="type-badge" [ngClass]="log.type">{{ log.type }}</span>
</div>
<span class="timestamp">{{ log.timestamp | date:'HH:mm:ss.SSS' }}</span>
</div>
<!-- Render Step Events in a beautiful, structured layout -->
@if (isStepEvent(log.type)) {
<div class="log-card-body step-body">
<!-- Step Summary (Message to User) -->
@if (log.data.summary) {
<div class="step-summary-section">
<div class="section-title">💬 Message to User / Summary:</div>
<div class="summary-bubble">{{ log.data.summary }}</div>
</div>
}
<!-- Model thinking process (collapsible) -->
@if (log.data.operator_native_thinking) {
<details class="thinking-details">
<summary class="details-summary">🧠 Model Thinking Process (Click to expand)</summary>
<div class="details-content thinking-text">{{ log.data.operator_native_thinking }}</div>
</details>
}
<!-- Raw thinking process (collapsible) -->
@if (log.data.operator_raw_thinking) {
<details class="planning-details">
<summary class="details-summary">📝 Raw Plan / Thoughts (Click to expand)</summary>
<div class="details-content planning-text">{{ log.data.operator_raw_thinking }}</div>
</details>
}
<!-- Tool Call (collapsible) -->
@if (log.data.action_taken) {
<details class="tool-details" [open]="isShortContent(log.data.action_taken)">
<summary class="details-summary">🛠️ Tool Call Details (Click to expand)</summary>
<div class="details-content">
<pre class="json-code"><code>{{ formatJson(log.data.action_taken) }}</code></pre>
</div>
</details>
}
<!-- Tool Call Result (collapsible) -->
@if (log.data.last_execution_result) {
<details class="result-details">
<summary class="details-summary">✓ Execution Result (Click to expand)</summary>
<div class="details-content">
<pre class="json-code"><code>{{ formatJson(log.data.last_execution_result) }}</code></pre>
</div>
</details>
}
</div>
}
<!-- Render other event types as raw JSON (collapsible) -->
@if (!isStepEvent(log.type)) {
<div class="log-card-body raw-body">
<details class="raw-details" [open]="isShortContent(log.data)">
<summary class="details-summary">📄 Raw Payload Data (Click to expand)</summary>
<div class="details-content">
<pre class="json-code"><code>{{ formatJson(log.data) }}</code></pre>
</div>
</details>
</div>
}
</div>
}
</div>
}
</main>
</div>
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Component, inject } from '@angular/core';
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AgentService } from '../../services/agent.service';
@@ -24,6 +24,7 @@ import { AgentService } from '../../services/agent.service';
standalone: true,
imports: [CommonModule, FormsModule],
templateUrl: './legacy-agent-stream.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './legacy-agent-stream.component.scss'
})
export class LegacyAgentStreamComponent {
@@ -33,7 +33,9 @@
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.9);
user-select: none;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background: rgba(255, 255, 255, 0.85);
@@ -56,7 +58,10 @@
text-decoration: none;
cursor: pointer;
line-height: 1;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
outline: none;
white-space: nowrap;
@@ -14,14 +14,14 @@
* limitations under the License.
*/
import { Component } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { RouterLink, RouterLinkActive } from '@angular/router';
@Component({
selector: 'app-nav-switcher',
standalone: true,
imports: [CommonModule, RouterLink, RouterLinkActive],
imports: [RouterLink, RouterLinkActive],
template: `
<nav class="floating-nav-switcher" aria-label="Main Navigation">
<a
@@ -45,6 +45,7 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
</a>
</nav>
`,
changeDetection: ChangeDetectionStrategy.Eager,
styleUrls: ['./nav-switcher.component.scss']
})
export class NavSwitcherComponent {}
@@ -36,6 +36,42 @@ export interface DeviceInfo {
installed_packages?: string[];
}
export interface AdbServerEndpoint {
host: string;
port: number;
socket: string;
identity: string;
mode: 'local' | 'remote';
is_local_default: boolean;
}
export interface AdbServerDevice {
serial: string;
state: string;
model: string | null;
product: string | null;
}
export interface AdbServerStatus {
endpoint: AdbServerEndpoint;
}
export interface AdbServerConnectionResult {
success: boolean;
message: string;
endpoint: AdbServerEndpoint;
devices: AdbServerDevice[];
persisted?: boolean;
persistence_error?: string | null;
error_code?: string;
output?: string;
}
export interface AdbServerConnectionResponse {
connection_result: AdbServerConnectionResult;
report?: SystemReadinessReport;
}
export interface ProbeResult {
id: string;
category: ProbeCategory;
@@ -674,15 +674,24 @@
<div class="step-title-row">
<h3 class="step-title">Android Device & Emulator Connection</h3>
</div>
<p class="step-desc">Physical Android phone or local Android Virtual Device (AVD)</p>
<p class="step-desc">Local emulator, attached phone, wireless device, or another ADB server</p>
</div>
<button
type="button"
class="btn-restart-adb btn-connection-options"
(click)="toggleConnectionMethods()"
>
<span class="material-symbols-outlined">swap_horiz</span>
<span>{{ showConnectionMethods() ? 'Hide Options' : 'Change Connection' }}</span>
</button>
<button
type="button"
class="btn-restart-adb"
[disabled]="isRestartingAdb()"
(click)="restartAdbServer()"
title="Restart local ADB daemon"
[title]="isRemoteAdbServer() ? 'Refresh devices without stopping the remote ADB server' : 'Restart local ADB daemon'"
>
@if (isRestartingAdb()) {
<span class="material-symbols-outlined spinning">sync</span>
@@ -692,7 +701,7 @@
<span class="feedback-text">{{ adbRestartFeedback() }}</span>
} @else {
<span class="material-symbols-outlined">sync</span>
<span>Restart ADB</span>
<span>{{ isRemoteAdbServer() ? 'Refresh Devices' : 'Restart ADB' }}</span>
}
</button>
</div>
@@ -726,6 +735,12 @@
<span class="meta-dot">·</span>
<span>Android {{ activeDevice()?.android_version }}</span>
}
@if (isRemoteAdbServer()) {
<span class="meta-dot">·</span>
<span class="remote-endpoint-label">
Remote via {{ adbServerStatus()?.endpoint?.host }}:{{ adbServerStatus()?.endpoint?.port }}
</span>
}
</div>
</div>
<div class="device-hero-status">
@@ -919,7 +934,7 @@
<div class="failed-actions-bar">
@if (emulatorLaunchState()?.avd_name) {
<button type="button" class="btn-retry-launch" (click)="launchAvdEmulator(emulatorLaunchState()!.avd_name!)">
<button type="button" class="btn-retry-launch" [disabled]="isRemoteAdbServer()" (click)="launchAvdEmulator(emulatorLaunchState()!.avd_name!)">
<span class="material-symbols-outlined">refresh</span>
<span>Retry Launch ({{ emulatorLaunchState()?.avd_name }})</span>
</button>
@@ -937,7 +952,7 @@
}
<!-- STATE B-3: NO DEVICE DETECTED / READY TO CONNECT GUIDE -->
@if (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.summary !== 'Device Locked' && adbProbe()?.summary !== 'Lock State Unknown' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed') {
@if (showConnectionMethods() || (!isDeviceReady() && !isEmulatorLaunching() && adbProbe()?.summary !== 'Device Booting' && adbProbe()?.summary !== 'Device Unauthorized' && adbProbe()?.summary !== 'Device Locked' && adbProbe()?.summary !== 'Lock State Unknown' && adbProbe()?.status !== 'fail' && emulatorLaunchState()?.status !== 'failed')) {
<div class="foolproof-guide-container">
<!-- Method Selection Nav Cards -->
<div class="connection-methods-nav">
@@ -954,13 +969,14 @@
<div class="method-info">
<div class="method-title-row">
<span class="method-title">Android Emulator</span>
@if (installedAvds().length > 0) {
<span class="method-badge-pill detected">{{ installedAvds().length }} DETECTED</span>
} @else {
<span class="method-badge-pill recommended">RECOMMENDED</span>
}
</div>
<span class="method-desc">Launch local Android Virtual Device</span>
<span class="method-desc">
@if (installedAvds().length > 0) {
{{ installedAvds().length }} local virtual device{{ installedAvds().length > 1 ? 's' : '' }} available
} @else {
Launch a local Android Virtual Device
}
</span>
</div>
</div>
<div class="method-card-right">
@@ -981,7 +997,6 @@
<div class="method-info">
<div class="method-title-row">
<span class="method-title">USB Cable</span>
<span class="method-badge-pill neutral">4 STEPS</span>
</div>
<span class="method-desc">Connect physical phone via USB</span>
</div>
@@ -1004,7 +1019,6 @@
<div class="method-info">
<div class="method-title-row">
<span class="method-title">Wireless ADB</span>
<span class="method-badge-pill info">WI-FI</span>
</div>
<span class="method-desc">Connect over local Wi-Fi</span>
</div>
@@ -1013,8 +1027,52 @@
<span class="radio-indicator" [class.checked]="activeAdbGuideTab() === 'wifi'"></span>
</div>
</button>
<button
type="button"
class="method-nav-card"
[class.active]="activeAdbGuideTab() === 'remote'"
(click)="setAdbGuideTab('remote')"
>
<div class="method-card-left">
<div class="method-icon-box">
<span class="material-symbols-outlined">devices</span>
</div>
<div class="method-info">
<div class="method-title-row">
<span class="method-title">Remote Computer</span>
</div>
<span class="method-desc">Phone connected to another computer</span>
</div>
</div>
<div class="method-card-right">
<span class="radio-indicator" [class.checked]="activeAdbGuideTab() === 'remote'"></span>
</div>
</button>
</div>
@if (activeAdbGuideTab() !== 'remote' && isRemoteAdbServer()) {
<div class="active-remote-notice">
<div class="active-remote-copy">
<span class="material-symbols-outlined">hub</span>
<span>
Artemis is currently using
<strong>{{ adbServerStatus()?.endpoint?.host }}:{{ adbServerStatus()?.endpoint?.port }}</strong>.
Switch to local ADB before using a local emulator, USB cable, or Wireless ADB.
</span>
</div>
<button
type="button"
class="btn-use-local-adb"
[disabled]="isSwitchingToLocalAdb()"
(click)="switchToLocalAdbServer()"
>
<span class="material-symbols-outlined" [class.spinning]="isSwitchingToLocalAdb()">sync</span>
<span>{{ isSwitchingToLocalAdb() ? 'Switching...' : 'Use Local ADB' }}</span>
</button>
</div>
}
<!-- Method 1: Emulator -->
@if (activeAdbGuideTab() === 'emulator') {
<div class="method-details-panel">
@@ -1049,7 +1107,7 @@
type="button"
class="btn-avd-launch"
[class.launching]="launchingAvd() === avd || (isEmulatorLaunching() && emulatorLaunchState()?.avd_name === avd)"
[disabled]="isEmulatorLaunching()"
[disabled]="isEmulatorLaunching() || isRemoteAdbServer()"
(click)="launchAvdEmulator(avd)"
[title]="isEmulatorLaunching() ? 'An emulator is currently launching...' : 'Launch ' + avd"
>
@@ -1319,7 +1377,7 @@
</div>
<div class="wifi-btn-col">
<button type="button" class="btn-wifi-connect" [disabled]="isConnectingWifi() || !wifiHost().trim()"
<button type="button" class="btn-wifi-connect" [disabled]="isConnectingWifi() || isRemoteAdbServer() || !wifiHost().trim()"
(click)="connectWifiDevice()">
@if (isConnectingWifi()) {
<span class="material-symbols-outlined spinning">sync</span>
@@ -1354,6 +1412,132 @@
</div>
}
<!-- Method 4: ADB server on another computer -->
@if (activeAdbGuideTab() === 'remote') {
<div class="method-details-panel remote-adb-panel">
<div class="remote-guide-intro">
<span class="material-symbols-outlined intro-icon">hub</span>
<span>Enter the host and port of an ADB server endpoint that this computer can access, so Artemis can discover and control a phone physically connected to another computer.</span>
</div>
<div class="remote-adb-interactive-box">
<div class="remote-adb-form-row">
<div class="remote-adb-input-col host-col">
<label class="remote-adb-label" for="remote-adb-host">ADB Server Host</label>
<div class="remote-adb-input-wrap">
<span class="material-symbols-outlined wrap-icon">dns</span>
<input
id="remote-adb-host"
type="text"
class="remote-adb-input"
[ngModel]="remoteAdbHost()"
(ngModelChange)="updateRemoteAdbHost($event)"
placeholder="127.0.0.1"
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<div class="remote-adb-input-col port-col">
<label class="remote-adb-label" for="remote-adb-port">ADB Server Port</label>
<div class="remote-adb-input-wrap">
<span class="material-symbols-outlined wrap-icon">tag</span>
<input
id="remote-adb-port"
type="text"
inputmode="numeric"
class="remote-adb-input"
[ngModel]="remoteAdbPort()"
(ngModelChange)="updateRemoteAdbPort($event)"
placeholder="5038"
autocomplete="off"
spellcheck="false"
/>
</div>
</div>
<div class="remote-adb-btn-col">
<button
type="button"
class="btn-remote-adb-connect"
[disabled]="isConnectingRemoteAdb() || isActivatingRemoteAdb() || !remoteAdbHost().trim()"
(click)="connectRemoteAdbServer()"
>
@if (isConnectingRemoteAdb()) {
<span class="material-symbols-outlined spinning">sync</span>
<span>Checking...</span>
} @else {
<span class="material-symbols-outlined">bolt</span>
<span>Test Connection</span>
}
</button>
</div>
</div>
<label class="remember-remote-adb">
<input
type="checkbox"
[ngModel]="rememberRemoteAdb()"
(ngModelChange)="rememberRemoteAdb.set($event)"
/>
<span>Remember this endpoint for future Artemis launches</span>
</label>
@if (remoteAdbMessage()) {
<div class="remote-adb-status-alert success">
<span class="material-symbols-outlined">check_circle</span>
<span>{{ remoteAdbMessage() }}</span>
</div>
}
@if (remoteAdbError()) {
<div class="remote-adb-status-alert error" role="alert">
<span class="material-symbols-outlined">error</span>
<span>{{ remoteAdbError() }}</span>
</div>
}
@if (remoteAdbDevices().length > 0) {
<div class="remote-device-results">
@for (device of remoteAdbDevices(); track device.serial) {
<div class="remote-device-row">
<span class="material-symbols-outlined">phone_android</span>
<div class="remote-device-copy">
<strong>{{ device.model || device.serial }}</strong>
<span>{{ device.serial }}</span>
</div>
<span class="remote-device-state" [class.ready]="device.state === 'device'">
{{ device.state === 'device' ? 'Ready' : device.state }}
</span>
</div>
}
</div>
}
@if (remoteAdbProbeResult()?.success && !isProbedRemoteAdbActive()) {
<button
type="button"
class="btn-remote-adb-connect btn-use-tested-endpoint"
[disabled]="isActivatingRemoteAdb()"
(click)="activateRemoteAdbServer()"
>
@if (isActivatingRemoteAdb()) {
<span class="material-symbols-outlined spinning">sync</span>
<span>Connecting...</span>
} @else {
<span class="material-symbols-outlined">check</span>
<span>{{ remoteAdbHasReadyDevice() ? 'Use Connection' : 'Use Anyway' }}</span>
}
</button>
}
</div>
<p class="remote-adb-explanation">
Artemis sends all ADB commands to this server instead of the default local server. Any phone visible to that server becomes available to Artemis.
</p>
</div>
}
</div>
}
@@ -1416,7 +1600,7 @@
</div>
<button type="button" class="btn-retry-auth" (click)="restartAdbServer()">
<span class="material-symbols-outlined">refresh</span>
<span>Restart ADB & Check</span>
<span>{{ isRemoteAdbServer() ? 'Refresh Devices' : 'Restart ADB & Check' }}</span>
</button>
</div>
</div>
@@ -110,12 +110,16 @@ $text-light: #94a3b8;
@mixin glass-primary-btn {
background: linear-gradient(135deg, rgba(26, 115, 232, 0.92) 0%, rgba(37, 99, 235, 0.85) 100%);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
backdrop-filter: blur(10px) saturate(180%);
-webkit-backdrop-filter: blur(10px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.28);
box-shadow: 0 4px 14px rgba(26, 115, 232, 0.32), inset 0 1px 1px rgba(255, 255, 255, 0.45);
color: #ffffff !important;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
span, .material-symbols-outlined, .run-icon, .action-icon {
color: #ffffff !important;
@@ -146,12 +150,17 @@ $text-light: #94a3b8;
@mixin glass-secondary-btn {
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(14px) saturate(180%);
-webkit-backdrop-filter: blur(14px) saturate(180%);
backdrop-filter: blur(10px) saturate(180%);
-webkit-backdrop-filter: blur(10px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.88);
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.05), inset 0 1px 1px rgba(255, 255, 255, 0.95);
color: $text-secondary;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1),
opacity 0.2s cubic-bezier(0.4, 0, 0.2, 1);
&:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.92);
@@ -213,10 +222,13 @@ $text-light: #94a3b8;
opacity: 0.9;
transition: opacity 0.8s ease;
/* The glow orbs are pre-softened radial gradients. A `filter: blur(90px+)`
on top of them looked nearly identical but forced the GPU to re-blur
half-megapixel layers on every frame of the float animations — the
gradients below bake the same softness in for free. */
.glow {
position: absolute;
border-radius: 50%;
filter: blur(90px);
transform: translate3d(0, 0, 0);
will-change: transform, opacity;
pointer-events: none;
@@ -227,7 +239,7 @@ $text-light: #94a3b8;
left: 10%;
width: 520px;
height: 520px;
background: radial-gradient(circle, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.04) 55%, transparent 75%);
background: radial-gradient(circle, rgba(59, 130, 246, 0.12) 0%, rgba(99, 102, 241, 0.04) 45%, transparent 82%);
animation: floatGlow1 22s ease-in-out infinite alternate;
}
@@ -236,7 +248,7 @@ $text-light: #94a3b8;
right: 12%;
width: 560px;
height: 560px;
background: radial-gradient(circle, rgba(147, 51, 234, 0.08) 0%, rgba(219, 39, 119, 0.03) 55%, transparent 75%);
background: radial-gradient(circle, rgba(147, 51, 234, 0.08) 0%, rgba(219, 39, 119, 0.03) 45%, transparent 82%);
animation: floatGlow2 26s ease-in-out infinite alternate;
}
@@ -245,7 +257,7 @@ $text-light: #94a3b8;
left: 30%;
width: 460px;
height: 460px;
background: radial-gradient(circle, rgba(6, 182, 212, 0.08) 0%, rgba(59, 130, 246, 0.03) 55%, transparent 75%);
background: radial-gradient(circle, rgba(6, 182, 212, 0.08) 0%, rgba(59, 130, 246, 0.03) 45%, transparent 82%);
animation: floatGlow3 20s ease-in-out infinite alternate;
}
@@ -256,9 +268,8 @@ $text-light: #94a3b8;
margin: 0 auto;
width: 720px;
height: 520px;
background: radial-gradient(circle, rgba(245, 158, 11, 0.09) 0%, rgba(217, 119, 6, 0.035) 45%, transparent 75%);
background: radial-gradient(circle, rgba(245, 158, 11, 0.09) 0%, rgba(217, 119, 6, 0.035) 38%, transparent 82%);
opacity: 0;
filter: blur(100px);
transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1);
animation: readyPulseGlow 9s ease-in-out infinite alternate;
pointer-events: none;
@@ -271,9 +282,8 @@ $text-light: #94a3b8;
margin: 0 auto;
width: 700px;
height: 520px;
background: radial-gradient(circle, rgba(16, 185, 129, 0.14) 0%, rgba(6, 182, 212, 0.08) 45%, transparent 75%);
background: radial-gradient(circle, rgba(16, 185, 129, 0.14) 0%, rgba(6, 182, 212, 0.08) 38%, transparent 82%);
opacity: 0;
filter: blur(100px);
transition: opacity 1s cubic-bezier(0.4, 0, 0.2, 1);
animation: readyPulseGlow 7s ease-in-out infinite alternate;
pointer-events: none;
@@ -286,9 +296,8 @@ $text-light: #94a3b8;
margin: 0 auto;
width: 680px;
height: 520px;
background: radial-gradient(circle, rgba(99, 102, 241, 0.18) 0%, rgba(147, 51, 234, 0.08) 45%, transparent 75%);
background: radial-gradient(circle, rgba(99, 102, 241, 0.18) 0%, rgba(147, 51, 234, 0.08) 38%, transparent 82%);
opacity: 0;
filter: blur(95px);
transition: opacity 0.8s cubic-bezier(0.4, 0, 0.2, 1);
pointer-events: none;
}
@@ -383,8 +392,8 @@ $text-light: #94a3b8;
max-width: 520px;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(16px) saturate(180%);
-webkit-backdrop-filter: blur(16px) saturate(180%);
backdrop-filter: blur(12px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
padding: 3px;
border-radius: 9999px;
border: 1px solid rgba(226, 232, 240, 0.85);
@@ -392,7 +401,9 @@ $text-light: #94a3b8;
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.9);
user-select: none;
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background: rgba(255, 255, 255, 0.85);
@@ -1717,6 +1728,12 @@ $text-light: #94a3b8;
.meta-dot {
color: $text-light;
}
.remote-endpoint-label {
color: $primary;
font-family: $font-mono;
font-size: 11px;
}
}
}
@@ -1739,6 +1756,7 @@ $text-light: #94a3b8;
animation: statusDotPulse 2s infinite cubic-bezier(0.4, 0, 0.6, 1);
}
}
}
.multi-device-switcher {
@@ -2365,10 +2383,14 @@ $text-light: #94a3b8;
.connection-methods-nav {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
@media (max-width: 860px) {
@media (max-width: 1100px) {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@media (max-width: 620px) {
grid-template-columns: 1fr;
}
@@ -2421,37 +2443,12 @@ $text-light: #94a3b8;
.method-title-row {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
.method-title {
font-size: 13px;
font-weight: 600;
color: $text-main;
}
.method-badge-pill {
font-size: 9.5px;
font-weight: 700;
padding: 1px 5px;
border-radius: 4px;
text-transform: uppercase;
&.detected, &.info {
background: $primary-light;
color: $primary;
}
&.recommended {
background: $emerald-light;
color: $emerald;
}
&.neutral {
background: #f1f5f9;
color: $text-muted;
}
}
}
.method-desc {
@@ -2511,6 +2508,68 @@ $text-light: #94a3b8;
}
}
.active-remote-notice {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
background: #fffbeb;
border: 1px solid $amber-border;
border-radius: 8px;
.active-remote-copy {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
color: #78350f;
font-size: 11.5px;
line-height: 1.45;
.material-symbols-outlined {
color: $amber;
font-size: 17px;
flex-shrink: 0;
}
strong {
font-family: $font-mono;
font-weight: 600;
}
}
.btn-use-local-adb {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
min-height: 32px;
padding: 0 12px;
@include glass-secondary-btn;
border-radius: 6px;
font-family: inherit;
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
.material-symbols-outlined {
font-size: 15px;
}
}
@media (max-width: 700px) {
align-items: stretch;
flex-direction: column;
.btn-use-local-adb {
width: 100%;
}
}
}
.method-details-panel {
background: #ffffff;
border: 1px solid $card-border;
@@ -2852,7 +2911,8 @@ $text-light: #94a3b8;
}
.usb-guide-intro,
.wifi-guide-intro {
.wifi-guide-intro,
.remote-guide-intro {
display: flex;
align-items: center;
gap: 8px;
@@ -3086,6 +3146,258 @@ $text-light: #94a3b8;
margin-top: 1px;
}
}
&.remote-adb-panel {
.remote-adb-interactive-box {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px;
background: #f8fafc;
border: 1px solid $card-border;
border-radius: 8px;
.remote-adb-form-row {
display: flex;
align-items: flex-end;
gap: 8px;
flex-wrap: wrap;
.remote-adb-input-col {
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
&.host-col {
flex: 1 1 220px;
}
&.port-col {
flex: 0 1 120px;
}
.remote-adb-label {
color: $text-muted;
font-size: 11px;
font-weight: 600;
}
.remote-adb-input-wrap {
display: flex;
align-items: center;
height: 36px;
padding: 0 9px;
background: #ffffff;
border: 1px solid $card-border;
border-radius: 6px;
transition: border-color 0.15s ease, box-shadow 0.15s ease;
&:focus-within {
border-color: $primary;
box-shadow: 0 0 0 2px rgba(26, 115, 232, 0.1);
}
.wrap-icon {
margin-right: 6px;
color: $text-light;
font-size: 15px;
flex-shrink: 0;
}
.remote-adb-input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
color: $text-main;
font-family: $font-mono;
font-size: 12px;
&::placeholder {
color: $text-light;
}
}
}
}
.remote-adb-btn-col {
flex-shrink: 0;
.btn-remote-adb-connect {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 5px;
height: 36px;
padding: 0 16px;
@include glass-primary-btn;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
.material-symbols-outlined {
font-size: 16px;
}
}
}
@media (max-width: 640px) {
align-items: stretch;
flex-direction: column;
.remote-adb-input-col,
.remote-adb-input-col.host-col,
.remote-adb-input-col.port-col,
.remote-adb-btn-col {
flex: 1 1 auto;
width: 100%;
}
.remote-adb-btn-col .btn-remote-adb-connect {
width: 100%;
}
}
}
.remember-remote-adb {
display: inline-flex;
align-items: center;
align-self: flex-start;
gap: 7px;
color: $text-secondary;
font-size: 11.5px;
cursor: pointer;
input {
width: 15px;
height: 15px;
margin: 0;
accent-color: $primary;
}
}
.btn-use-tested-endpoint {
display: inline-flex;
align-items: center;
justify-content: center;
align-self: flex-end;
gap: 5px;
min-width: 150px;
height: 36px;
padding: 0 16px;
@include glass-primary-btn;
border-radius: 6px;
font-family: inherit;
font-size: 12px;
font-weight: 600;
cursor: pointer;
.material-symbols-outlined {
font-size: 16px;
}
}
.remote-adb-status-alert {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 7px 10px;
border-radius: 6px;
font-size: 11.5px;
font-weight: 600;
line-height: 1.4;
.material-symbols-outlined {
font-size: 15px;
flex-shrink: 0;
}
&.success {
color: $emerald-dark;
background: $emerald-light;
border: 1px solid $emerald-border;
}
&.error {
color: $rose-dark;
background: $rose-light;
border: 1px solid $rose-border;
}
}
.remote-device-results {
display: flex;
flex-direction: column;
overflow: hidden;
background: #ffffff;
border: 1px solid $card-border;
border-radius: 7px;
.remote-device-row {
display: flex;
align-items: center;
gap: 9px;
min-width: 0;
padding: 8px 10px;
border-bottom: 1px solid #f1f5f9;
&:last-child {
border-bottom: 0;
}
> .material-symbols-outlined {
color: $primary;
font-size: 17px;
}
.remote-device-copy {
display: flex;
flex: 1;
flex-direction: column;
min-width: 0;
strong {
color: $text-main;
font-size: 12px;
font-weight: 600;
}
span {
overflow: hidden;
color: $text-muted;
font-family: $font-mono;
font-size: 10.5px;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.remote-device-state {
color: $amber-dark;
font-size: 10.5px;
font-weight: 600;
text-transform: capitalize;
&.ready {
color: $emerald-dark;
}
}
}
}
}
.remote-adb-explanation {
margin: 0;
color: $text-muted;
font-size: 11.5px;
line-height: 1.5;
}
}
}
}
@@ -3496,13 +3808,15 @@ $text-light: #94a3b8;
/* Unified Sculptured Composer (Linear / Claude / ChatGPT Grade) */
.composer-card {
background: rgba(255, 255, 255, 0.88);
backdrop-filter: blur(28px) saturate(190%);
-webkit-backdrop-filter: blur(28px) saturate(190%);
backdrop-filter: blur(16px) saturate(190%);
-webkit-backdrop-filter: blur(16px) saturate(190%);
border: 1px solid rgba(255, 255, 255, 0.95);
box-shadow: 0 20px 50px -12px rgba(15, 23, 42, 0.08), 0 1px 3px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.95);
border-radius: 20px;
padding: 18px 20px 14px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
transition: border-color 0.3s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.3s cubic-bezier(0.4, 0, 0.2, 1),
background 0.3s cubic-bezier(0.4, 0, 0.2, 1);
position: relative;
&.is-pro-active {
@@ -4125,12 +4439,15 @@ $text-light: #94a3b8;
padding: 14px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.72);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
backdrop-filter: blur(10px) saturate(180%);
-webkit-backdrop-filter: blur(10px) saturate(180%);
border: 1px solid rgba(226, 232, 240, 0.85);
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.04), inset 0 1px 1px rgba(255, 255, 255, 0.95);
cursor: pointer;
transition: all 0.22s cubic-bezier(0.4, 0, 0.2, 1);
transition: background 0.22s cubic-bezier(0.4, 0, 0.2, 1),
transform 0.22s cubic-bezier(0.4, 0, 0.2, 1),
box-shadow 0.22s cubic-bezier(0.4, 0, 0.2, 1),
border-color 0.22s cubic-bezier(0.4, 0, 0.2, 1);
user-select: none;
position: relative;
overflow: hidden;
@@ -4367,18 +4684,17 @@ $text-light: #94a3b8;
}
}
/* Transform-only: animating opacity here overrode the .glow-pending/.glow-ready
fade transitions and kept both state glows permanently visible. */
@keyframes readyPulseGlow {
0% {
transform: scale(0.97);
opacity: 0.7;
}
50% {
transform: scale(1.06);
opacity: 1;
}
100% {
transform: scale(0.98);
opacity: 0.75;
}
}
@@ -4411,3 +4727,13 @@ $text-light: #94a3b8;
opacity: 0.8;
}
}
/* Respect the user's motion preference: freeze the purely decorative ambient
animations (aurora drift, pulses, sparkles). Functional spinners keep going. */
@media (prefers-reduced-motion: reduce) {
.home-container .aurora-background .glow,
.sparkle-icon,
.status-dot {
animation: none !important;
}
}
@@ -14,13 +14,18 @@
* limitations under the License.
*/
import { Component, signal, computed, effect, inject, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, signal, computed, effect, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { Router, RouterLink } from '@angular/router';
import { AgentService } from '../../services/agent.service';
import { SystemService } from '../../services/system.service';
import { DeviceInfo, ProbeResult } from '../../core/models/system.model';
import {
AdbServerConnectionResult,
AdbServerDevice,
DeviceInfo,
ProbeResult
} from '../../core/models/system.model';
import {
AppReference,
SmartSuggestion,
@@ -30,12 +35,15 @@ import { TaskRecommendationService } from '../../core/services/task-recommendati
export type { AppReference, SmartSuggestion, SuggestionCategory };
type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote';
@Component({
selector: 'app-home',
standalone: true,
imports: [CommonModule, FormsModule],
imports: [FormsModule],
templateUrl: './home.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './home.component.scss'
})
export class HomeComponent implements OnInit, OnDestroy {
@@ -47,8 +55,8 @@ export class HomeComponent implements OnInit, OnDestroy {
// High-level navigation mode: 'diagnostics' (System Setup Guide) vs 'launcher' (Task Execution)
public activeTab = signal<'diagnostics' | 'launcher'>('launcher');
// Interactive guide sub-tab inside ADB section: 'emulator' | 'usb' | 'wifi'
public activeAdbGuideTab = signal<'emulator' | 'usb' | 'wifi'>('emulator');
// Interactive guide sub-tab inside the ADB section
public activeAdbGuideTab = signal<AdbGuideTab>('emulator');
public emulatorSetupMode = signal<'studio' | 'cli'>('studio');
// Interactive guide tab for LLM / OCR credentials: 'gemini' | 'ocr'
@@ -90,6 +98,29 @@ export class HomeComponent implements OnInit, OnDestroy {
public wifiConnectMessage = signal<string | null>(null);
public wifiConnectError = signal<string | null>(null);
public adbRestartFeedback = signal<string | null>(null);
public showConnectionMethods = signal<boolean>(false);
// ADB server endpoint connection state
public remoteAdbHost = signal<string>('127.0.0.1');
public remoteAdbPort = signal<string>('5038');
public rememberRemoteAdb = signal<boolean>(true);
public isConnectingRemoteAdb = signal<boolean>(false);
public isActivatingRemoteAdb = signal<boolean>(false);
public isSwitchingToLocalAdb = signal<boolean>(false);
public remoteAdbMessage = signal<string | null>(null);
public remoteAdbError = signal<string | null>(null);
public remoteAdbDevices = signal<AdbServerDevice[]>([]);
public remoteAdbProbeResult = signal<AdbServerConnectionResult | null>(null);
public adbServerStatus = computed(() => this.systemService.adbServerStatus());
public isRemoteAdbServer = computed(() => this.systemService.isRemoteAdbServer());
public remoteAdbHasReadyDevice = computed(() =>
this.remoteAdbDevices().some(device => device.state === 'device')
);
public isProbedRemoteAdbActive = computed(() => {
const tested = this.remoteAdbProbeResult()?.endpoint;
const active = this.adbServerStatus()?.endpoint;
return !!tested && !!active && tested.identity === active.identity;
});
public wifiCommand = computed(() => {
const h = this.wifiHost().trim() || '<phone-ip>';
@@ -334,6 +365,16 @@ export class HomeComponent implements OnInit, OnDestroy {
// Initial fetch of system readiness & model configuration
this.systemService.fetchReadiness().subscribe();
this.systemService.fetchModelConfigEnv().subscribe();
this.systemService.fetchAdbServerStatus().subscribe({
next: status => {
if (status.endpoint.mode === 'remote') {
this.remoteAdbHost.set(status.endpoint.host);
this.remoteAdbPort.set(String(status.endpoint.port));
this.activeAdbGuideTab.set('remote');
}
},
error: () => {}
});
window.addEventListener('focus', this.focusListener);
}
@@ -354,8 +395,10 @@ export class HomeComponent implements OnInit, OnDestroy {
this.selectedOs.set(os);
}
public setAdbGuideTab(tab: 'emulator' | 'usb' | 'wifi'): void {
public setAdbGuideTab(tab: AdbGuideTab): void {
this.activeAdbGuideTab.set(tab);
this.remoteAdbError.set(null);
this.remoteAdbMessage.set(null);
}
public setEmulatorSetupMode(mode: 'studio' | 'cli'): void {
@@ -609,7 +652,9 @@ export class HomeComponent implements OnInit, OnDestroy {
this.adbRestartFeedback.set(null);
this.systemService.restartAdb().subscribe({
next: (res) => {
this.adbRestartFeedback.set('ADB Refreshed ✓');
this.adbRestartFeedback.set(
res?.restart_result?.skipped ? 'Devices Refreshed ✓' : 'ADB Refreshed ✓'
);
setTimeout(() => this.adbRestartFeedback.set(null), 2500);
},
error: () => {
@@ -620,6 +665,10 @@ export class HomeComponent implements OnInit, OnDestroy {
}
public connectWifiDevice(): void {
if (this.isRemoteAdbServer()) {
this.wifiConnectError.set('Switch to local ADB before connecting a Wireless ADB device.');
return;
}
const host = this.wifiHost().trim();
const portStr = this.wifiPort().trim() || '5555';
const port = parseInt(portStr, 10) || 5555;
@@ -651,7 +700,123 @@ export class HomeComponent implements OnInit, OnDestroy {
});
}
public connectRemoteAdbServer(): void {
const host = this.remoteAdbHost().trim();
const port = Number(this.remoteAdbPort().trim());
if (!host) {
this.remoteAdbError.set('Enter the host name or IP address of the ADB server.');
return;
}
if (!Number.isInteger(port) || port < 1 || port > 65535) {
this.remoteAdbError.set('Enter a port between 1 and 65535.');
return;
}
this.isConnectingRemoteAdb.set(true);
this.remoteAdbError.set(null);
this.remoteAdbMessage.set(null);
this.remoteAdbDevices.set([]);
this.remoteAdbProbeResult.set(null);
this.systemService.probeAdbServer(host, port).subscribe({
next: response => {
this.isConnectingRemoteAdb.set(false);
const result = response.connection_result;
if (result.success) {
this.remoteAdbProbeResult.set(result);
this.remoteAdbDevices.set(result.devices || []);
this.remoteAdbMessage.set(result.message);
} else {
this.remoteAdbError.set(result.message);
}
},
error: error => {
this.isConnectingRemoteAdb.set(false);
this.remoteAdbError.set(
error?.error?.detail || 'Unable to test the ADB server endpoint.'
);
}
});
}
public activateRemoteAdbServer(): void {
const tested = this.remoteAdbProbeResult();
if (!tested?.success) {
this.remoteAdbError.set('Test the endpoint before using it.');
return;
}
this.isActivatingRemoteAdb.set(true);
this.remoteAdbError.set(null);
this.systemService.connectAdbServer(
tested.endpoint.host,
tested.endpoint.port,
this.rememberRemoteAdb()
).subscribe({
next: response => {
this.isActivatingRemoteAdb.set(false);
const result = response.connection_result;
if (result.success) {
this.remoteAdbProbeResult.set(result);
this.remoteAdbDevices.set(result.devices || []);
this.remoteAdbMessage.set(result.message);
} else {
this.remoteAdbError.set(result.message);
}
},
error: error => {
this.isActivatingRemoteAdb.set(false);
this.remoteAdbError.set(
error?.error?.detail || 'Unable to use the ADB server endpoint.'
);
}
});
}
public updateRemoteAdbHost(value: string): void {
this.remoteAdbHost.set(value);
this.clearRemoteAdbProbe();
}
public updateRemoteAdbPort(value: string): void {
this.remoteAdbPort.set(value);
this.clearRemoteAdbProbe();
}
private clearRemoteAdbProbe(): void {
this.remoteAdbProbeResult.set(null);
this.remoteAdbDevices.set([]);
this.remoteAdbMessage.set(null);
this.remoteAdbError.set(null);
}
public switchToLocalAdbServer(): void {
this.isSwitchingToLocalAdb.set(true);
this.remoteAdbError.set(null);
this.systemService.useLocalAdbServer(true).subscribe({
next: response => {
this.isSwitchingToLocalAdb.set(false);
this.remoteAdbDevices.set([]);
this.remoteAdbMessage.set(response.connection_result.message);
},
error: error => {
this.isSwitchingToLocalAdb.set(false);
this.remoteAdbError.set(
error?.error?.detail || 'Unable to switch back to the local ADB server.'
);
}
});
}
public toggleConnectionMethods(): void {
this.showConnectionMethods.update(value => !value);
}
public launchAvdEmulator(avdName: string): void {
if (this.isRemoteAdbServer()) {
return;
}
this.systemService.launchEmulator(avdName).subscribe();
}
@@ -14,16 +14,17 @@
* limitations under the License.
*/
import { Component, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, HostListener, ChangeDetectionStrategy } from '@angular/core';
import { LegacyAgentStreamComponent } from '../../components/legacy-agent-stream/legacy-agent-stream.component';
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
@Component({
selector: 'app-legacy-workspace',
standalone: true,
imports: [CommonModule, LegacyAgentStreamComponent, ChatInterfaceComponent],
imports: [LegacyAgentStreamComponent, ChatInterfaceComponent],
templateUrl: './legacy-workspace.component.html',
changeDetection: ChangeDetectionStrategy.Eager,
styleUrl: './legacy-workspace.component.scss'
})
export class LegacyWorkspaceComponent {
@@ -1,20 +1,20 @@
<!--
Copyright 2026 Google LLC
Copyright 2026 Google LLC
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<div class="workspace-container" [class.is-resizing]="isDragging">
<div class="workspace-container" [class.is-resizing]="isDragging()">
<!-- Left Main Panel: Raw Agent Log Stream -->
<main class="left-panel glass-panel">
<app-agent-stream></app-agent-stream>
@@ -23,13 +23,15 @@
<div class="workspace-floating-bar-wrapper" [class.is-expanded]="isBarExpanded()" [class.is-dormant]="!isBarExpanded()"
[class.is-focused]="isInputFocused()" [class.has-text]="!!taskInput.trim()" [class.is-running]="isTaskRunning()">
<!-- Floating Error / Notification Toast -->
<div class="floating-dock-toast error" *ngIf="errorMessage">
<span class="material-symbols-outlined toast-icon">error</span>
<span class="toast-text">{{ errorMessage }}</span>
<button type="button" class="btn-toast-close" (click)="errorMessage = null" aria-label="Close error message">
<span class="material-symbols-outlined">close</span>
</button>
</div>
@if (errorMessage()) {
<div class="floating-dock-toast error">
<span class="material-symbols-outlined toast-icon">error</span>
<span class="toast-text">{{ errorMessage() }}</span>
<button type="button" class="btn-toast-close" (click)="errorMessage.set(null)" aria-label="Close error message">
<span class="material-symbols-outlined">close</span>
</button>
</div>
}
<!-- Dock Row (Contains the Main Card and the Separate Stop Button when running) -->
<div class="floating-dock-row">
@@ -52,9 +54,9 @@
</linearGradient>
</defs>
<path d="M 0,25 Q 300,55 600,20 T 1200,25 Q 1500,55 1800,20 T 2400,25 L 2400,80 L 0,80 Z"
fill="url(#waveGradBack)"></path>
fill="url(#waveGradBack)"></path>
</svg>
<!-- Mid Fluid Wave -->
<svg class="liquid-wave wave-mid" viewBox="0 0 2400 80" preserveAspectRatio="none">
<defs>
@@ -66,9 +68,9 @@
</linearGradient>
</defs>
<path d="M 0,35 Q 300,5 600,45 T 1200,35 Q 1500,5 1800,45 T 2400,35 L 2400,80 L 0,80 Z"
fill="url(#waveGradMid)"></path>
fill="url(#waveGradMid)"></path>
</svg>
<!-- Front Fluid Wave -->
<svg class="liquid-wave wave-front" viewBox="0 0 2400 80" preserveAspectRatio="none">
<defs>
@@ -80,16 +82,16 @@
</linearGradient>
</defs>
<path d="M 0,18 Q 300,50 600,15 T 1200,18 Q 1500,50 1800,15 T 2400,18 L 2400,80 L 0,80 Z"
fill="url(#waveGradFront)"></path>
fill="url(#waveGradFront)"></path>
</svg>
</div>
</div>
<!-- Dormant / Capsule Peek View (Visible when mouse is away and input idle) -->
<div class="capsule-peek-view">
<span class="capsule-placeholder">Start a new task...</span>
</div>
<!-- Expanded Full View Content (Visible on hover, focus, or text) -->
<div class="expanded-card-content">
<div class="card-main-input-row">
@@ -101,7 +103,7 @@
(focus)="onInputFocus()"
(blur)="onInputBlur()"
rows="1"
[disabled]="isSubmitting"
[disabled]="isSubmitting()"
[placeholder]="selectedProfile() === 'pro'
? 'Pro: Multi-agent cognitive graph architecture for complex multi-step tasks'
: 'Flash: Reactive fast loop for standard app workflows within 25 steps'"
@@ -111,30 +113,38 @@
</div>
<!-- Clear Button (visible when text is entered) -->
<button *ngIf="taskInput.trim() && !isSubmitting" type="button" class="dock-circle-btn btn-clear"
(click)="clearInput($event)" title="Clear input text" aria-label="Clear input text">
<span class="material-symbols-outlined">close</span>
</button>
@if (taskInput.trim() && !isSubmitting()) {
<button type="button" class="dock-circle-btn btn-clear"
(click)="clearInput($event)" title="Clear input text" aria-label="Clear input text">
<span class="material-symbols-outlined">close</span>
</button>
}
<!-- Stop Button (Visible when currently viewed task is active) -->
<button *ngIf="isTaskRunning()" type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting"
(click)="stopTask($event)"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
@if (isTaskRunning()) {
<button type="button" class="dock-circle-btn btn-stop" [disabled]="isSubmitting()"
(click)="stopTask($event)"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
}
<!-- Send Button -->
<button type="button" class="dock-circle-btn btn-send"
[class.has-content]="!!taskInput.trim()"
[disabled]="!taskInput.trim() || isSubmitting"
[disabled]="!taskInput.trim() || isSubmitting()"
(click)="submitTask()"
[title]="isSubmitting ? 'Starting task...' : 'Start task (Enter)'"
[title]="isSubmitting() ? 'Starting task...' : 'Start task (Enter)'"
aria-label="Start task"
>
<span class="material-symbols-outlined spinning-icon" *ngIf="isSubmitting">sync</span>
<span class="material-symbols-outlined" *ngIf="!isSubmitting">arrow_upward</span>
>
@if (isSubmitting()) {
<span class="material-symbols-outlined spinning-icon">sync</span>
}
@if (!isSubmitting()) {
<span class="material-symbols-outlined">arrow_upward</span>
}
</button>
</div>
@@ -162,28 +172,30 @@
</div>
<!-- Separate Standalone Stop Button Beside the Capsule (Visible when currently viewed task is active) -->
<button *ngIf="isTaskRunning()" type="button" class="standalone-stop-btn"
[disabled]="isSubmitting"
(click)="stopTask($event)"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
@if (isTaskRunning()) {
<button type="button" class="standalone-stop-btn"
[disabled]="isSubmitting()"
(click)="stopTask($event)"
[title]="stopButtonTitle()"
[attr.aria-label]="stopButtonTitle()"
>
<span class="material-symbols-outlined">stop</span>
</button>
}
</div>
</div>
</main>
<!-- Split Pane Drag Resizer -->
<div class="resizer" (mousedown)="onDragStart($event)">
<div class="resizer-handle"></div>
</div>
<!-- Right Sidebar Panel: Chat Interface Mock -->
<aside class="right-panel" [style.width.px]="rightPanelWidth">
<app-chat-interface></app-chat-interface>
</aside>
<!-- Floating Video Replay Player Modal / Window -->
<app-floating-video-player></app-floating-video-player>
<!-- Split Pane Drag Resizer -->
<div class="resizer" (mousedown)="onDragStart($event)">
<div class="resizer-handle"></div>
</div>
<!-- Right Sidebar Panel: Chat Interface Mock -->
<aside class="right-panel" [style.width.px]="rightPanelWidth()">
<app-chat-interface></app-chat-interface>
</aside>
<!-- Floating Video Replay Player Modal / Window -->
<app-floating-video-player></app-floating-video-player>
</div>
@@ -104,7 +104,7 @@ $rose-dark: #dc2626;
height: 40px;
background: rgba(0, 0, 0, 0.15);
border-radius: 4px;
transition: all 0.2s ease;
transition: background 0.2s ease, height 0.2s ease;
}
}
@@ -308,7 +308,11 @@ $rose-dark: #dc2626;
background: radial-gradient(ellipse at center, rgba(168, 85, 247, 0.45) 0%, rgba(236, 72, 153, 0.25) 50%, transparent 80%);
}
/* Wave Tracks Wrapper with Soft Gaussian Blur */
/* Wave Tracks Wrapper. The soft-liquid look comes from blurring each
wave individually: blurring the WRAPPER forced the GPU to re-run the
gaussian pass every animation frame (child transforms invalidate the
parent's filtered layer), while per-wave blur over transform-only
animations is rasterized once and then just composited. */
.wave-tracks-wrapper {
position: absolute;
bottom: 0;
@@ -316,7 +320,6 @@ $rose-dark: #dc2626;
width: 100%;
height: 22px;
overflow: hidden;
filter: blur(7px); /* Soften sharp vector wave edges into silky liquid light */
mask-image: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 60%, transparent 100%);
-webkit-mask-image: linear-gradient(to top, rgba(0,0,0,0.9) 0%, rgba(0,0,0,0.5) 60%, transparent 100%);
@@ -327,7 +330,7 @@ $rose-dark: #dc2626;
width: 200%;
height: 100%;
will-change: transform;
filter: blur(3px); /* Secondary soft layer blur */
filter: blur(8px); /* Silky liquid softness, applied per cached wave layer */
&.wave-back {
opacity: 0.45;
@@ -562,7 +565,11 @@ $rose-dark: #dc2626;
cursor: pointer;
outline: none;
flex-shrink: 0;
transition: all 0.18s cubic-bezier(0.16, 1, 0.3, 1);
transition: background 0.18s cubic-bezier(0.16, 1, 0.3, 1),
color 0.18s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.18s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.18s cubic-bezier(0.16, 1, 0.3, 1),
border-color 0.18s cubic-bezier(0.16, 1, 0.3, 1);
.material-symbols-outlined {
font-size: 18px;
@@ -695,7 +702,9 @@ $rose-dark: #dc2626;
font-size: 11px;
font-weight: 550;
cursor: pointer;
transition: all 0.16s cubic-bezier(0.16, 1, 0.3, 1);
transition: color 0.16s cubic-bezier(0.16, 1, 0.3, 1),
background 0.16s cubic-bezier(0.16, 1, 0.3, 1),
box-shadow 0.16s cubic-bezier(0.16, 1, 0.3, 1);
outline: none;
user-select: none;
box-sizing: border-box;
@@ -863,3 +872,14 @@ $rose-dark: #dc2626;
transform: scaleX(1.08);
}
}
/* Respect the user's motion preference: freeze the decorative liquid waves
and pulses while keeping the frosted dock visuals intact. */
@media (prefers-reduced-motion: reduce) {
.liquid-wave,
.wave-glow-ambient,
.pulse-indicator-dot,
.running-pulse-dot {
animation: none !important;
}
}
@@ -14,8 +14,8 @@
* limitations under the License.
*/
import { Component, HostListener, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Component, ChangeDetectionStrategy, NgZone, DestroyRef, inject, computed, signal, ViewChild, ElementRef, OnInit } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { AgentStreamComponent } from '../../components/agent-stream/agent-stream.component';
import { ChatInterfaceComponent } from '../../components/chat-interface/chat-interface.component';
@@ -26,26 +26,35 @@ import { AgentService } from '../../services/agent.service';
selector: 'app-workspace',
standalone: true,
imports: [
CommonModule,
FormsModule,
AgentStreamComponent,
ChatInterfaceComponent,
FloatingVideoPlayerComponent
],
],
templateUrl: './workspace.component.html',
styleUrl: './workspace.component.scss'
styleUrl: './workspace.component.scss',
changeDetection: ChangeDetectionStrategy.OnPush
})
export class WorkspaceComponent implements OnInit {
public agentService = inject(AgentService);
private zone = inject(NgZone);
private destroyRef = inject(DestroyRef);
// Default right panel width to 1/3 of the screen (or 450px as fallback)
public rightPanelWidth = typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450;
public isDragging = false;
public rightPanelWidth = signal<number>(
typeof window !== 'undefined' ? Math.round(window.innerWidth / 3) : 450
);
public isDragging = signal<boolean>(false);
private dragWidthRafId: number | null = null;
private pendingDragWidth = 0;
// Floating Command Bar State
public taskInput: string = '';
public isSubmitting: boolean = false;
public errorMessage: string | null = null;
// Floating Command Bar State. taskInput is backed by a signal so computed
// expressions (isBarExpanded) genuinely track it under OnPush.
private taskInputSignal = signal<string>('');
public get taskInput(): string { return this.taskInputSignal(); }
public set taskInput(value: string) { this.taskInputSignal.set(value); }
public isSubmitting = signal<boolean>(false);
public errorMessage = signal<string | null>(null);
public selectedProfile = signal<'flash' | 'pro'>('flash');
// Expand States (Signals for 0-latency reactivity)
@@ -61,6 +70,16 @@ export class WorkspaceComponent implements OnInit {
this.selectedProfile.set(saved);
}
}
// The global ⌘K/Ctrl+K shortcut is registered outside the Angular zone so
// ordinary typing never schedules an extra change-detection pass.
this.zone.runOutsideAngular(() => {
window.addEventListener('keydown', this.onGlobalKeyDown);
});
this.destroyRef.onDestroy(() => {
window.removeEventListener('keydown', this.onGlobalKeyDown);
this.detachDragListeners();
});
}
/**
@@ -179,13 +198,12 @@ export class WorkspaceComponent implements OnInit {
/**
* Global keyboard shortcut (⌘K or Ctrl+K) to focus input bar from anywhere
*/
@HostListener('window:keydown', ['$event'])
public onGlobalKeyDown(event: KeyboardEvent): void {
private onGlobalKeyDown = (event: KeyboardEvent): void => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
event.preventDefault();
this.focusInput();
}
}
};
/**
* Handle enter key in floating dock textarea
@@ -202,12 +220,12 @@ export class WorkspaceComponent implements OnInit {
*/
public submitTask(): void {
const goal = this.taskInput.trim();
if (!goal || this.isSubmitting) {
if (!goal || this.isSubmitting()) {
return;
}
this.isSubmitting = true;
this.errorMessage = null;
this.isSubmitting.set(true);
this.errorMessage.set(null);
if (this.dockInputRef?.nativeElement) {
this.dockInputRef.nativeElement.blur();
@@ -220,15 +238,15 @@ export class WorkspaceComponent implements OnInit {
if (this.dockInputRef?.nativeElement) {
this.dockInputRef.nativeElement.style.height = 'auto';
}
this.isSubmitting = false;
this.isSubmitting.set(false);
this.agentService.fetchStatus();
},
error: (err) => {
console.error('Failed to submit task:', err);
this.isSubmitting = false;
this.errorMessage = err.error?.detail || 'The runner is busy. Please wait for current task to finish.';
this.isSubmitting.set(false);
this.errorMessage.set(err.error?.detail || 'The runner is busy. Please wait for current task to finish.');
setTimeout(() => {
this.errorMessage = null;
this.errorMessage.set(null);
}, 5000);
}
});
@@ -245,28 +263,31 @@ export class WorkspaceComponent implements OnInit {
return;
}
const targetSessionId = this.agentService.currentSessionId();
this.isSubmitting = true;
this.errorMessage = null;
this.isSubmitting.set(true);
this.errorMessage.set(null);
this.agentService.stopTask(targetSessionId, false);
setTimeout(() => {
this.isSubmitting = false;
this.isSubmitting.set(false);
}, 400);
}
/**
* Handle mouse down on resizer bar to start dragging
* Handle mouse down on resizer bar to start dragging. The move/up listeners
* are attached only for the duration of the drag and run outside the Angular
* zone: idle mouse movement over the workspace never triggers change
* detection, and drag updates are coalesced to one per animation frame.
*/
public onDragStart(event: MouseEvent): void {
this.isDragging = true;
this.isDragging.set(true);
event.preventDefault();
this.zone.runOutsideAngular(() => {
document.addEventListener('mousemove', this.onMouseMove);
document.addEventListener('mouseup', this.onMouseUp);
});
}
/**
* Listen to global mousemove events to adjust the width dynamically
*/
@HostListener('document:mousemove', ['$event'])
public onMouseMove(event: MouseEvent): void {
if (!this.isDragging) {
private onMouseMove = (event: MouseEvent): void => {
if (!this.isDragging()) {
return;
}
@@ -276,15 +297,27 @@ export class WorkspaceComponent implements OnInit {
// Apply boundary limits to prevent panels from shrinking too much
if (newWidth >= minWidth && newWidth <= maxWidth) {
this.rightPanelWidth = newWidth;
this.pendingDragWidth = newWidth;
if (this.dragWidthRafId === null) {
this.dragWidthRafId = requestAnimationFrame(() => {
this.dragWidthRafId = null;
this.rightPanelWidth.set(this.pendingDragWidth);
});
}
}
};
private onMouseUp = (): void => {
this.isDragging.set(false);
this.detachDragListeners();
};
private detachDragListeners(): void {
document.removeEventListener('mousemove', this.onMouseMove);
document.removeEventListener('mouseup', this.onMouseUp);
if (this.dragWidthRafId !== null) {
cancelAnimationFrame(this.dragWidthRafId);
this.dragWidthRafId = null;
}
}
/**
* Listen to global mouseup events to stop resizing
*/
@HostListener('document:mouseup')
public onMouseUp(): void {
this.isDragging = false;
}
}
@@ -14,7 +14,7 @@
* limitations under the License.
*/
import { Injectable, signal, inject, computed } from '@angular/core';
import { Injectable, signal, inject, computed, DestroyRef, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
@@ -63,6 +63,7 @@ interface SessionVideoResponse {
})
export class AgentService {
private http = inject(HttpClient);
private zone = inject(NgZone);
private activePauseCardKey: string | null = null;
// Signals to expose state to components
@@ -81,21 +82,25 @@ export class AgentService {
const activeList = this.activeTasks();
const sessionMap = new Map<string, Session>();
// Lookup maps keep the per-session merge O(1) instead of scanning the
// pending/active lists multiple times for every raw session.
const pendingById = new Map(pending.map((p) => [p.session_id, p]));
const activeById = new Map(activeList.map((at) => [at.session_id, at]));
// 1. First add raw sessions from DB
raw.forEach((s) => {
const activeMatch = activeById.get(s.session_id);
const pendingMatch = pendingById.get(s.session_id);
const isCurrentActive = ((status === 'running' || status === 'paused') && runId === s.session_id)
|| activeList.some(at => at.session_id === s.session_id);
|| !!activeMatch;
const isTerminal = s.status === 'completed' || s.status === 'success' || s.status === 'failed' || s.status === 'cancelled';
let sStatus = s.status;
if (!isTerminal) {
const isPending = this.pendingQueue().some(p => p.session_id === s.session_id) && !isCurrentActive;
const isPending = !!pendingMatch && !isCurrentActive;
sStatus = isCurrentActive ? (status === 'paused' ? 'paused' : 'running') : (isPending ? 'pending' : s.status);
} else {
sStatus = (s.status === 'success') ? 'completed' : s.status;
}
const activeMatch = activeList.find(at => at.session_id === s.session_id);
const pendingMatch = this.pendingQueue().find(p => p.session_id === s.session_id);
let serial = s.device_serial || s.device_id || activeMatch?.device_id || pendingMatch?.device_serial || null;
if (!serial && s.device_info) {
try {
@@ -122,9 +127,9 @@ export class AgentService {
// 2. Add pending queue sessions if not yet in raw sessions
pending.forEach((p) => {
if (!sessionMap.has(p.session_id)) {
const activeMatch = activeById.get(p.session_id);
const isCurrentRunning = ((status === 'running' || status === 'paused') && runId === p.session_id)
|| activeList.some(at => at.session_id === p.session_id);
const activeMatch = activeList.find(at => at.session_id === p.session_id);
|| !!activeMatch;
const finalPending: Session = {
...p,
status: isCurrentRunning ? (status === 'paused' ? 'paused' : 'running') : (p.status || 'pending'),
@@ -241,11 +246,23 @@ export class AgentService {
private videoRetryTimer: ReturnType<typeof setTimeout> | null = null;
private videoWaitStartedAt = 0;
/**
* Step logs only. Streaming text chunks cannot change replay frames, so the
* custom equality keeps downstream frame extraction from re-running on every
* llm_stream update of the sessionLogs signal.
*/
private stepLogsForReplay = computed<any[]>(
() => this.sessionLogs().filter(
(log) => log && (log.type === 'step_updated' || log.type === 'step_recorded')
),
{ equal: (a, b) => a.length === b.length && a.every((log, i) => log === b[i]) }
);
/**
* Computed step replay frames from current session logs
*/
public currentSessionStepFrames = computed<StepReplayFrame[]>(() => {
return extractStepReplayFrames(this.sessionLogs());
return extractStepReplayFrames(this.stepLogsForReplay());
});
public hasCurrentSessionStepFrames = computed<boolean>(() => {
@@ -328,11 +345,29 @@ export class AgentService {
private sessionSnapshotAppliedId = 0;
private pendingSnapshotRequests = new Set<number>();
// Streaming chunks are coalesced into one signal update per short window so a
// chatty LLM stream cannot force a full recompute pass per SSE chunk.
private pendingStreamChunks = new Map<string, { execId: any; stepId: any; streamType: string; chunk: string }>();
private streamFlushTimer: ReturnType<typeof setTimeout> | null = null;
private lastQueueSignature: string | null = null;
private lastActiveTasksSignature: string | null = null;
private lastPersistedSessionsJson: string | null = null;
private onVisibilityChange = () => {
if (typeof document !== 'undefined' && !document.hidden) {
this.fetchStatus();
this.fetchSessions();
}
};
constructor() {
this.restoreSessionsCache();
this.fetchSessions();
this.startStatusPolling();
this.ensureLiveStream();
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
inject(DestroyRef).onDestroy(() => this.destroy());
}
/**
@@ -451,20 +486,25 @@ export class AgentService {
this.isPaused.set(false);
this.pausedError.set(null);
this.isRetrying.set(false);
this.invalidateStatusSignatures();
if (effectiveStopAll) {
this.pendingQueue.set([]);
}
// Mark active streaming / pending logs as finished if viewing target session
if (!targetSessionId || this.currentSessionId() === targetSessionId) {
this.sessionLogs.update((logs) =>
logs.map((l) => {
this.flushStreamChunks();
this.sessionLogs.update((logs) => {
if (!logs.some((l) => l.type === 'llm_stream' && !l.data?.isCompleted)) {
return logs;
}
return logs.map((l) => {
if (l.type === 'llm_stream' && !l.data?.isCompleted) {
return { ...l, data: { ...l.data, isCompleted: true } };
}
return l;
})
);
});
});
}
// Optimistically remove from activeTasks
@@ -545,6 +585,7 @@ export class AgentService {
*/
public deleteSession(sessionId: string): Observable<any> {
// 1. Optimistically update local session state immediately
this.invalidateStatusSignatures();
this.rawSessions.update((list) => list.filter((s) => s.session_id !== sessionId));
this.persistSessionsCache(this.rawSessions());
this.pendingQueue.update((list) => list.filter((s) => s.session_id !== sessionId));
@@ -588,6 +629,7 @@ export class AgentService {
*/
public clearAllHistory(): Observable<any> {
// 1. Optimistically clear all local session state immediately
this.invalidateStatusSignatures();
this.userPinnedSessionId.set(null);
this.rawSessions.set([]);
this.clearSessionsCache();
@@ -637,6 +679,7 @@ export class AgentService {
if (!sessionId) {
this.sessionLoadGeneration++;
this.pendingSnapshotRequests.clear();
this.discardPendingStreamChunks();
this.isSessionContentLoading.set(false);
this.currentSessionId.set(null);
this.sessionLogs.set([]);
@@ -650,6 +693,7 @@ export class AgentService {
const loadGeneration = ++this.sessionLoadGeneration;
this.pendingSnapshotRequests.clear();
this.discardPendingStreamChunks();
this.sessionSnapshotAppliedId = 0;
this.isSessionContentLoading.set(true);
this.currentSessionId.set(sessionId);
@@ -681,6 +725,10 @@ export class AgentService {
}
console.log('Establishing persistent unified live stream via /api/stream');
// The stream is registered outside the Angular zone: high-frequency SSE
// callbacks must not schedule a change-detection pass each. Signal writes
// still notify the render scheduler, so the UI stays live.
this.zone.runOutsideAngular(() => {
this.eventSource = new EventSource('/api/stream');
this.eventSource.addEventListener('info', () => {
@@ -717,6 +765,12 @@ export class AgentService {
const parsedData = JSON.parse(event.data);
const evtSessionId = parsedData?.session_id;
// Preserve ordering: buffered stream text must land before any
// non-stream event is appended to the session logs.
if (eventType !== 'llm_stream') {
this.flushStreamChunks();
}
if (eventType === 'recording_ready' || eventType === 'recording_failed') {
if (
this.isVideoWindowOpen()
@@ -765,6 +819,7 @@ export class AgentService {
const endedId = evtSessionId || this.runningSessionId();
if (endedId) {
this.applySessionEndedStatus(endedId, parsedData);
this.invalidateStatusSignatures();
this.activeTasks.update(list => list.filter(at => at.session_id !== endedId));
}
const remaining = this.activeTasks();
@@ -874,62 +929,15 @@ export class AgentService {
}
if (eventType === 'llm_stream') {
const execId = parsedData.execution_id;
const stepId = parsedData.step_id;
const chunk = parsedData.chunk;
const streamType = parsedData.stream_type || 'text';
this.sessionLogs.update((logs) => {
const existingIndex = logs.findIndex(
(l) => l.type === 'llm_stream' &&
l.data.execution_id === execId &&
(l.data.stream_type || 'text') === streamType
);
if (existingIndex > -1) {
const updatedLogs = [...logs];
const existingLog = updatedLogs[existingIndex];
updatedLogs[existingIndex] = {
...existingLog,
data: {
...existingLog.data,
text: existingLog.data.text + chunk,
step_id: stepId || existingLog.data.step_id
}
};
return updatedLogs;
} else {
const updatedLogs = logs.map((l) => {
if (l.type === 'llm_stream' &&
(l.data.stream_type || 'text') === streamType &&
!l.data.isCompleted) {
return {
...l,
data: { ...l.data, isCompleted: true }
};
}
return l;
});
return [
...updatedLogs,
{
type: 'llm_stream',
timestamp: new Date().toISOString(),
data: {
execution_id: execId,
step_id: stepId,
text: chunk,
stream_type: streamType,
isCompleted: false
}
}
];
}
});
this.queueStreamChunk(parsedData);
} else {
this.sessionLogs.update((logs) => {
const updatedLogs = logs.map((l) => {
// Copying every log object on every event is wasted work when no
// stream is open; only rewrite entries that actually change.
const hasOpenStream = logs.some(
(l) => l.type === 'llm_stream' && !l.data.isCompleted
);
const updatedLogs = !hasOpenStream ? [...logs] : logs.map((l) => {
if (l.type === 'llm_stream' && !l.data.isCompleted) {
return {
...l,
@@ -991,6 +999,92 @@ export class AgentService {
this.eventSource.onerror = (err) => {
console.warn('Persistent live stream issue, browser will auto-reconnect:', err);
};
});
}
/**
* Buffer one llm_stream chunk; the buffer is flushed into sessionLogs at most
* once per short window (and immediately before any non-stream event so the
* relative ordering of stream text and step events is preserved).
*/
private queueStreamChunk(parsedData: any): void {
const execId = parsedData.execution_id;
const stepId = parsedData.step_id;
const streamType = parsedData.stream_type || 'text';
const key = `${execId}|${streamType}`;
const pending = this.pendingStreamChunks.get(key);
if (pending) {
pending.chunk += parsedData.chunk;
if (stepId) pending.stepId = stepId;
} else {
this.pendingStreamChunks.set(key, { execId, stepId, streamType, chunk: parsedData.chunk });
}
if (!this.streamFlushTimer) {
const delay = typeof document !== 'undefined' && document.hidden ? 500 : 80;
this.streamFlushTimer = setTimeout(() => this.flushStreamChunks(), delay);
}
}
private discardPendingStreamChunks(): void {
this.pendingStreamChunks?.clear();
if (this.streamFlushTimer) {
clearTimeout(this.streamFlushTimer);
this.streamFlushTimer = null;
}
}
private flushStreamChunks(): void {
if (this.streamFlushTimer) {
clearTimeout(this.streamFlushTimer);
this.streamFlushTimer = null;
}
if (!this.pendingStreamChunks?.size) return;
const batches = Array.from(this.pendingStreamChunks.values());
this.pendingStreamChunks.clear();
this.sessionLogs.update((logs) => {
let next = [...logs];
for (const batch of batches) {
const existingIndex = next.findIndex(
(l) => l.type === 'llm_stream' &&
l.data.execution_id === batch.execId &&
(l.data.stream_type || 'text') === batch.streamType
);
if (existingIndex > -1) {
const existingLog = next[existingIndex];
next[existingIndex] = {
...existingLog,
data: {
...existingLog.data,
text: existingLog.data.text + batch.chunk,
step_id: batch.stepId || existingLog.data.step_id
}
};
} else {
next = next.map((l) => {
if (l.type === 'llm_stream' &&
(l.data.stream_type || 'text') === batch.streamType &&
!l.data.isCompleted) {
return { ...l, data: { ...l.data, isCompleted: true } };
}
return l;
});
next.push({
type: 'llm_stream',
timestamp: new Date().toISOString(),
data: {
execution_id: batch.execId,
step_id: batch.stepId,
text: batch.chunk,
stream_type: batch.streamType,
isCompleted: false
}
});
}
}
return next;
});
}
/**
@@ -1065,6 +1159,7 @@ export class AgentService {
if (!cached) return;
const sessions = JSON.parse(cached);
if (Array.isArray(sessions)) {
this.lastPersistedSessionsJson = cached;
this.rawSessions.set(sessions);
}
} catch {
@@ -1074,13 +1169,17 @@ export class AgentService {
private persistSessionsCache(sessions: Session[]): void {
try {
localStorage.setItem(SESSION_CACHE_KEY, JSON.stringify(sessions));
const serialized = JSON.stringify(sessions);
if (serialized === this.lastPersistedSessionsJson) return;
this.lastPersistedSessionsJson = serialized;
localStorage.setItem(SESSION_CACHE_KEY, serialized);
} catch {
// Storage can be unavailable in private browsing or embedded contexts.
}
}
private clearSessionsCache(): void {
this.lastPersistedSessionsJson = null;
try {
localStorage.removeItem(SESSION_CACHE_KEY);
} catch {
@@ -1217,6 +1316,7 @@ export class AgentService {
private setSessionStatus(sessionId: string | null, status: Session['status']): void {
if (!sessionId) return;
this.invalidateStatusSignatures();
this.rawSessions.update((sessions) => sessions.map((session) =>
session.session_id === sessionId ? { ...session, status } : session
));
@@ -1240,6 +1340,7 @@ export class AgentService {
if (status) {
this.activeSessionTracking.delete(String(sessionId));
this.setSessionStatus(String(sessionId), status);
this.invalidateStatusSignatures();
this.activeTasks.update((list) => list.filter((at) => at.session_id !== String(sessionId)));
}
}
@@ -1249,14 +1350,30 @@ export class AgentService {
*/
private startStatusPolling(): void {
this.fetchStatus();
this.statusInterval = setInterval(() => {
this.fetchStatus();
this.pollCounter++;
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
if (this.pollCounter % 3 === 0) {
this.fetchSessions();
}
}, 2000);
// The interval runs outside the Angular zone so a poll tick alone never
// schedules change detection; only actual signal updates do.
this.zone.runOutsideAngular(() => {
this.statusInterval = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) {
return; // Paused while the tab is in the background; resumed by visibilitychange.
}
this.fetchStatus();
this.pollCounter++;
// Periodically refresh sessions every 6 seconds (every 3 polling intervals of 2s) to stay in sync with external DB modifications
if (this.pollCounter % 3 === 0) {
this.fetchSessions();
}
}, 2000);
});
}
/**
* Optimistic local mutations of queue/active state must force the next poll
* to re-apply backend data even when its payload has not changed.
*/
private invalidateStatusSignatures(): void {
this.lastQueueSignature = null;
this.lastActiveTasksSignature = null;
}
/**
@@ -1291,26 +1408,37 @@ export class AgentService {
this.activePauseCardKey = null;
}
// Map backend pending queue to real Session / TaskQueueItem objects
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
if (typeof item === 'object' && item !== null) {
// Skip signal updates when backend payloads did not change: setting a
// fresh array reference every 2s poll would force the whole sessions
// computed chain to re-derive for identical data.
const queueSignature = JSON.stringify(data.queue || []);
if (queueSignature !== this.lastQueueSignature) {
this.lastQueueSignature = queueSignature;
// Map backend pending queue to real Session / TaskQueueItem objects
const pending: Session[] = (data.queue || []).map((item: any, index: number) => {
if (typeof item === 'object' && item !== null) {
return {
session_id: item.session_id || `pending-task-${index}`,
initial_goal: item.goal || '',
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
status: item.status || 'pending',
device_serial: item.device_serial || item.device_id || null
};
}
return {
session_id: item.session_id || `pending-task-${index}`,
initial_goal: item.goal || '',
start_time: item.start_time || item.created_at || (Date.now() / 1000 + index),
status: item.status || 'pending',
device_serial: item.device_serial || item.device_id || null
session_id: `task-queued-${index}`,
initial_goal: String(item),
start_time: (Date.now() / 1000) + index,
status: 'pending'
};
}
return {
session_id: `task-queued-${index}`,
initial_goal: String(item),
start_time: (Date.now() / 1000) + index,
status: 'pending'
};
});
this.pendingQueue.set(pending);
this.activeTasks.set(data.active_tasks || []);
});
this.pendingQueue.set(pending);
}
const activeTasksSignature = JSON.stringify(data.active_tasks || []);
if (activeTasksSignature !== this.lastActiveTasksSignature) {
this.lastActiveTasksSignature = activeTasksSignature;
this.activeTasks.set(data.active_tasks || []);
}
if (oldStatus !== data.status || oldRunningSessionId !== data.session_id) {
this.fetchSessions();
@@ -1367,9 +1495,15 @@ export class AgentService {
public destroy(): void {
if (this.statusInterval) {
clearInterval(this.statusInterval);
this.statusInterval = null;
}
if (this.eventSource) {
this.eventSource.close();
this.eventSource = null;
}
this.discardPendingStreamChunks();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.onVisibilityChange);
}
this.cancelVideoRetry();
}
@@ -1,4 +1,4 @@
import { provideHttpClient } from '@angular/common/http';
import { provideHttpClient, withXhr } from '@angular/common/http';
import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing';
import { TestBed } from '@angular/core/testing';
@@ -23,7 +23,7 @@ describe('SystemService readiness polling', () => {
TestBed.configureTestingModule({
providers: [
SystemService,
provideHttpClient(),
provideHttpClient(withXhr()),
provideHttpClientTesting()
]
});
@@ -69,4 +69,105 @@ describe('SystemService readiness polling', () => {
expect(request.request.params.get('force')).toBe('true');
request.flush(report(30));
});
it('loads the active ADB server endpoint', () => {
service.fetchAdbServerStatus().subscribe();
const request = http.expectOne('/api/system/adb/server');
request.flush({
endpoint: {
host: '127.0.0.1',
port: 5038,
socket: 'tcp:127.0.0.1:5038',
mode: 'remote',
is_local_default: false
}
});
expect(service.isRemoteAdbServer()).toBeTrue();
expect(service.adbServerStatus()?.endpoint.port).toBe(5038);
});
it('activates an ADB server endpoint and applies its readiness report', () => {
service.connectAdbServer('127.0.0.1', 5038, true).subscribe();
const request = http.expectOne('/api/system/adb/server/connect');
expect(request.request.body).toEqual({
host: '127.0.0.1',
port: 5038,
persist: true
});
request.flush({
connection_result: {
success: true,
message: 'Connected.',
endpoint: {
host: '127.0.0.1',
port: 5038,
socket: 'tcp:127.0.0.1:5038',
mode: 'remote',
is_local_default: false
},
devices: []
},
report: report(40)
});
expect(service.isRemoteAdbServer()).toBeTrue();
expect(service.readinessReport()?.timestamp).toBe(40);
});
it('probes an ADB server without changing the active endpoint', () => {
service.probeAdbServer('127.0.0.1', 5038).subscribe();
const request = http.expectOne('/api/system/adb/server/probe');
expect(request.request.body).toEqual({
host: '127.0.0.1',
port: 5038,
persist: false
});
request.flush({
connection_result: {
success: true,
message: 'Endpoint is reachable.',
endpoint: {
host: '127.0.0.1',
port: 5038,
socket: 'tcp:127.0.0.1:5038',
identity: 'tcp:127.0.0.1:5038',
mode: 'remote',
is_local_default: false
},
devices: []
}
});
expect(service.adbServerStatus()).toBeNull();
});
it('restores the standard local ADB server explicitly', () => {
service.useLocalAdbServer(true).subscribe();
const request = http.expectOne(req =>
req.url === '/api/system/adb/server/local' && req.params.get('persist') === 'true'
);
request.flush({
connection_result: {
success: true,
message: 'Using local ADB.',
endpoint: {
host: '127.0.0.1',
port: 5037,
socket: 'tcp:127.0.0.1:5037',
mode: 'local',
is_local_default: true
},
devices: []
},
report: report(50)
});
expect(service.isRemoteAdbServer()).toBeFalse();
expect(service.adbServerStatus()?.endpoint.port).toBe(5037);
});
});
@@ -14,10 +14,18 @@
* limitations under the License.
*/
import { Injectable, signal, computed, inject } from '@angular/core';
import { Injectable, signal, computed, inject, DestroyRef, NgZone } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable, finalize, shareReplay, tap } from 'rxjs';
import { SystemReadinessReport, DeviceInfo, ProbeResult, EmulatorLaunchState, EmulatorLaunchStage } from '../core/models/system.model';
import {
AdbServerConnectionResponse,
AdbServerStatus,
SystemReadinessReport,
DeviceInfo,
ProbeResult,
EmulatorLaunchState,
EmulatorLaunchStage
} from '../core/models/system.model';
@Injectable({
providedIn: 'root'
@@ -30,6 +38,10 @@ export class SystemService {
public hasReadinessReport = computed(() => this.readinessReport() !== null);
public isLoading = signal<boolean>(false);
public isRestartingAdb = signal<boolean>(false);
public adbServerStatus = signal<AdbServerStatus | null>(null);
public isRemoteAdbServer = computed(
() => this.adbServerStatus()?.endpoint.mode === 'remote'
);
public launchingAvd = signal<string | null>(null);
public emulatorLaunchState = signal<EmulatorLaunchState | null>(null);
public isEmulatorLaunching = computed(() => {
@@ -138,24 +150,49 @@ export class SystemService {
private pollingTimer: any = null;
private readinessRequest$: Observable<SystemReadinessReport> | null = null;
private zone = inject(NgZone);
private lastAppliedReportJson: string | null = null;
private onVisibilityChange = () => {
if (typeof document !== 'undefined' && !document.hidden) {
this.fetchReadiness(true).subscribe({ error: () => {} });
}
};
constructor() {
this.startAutoPolling(3000);
if (typeof document !== 'undefined') {
document.addEventListener('visibilitychange', this.onVisibilityChange);
}
inject(DestroyRef).onDestroy(() => {
this.stopAutoPolling();
this.stopEmulatorStatusPolling();
if (typeof document !== 'undefined') {
document.removeEventListener('visibilitychange', this.onVisibilityChange);
}
});
}
/**
* Start periodic auto-polling for system readiness changes
* Start periodic auto-polling for system readiness changes. The interval
* runs outside the Angular zone (a tick alone must not trigger change
* detection) and pauses while the tab is hidden; a visibilitychange listener
* refreshes immediately when the tab becomes visible again.
*/
public startAutoPolling(intervalMs: number = 3000): void {
if (this.pollingTimer) {
clearInterval(this.pollingTimer);
}
this.pollingTimer = setInterval(() => {
// Perform silent background check without disturbing UI loading state
this.fetchReadiness(true).subscribe({
error: () => {} // Silent catch
});
}, intervalMs);
this.zone.runOutsideAngular(() => {
this.pollingTimer = setInterval(() => {
if (typeof document !== 'undefined' && document.hidden) {
return;
}
// Perform silent background check without disturbing UI loading state
this.fetchReadiness(true).subscribe({
error: () => {} // Silent catch
});
}, intervalMs);
});
}
/**
@@ -214,8 +251,15 @@ export class SystemService {
if (current && report.timestamp < current.timestamp) {
return;
}
this.readinessReport.set(report);
this.lastCheckedTime.set(new Date(report.timestamp * 1000));
// A poll usually returns an identical report; setting a new object anyway
// would cascade through every probe computed for no visible change.
const serialized = JSON.stringify({ ...report, timestamp: 0 });
if (serialized === this.lastAppliedReportJson) {
return;
}
this.lastAppliedReportJson = serialized;
this.readinessReport.set(report);
}
/**
@@ -388,6 +432,69 @@ export class SystemService {
);
}
/**
* Fetch the process-wide ADB server endpoint used by Artemis.
*/
public fetchAdbServerStatus(): Observable<AdbServerStatus> {
return this.http.get<AdbServerStatus>('/api/system/adb/server').pipe(
tap(status => this.adbServerStatus.set(status))
);
}
/**
* Test an ADB server endpoint without changing the active connection.
*/
public probeAdbServer(
host: string,
port: number
): Observable<AdbServerConnectionResponse> {
return this.http.post<AdbServerConnectionResponse>(
'/api/system/adb/server/probe',
{ host, port, persist: false }
);
}
/**
* Validate and activate an ADB server endpoint.
*/
public connectAdbServer(
host: string,
port: number,
persist: boolean = true
): Observable<AdbServerConnectionResponse> {
return this.http.post<AdbServerConnectionResponse>(
'/api/system/adb/server/connect',
{ host, port, persist }
).pipe(
tap(response => {
if (response.connection_result.success) {
this.adbServerStatus.set({ endpoint: response.connection_result.endpoint });
if (response.report) {
this.applyReadinessReport(response.report);
}
}
})
);
}
/**
* Switch Artemis back to the standard local ADB server.
*/
public useLocalAdbServer(persist: boolean = true): Observable<AdbServerConnectionResponse> {
return this.http.post<AdbServerConnectionResponse>(
'/api/system/adb/server/local',
{},
{ params: { persist } }
).pipe(
tap(response => {
this.adbServerStatus.set({ endpoint: response.connection_result.endpoint });
if (response.report) {
this.applyReadinessReport(response.report);
}
})
);
}
/**
* Select a specific connected device serial as active target
*/
@@ -283,6 +283,39 @@ export function consolidateLogsToBlocks(rawLogs: any[]): StepBlock[] {
return blocks;
}
/**
* Cache for the token-estimate fallback. Tool payloads can carry multi-megabyte
* base64 screenshots; serializing them on every recompute would block the main
* thread, so each payload object is measured exactly once.
*/
const payloadSizeCache = new WeakMap<object, { isImage: boolean; length: number }>();
function estimatePayloadSize(payload: any): { isImage: boolean; length: number } {
if (typeof payload === 'string') {
return {
isImage: payload.includes('data:image/') || payload.includes('base64,'),
length: payload.length
};
}
if (payload === null || typeof payload !== 'object') {
return { isImage: false, length: String(payload).length };
}
const cached = payloadSizeCache.get(payload);
if (cached) return cached;
let payloadStr = '';
try {
payloadStr = JSON.stringify(payload) || '';
} catch {
payloadStr = String(payload);
}
const result = {
isImage: payloadStr.includes('data:image/') || payloadStr.includes('base64,'),
length: payloadStr.length
};
payloadSizeCache.set(payload, result);
return result;
}
/**
* Safely extract and aggregate token usage for a step block
*/
@@ -345,11 +378,11 @@ export function extractBlockTokens(block: StepBlock): { total: number; prompt: n
if (data.generic_tools && Array.isArray(data.generic_tools)) {
data.generic_tools.forEach((t: any) => {
if (t.payload) {
const payloadStr = typeof t.payload === 'string' ? t.payload : JSON.stringify(t.payload);
if (payloadStr.includes('data:image/') || payloadStr.includes('base64,')) {
const estimate = estimatePayloadSize(t.payload);
if (estimate.isImage) {
numImages += 1;
} else {
textLen += payloadStr.length;
textLen += estimate.length;
}
}
});
@@ -18,11 +18,27 @@ import { ActionParam } from '../core/models/stream.model';
import { isAndroidAction, isReportStatusAction } from './action-formatter.util';
import { extractNumbersFromCoordinateValue, isPureDirectionString, parseSequenceCoordinates } from './image-overlay.util';
// Tool objects are replaced (not mutated) when a trace is updated, so a
// WeakMap keyed on the tool is a safe memo for the parsed-args lookup that
// templates would otherwise re-run (including JSON.parse) on every render.
const toolArgsCache = new WeakMap<object, any>();
/**
* Extract arguments from tool payload or direct args field
*/
export function getToolArgs(tool: any): any {
if (!tool) return {};
if (typeof tool === 'object') {
const cached = toolArgsCache.get(tool);
if (cached !== undefined) return cached;
const args = computeToolArgs(tool);
toolArgsCache.set(tool, args);
return args;
}
return computeToolArgs(tool);
}
function computeToolArgs(tool: any): any {
if (tool.payload) {
let payloadObj = tool.payload;
if (typeof payloadObj === 'string') {
+9 -1
View File
@@ -11,5 +11,13 @@
],
"include": [
"src/**/*.d.ts"
]
],
"angularCompilerOptions": {
"extendedDiagnostics": {
"checks": {
"nullishCoalescingNotNullable": "suppress",
"optionalChainNotNullable": "suppress"
}
}
}
}
+9 -1
View File
@@ -11,5 +11,13 @@
"include": [
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
],
"angularCompilerOptions": {
"extendedDiagnostics": {
"checks": {
"nullishCoalescingNotNullable": "suppress",
"optionalChainNotNullable": "suppress"
}
}
}
}