feat: enable remote SDK and resilient device orchestration
This commit is contained in:
1 parent
3cc711df51
commit
ddfa420945
110 files changed
+14450
-12954
No files matched your search
@@ -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})
|
||||
@@ -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
|
||||
|
||||
|
||||
+395
-179
File diff suppressed because it is too large.
Load diff
@@ -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)
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
Generated
+3538
-9640
File diff suppressed because it is too large.
Load diff
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+280
-225
@@ -1,17 +1,17 @@
|
||||
<!--
|
||||
Copyright 2026 Google LLC
|
||||
Copyright 2026 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<div class="chat-interface-container">
|
||||
@@ -19,267 +19,322 @@
|
||||
<header class="chat-header">
|
||||
<!-- Tab Selector Header inside row -->
|
||||
<div class="tab-selector-bar">
|
||||
<button
|
||||
class="tab-selector-btn"
|
||||
[class.active]="agentService.activeTab() === 'tasks'"
|
||||
<button
|
||||
class="tab-selector-btn"
|
||||
[class.active]="agentService.activeTab() === 'tasks'"
|
||||
(click)="agentService.activeTab.set('tasks')"
|
||||
>
|
||||
>
|
||||
<span class="material-symbols-outlined tab-icon">list_alt</span>
|
||||
<span class="tab-text">Task Queue</span>
|
||||
</button>
|
||||
<button
|
||||
class="tab-selector-btn"
|
||||
[class.active]="agentService.activeTab() === 'notes'"
|
||||
<button
|
||||
class="tab-selector-btn"
|
||||
[class.active]="agentService.activeTab() === 'notes'"
|
||||
(click)="agentService.activeTab.set('notes')"
|
||||
>
|
||||
>
|
||||
<span class="material-symbols-outlined tab-icon">description</span>
|
||||
<span class="tab-text">Notes & Plans</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="header-right">
|
||||
<button
|
||||
*ngIf="agentService.sessions().length > 0 && agentService.activeTab() === 'tasks'"
|
||||
class="btn-clear-history"
|
||||
(click)="clearHistory()"
|
||||
title="Clear all tasks and history"
|
||||
aria-label="Clear all tasks and history"
|
||||
[disabled]="isSubmitting"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
@if (agentService.sessions().length > 0 && agentService.activeTab() === 'tasks') {
|
||||
<button
|
||||
class="btn-clear-history"
|
||||
(click)="clearHistory()"
|
||||
title="Clear all tasks and history"
|
||||
aria-label="Clear all tasks and history"
|
||||
[disabled]="isSubmitting()"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Scrollable Messages/Content Area -->
|
||||
<main class="chat-messages">
|
||||
<!-- Tasks Tab Content -->
|
||||
<ng-container *ngIf="agentService.activeTab() === 'tasks'">
|
||||
@if (agentService.activeTab() === 'tasks') {
|
||||
<!-- Active Queue Section -->
|
||||
<div class="queue-section-header">
|
||||
<span class="material-symbols-outlined section-icon">bolt</span> Active Queue ({{ activeQueue().length }})
|
||||
</div>
|
||||
|
||||
<div *ngIf="activeQueue().length === 0" class="empty-section-placeholder">
|
||||
No active or queued tasks.
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngFor="let session of activeQueue(); trackBy: trackSession"
|
||||
class="task-card"
|
||||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||||
[class.running]="getTaskStatus(session) === 'running'"
|
||||
(click)="selectTask(session.session_id)"
|
||||
>
|
||||
<div class="task-card-header">
|
||||
<span class="task-badge" [ngClass]="getTaskStatus(session)">
|
||||
<span class="status-dot" *ngIf="getTaskStatus(session) === 'running'"></span>
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<div class="task-card-header-actions">
|
||||
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
@if (activeQueue().length === 0) {
|
||||
<div class="empty-section-placeholder">
|
||||
No active or queued tasks.
|
||||
</div>
|
||||
}
|
||||
@for (session of activeQueue(); track trackSession($index, session)) {
|
||||
<div
|
||||
class="task-card"
|
||||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||||
[class.running]="getTaskStatus(session) === 'running'"
|
||||
(click)="selectTask(session.session_id)"
|
||||
>
|
||||
<div class="task-card-header">
|
||||
<span class="task-badge" [ngClass]="getTaskStatus(session)">
|
||||
@if (getTaskStatus(session) === 'running') {
|
||||
<span class="status-dot"></span>
|
||||
}
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
|
||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
<button
|
||||
*ngIf="getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused'"
|
||||
type="button"
|
||||
class="btn-stop-card"
|
||||
(click)="stopTask(session.session_id, $event)"
|
||||
title="Stop this task"
|
||||
aria-label="Stop this task"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop_circle</span>
|
||||
</button>
|
||||
<button
|
||||
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
[disabled]="isSubmitting"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
<div class="task-card-header-actions">
|
||||
@if (getDeviceSerial(session)) {
|
||||
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
</span>
|
||||
}
|
||||
@if (getDeviceSerial(session)) {
|
||||
<span class="task-time-divider">·</span>
|
||||
}
|
||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
@if (getTaskStatus(session) === 'running' || getTaskStatus(session) === 'paused') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-stop-card"
|
||||
(click)="stopTask(session.session_id, $event)"
|
||||
title="Stop this task"
|
||||
aria-label="Stop this task"
|
||||
>
|
||||
<span class="material-symbols-outlined">stop_circle</span>
|
||||
</button>
|
||||
}
|
||||
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
[disabled]="isSubmitting()"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
</div>
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
</div>
|
||||
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
}
|
||||
<!-- Collapsible History Section -->
|
||||
<details class="history-section" open>
|
||||
<summary class="history-summary">
|
||||
<span class="material-symbols-outlined section-icon">history</span> Past Runs & History ({{ historyTasks().length }})
|
||||
</summary>
|
||||
|
||||
<div class="history-list-wrapper">
|
||||
<div *ngIf="historyTasks().length === 0" class="empty-section-placeholder">
|
||||
No past tasks.
|
||||
</div>
|
||||
|
||||
<div
|
||||
*ngFor="let session of historyTasks(); trackBy: trackSession"
|
||||
class="task-card"
|
||||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||||
[class.running]="getTaskStatus(session) === 'running'"
|
||||
(click)="selectTask(session.session_id)"
|
||||
>
|
||||
<div class="task-card-header">
|
||||
<span class="task-badge" [ngClass]="getTaskStatus(session)">
|
||||
<span class="status-dot" *ngIf="getTaskStatus(session) === 'running'"></span>
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<div class="task-card-header-actions">
|
||||
<span class="task-device" *ngIf="getDeviceSerial(session)" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
@if (historyTasks().length === 0) {
|
||||
<div class="empty-section-placeholder">
|
||||
No past tasks.
|
||||
</div>
|
||||
}
|
||||
@for (session of historyTasks(); track trackSession($index, session)) {
|
||||
<div
|
||||
class="task-card"
|
||||
[class.selected]="agentService.currentSessionId() === session.session_id"
|
||||
[class.running]="getTaskStatus(session) === 'running'"
|
||||
(click)="selectTask(session.session_id)"
|
||||
>
|
||||
<div class="task-card-header">
|
||||
<span class="task-badge" [ngClass]="getTaskStatus(session)">
|
||||
@if (getTaskStatus(session) === 'running') {
|
||||
<span class="status-dot"></span>
|
||||
}
|
||||
{{ getTaskStatus(session) | uppercase }}
|
||||
</span>
|
||||
<span class="task-time-divider" *ngIf="getDeviceSerial(session)">·</span>
|
||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
<button
|
||||
*ngIf="getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused'"
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
[disabled]="isSubmitting"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
<div class="task-card-header-actions">
|
||||
@if (getDeviceSerial(session)) {
|
||||
<span class="task-device" [title]="'Device: ' + getDeviceSerial(session)">
|
||||
<span class="material-symbols-outlined device-icon">phone_android</span>
|
||||
<span class="device-name">{{ getDeviceSerial(session) }}</span>
|
||||
</span>
|
||||
}
|
||||
@if (getDeviceSerial(session)) {
|
||||
<span class="task-time-divider">·</span>
|
||||
}
|
||||
<span class="task-time">{{ session.start_time * 1000 | date:'shortTime' }}</span>
|
||||
@if (getTaskStatus(session) !== 'running' && getTaskStatus(session) !== 'paused') {
|
||||
<button
|
||||
type="button"
|
||||
class="btn-delete-card"
|
||||
(click)="deleteTask(session.session_id, $event)"
|
||||
title="Delete this task"
|
||||
aria-label="Delete this task"
|
||||
[disabled]="isSubmitting()"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
</div>
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-goal" title="{{ session.initial_goal }}">
|
||||
{{ session.initial_goal || 'Unnamed Task' }}
|
||||
</div>
|
||||
|
||||
<div class="task-card-footer">
|
||||
<span class="task-id" [title]="session.session_id">ID: {{ session.session_id }}</span>
|
||||
<span class="task-date">{{ session.start_time * 1000 | date:'MM/dd/yy' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</details>
|
||||
</ng-container>
|
||||
}
|
||||
|
||||
<!-- Notes Tab Content -->
|
||||
<ng-container *ngIf="agentService.activeTab() === 'notes'">
|
||||
@if (agentService.activeTab() === 'notes') {
|
||||
<div class="notes-tab-content">
|
||||
<!-- Notes Selector / Pills (Only shown if there are multiple markdown notes) -->
|
||||
<div class="notes-selector-container" *ngIf="noteKeys().length > 1">
|
||||
<div class="notes-selector-list">
|
||||
<button
|
||||
*ngFor="let key of noteKeys(); trackBy: trackNoteKey"
|
||||
class="note-selector-pill"
|
||||
[class.active]="agentService.selectedNoteKey() === key"
|
||||
(click)="selectNote(key)"
|
||||
>
|
||||
<span class="material-symbols-outlined pill-icon">draft</span>
|
||||
{{ key }}
|
||||
</button>
|
||||
@if (noteKeys().length > 1) {
|
||||
<div class="notes-selector-container">
|
||||
<div class="notes-selector-list">
|
||||
@for (key of noteKeys(); track trackNoteKey($index, key)) {
|
||||
<button
|
||||
class="note-selector-pill"
|
||||
[class.active]="agentService.selectedNoteKey() === key"
|
||||
(click)="selectNote(key)"
|
||||
>
|
||||
<span class="material-symbols-outlined pill-icon">draft</span>
|
||||
{{ key }}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div *ngIf="noteKeys().length === 0" class="empty-notes-container">
|
||||
<div class="empty-notes-icon">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
}
|
||||
@if (noteKeys().length === 0) {
|
||||
<div class="empty-notes-container">
|
||||
<div class="empty-notes-icon">
|
||||
<span class="material-symbols-outlined">description</span>
|
||||
</div>
|
||||
<h4 class="empty-notes-title">No Notes & Plans</h4>
|
||||
<p class="empty-notes-subtitle">No notes or structured plans were recorded for this session.</p>
|
||||
</div>
|
||||
<h4 class="empty-notes-title">No Notes & Plans</h4>
|
||||
<p class="empty-notes-subtitle">No notes or structured plans were recorded for this session.</p>
|
||||
</div>
|
||||
|
||||
}
|
||||
<!-- Notes Content Viewer (Clean card blocks matching the second screenshot style) -->
|
||||
<div class="note-viewer-cards" *ngIf="noteKeys().length > 0">
|
||||
<!-- Render Note Title if present -->
|
||||
<h2 class="note-title" *ngIf="parsedNote().title">{{ parsedNote().title }}</h2>
|
||||
|
||||
<!-- Render Milestones list as Text Blocks -->
|
||||
<div class="milestones-container" *ngIf="parsedNote().milestones.length > 0">
|
||||
<div
|
||||
*ngFor="let milestone of parsedNote().milestones; trackBy: trackMilestone"
|
||||
class="milestone-block"
|
||||
[ngClass]="milestone.type"
|
||||
>
|
||||
<!-- Milestone Title Row with status icon -->
|
||||
<div class="milestone-title-row">
|
||||
<ng-container [ngSwitch]="milestone.type">
|
||||
<span *ngSwitchCase="'checked'" class="material-symbols-outlined milestone-status-icon checked">check_box</span>
|
||||
<span *ngSwitchCase="'progress'" class="material-symbols-outlined milestone-status-icon progress">indeterminate_check_box</span>
|
||||
<span *ngSwitchCase="'unchecked'" class="material-symbols-outlined milestone-status-icon unchecked">check_box_outline_blank</span>
|
||||
</ng-container>
|
||||
<div class="milestone-text">
|
||||
<span
|
||||
*ngFor="let seg of milestone.segments"
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
</div>
|
||||
@if (noteKeys().length > 0) {
|
||||
<div class="note-viewer-cards">
|
||||
<!-- Render Note Title if present -->
|
||||
@if (parsedNote().title) {
|
||||
<h2 class="note-title">{{ parsedNote().title }}</h2>
|
||||
}
|
||||
<!-- Render Milestones list as Text Blocks -->
|
||||
@if (parsedNote().milestones.length > 0) {
|
||||
<div class="milestones-container">
|
||||
@for (milestone of parsedNote().milestones; track trackMilestone($index, milestone)) {
|
||||
<div
|
||||
class="milestone-block"
|
||||
[ngClass]="milestone.type"
|
||||
>
|
||||
<!-- Milestone Title Row with status icon -->
|
||||
<div class="milestone-title-row">
|
||||
@switch (milestone.type) {
|
||||
@case ('checked') {
|
||||
<span class="material-symbols-outlined milestone-status-icon checked">check_box</span>
|
||||
}
|
||||
@case ('progress') {
|
||||
<span class="material-symbols-outlined milestone-status-icon progress">indeterminate_check_box</span>
|
||||
}
|
||||
@case ('unchecked') {
|
||||
<span class="material-symbols-outlined milestone-status-icon unchecked">check_box_outline_blank</span>
|
||||
}
|
||||
}
|
||||
<div class="milestone-text">
|
||||
@for (seg of milestone.segments; track seg) {
|
||||
<span
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<!-- Sub-steps list if any -->
|
||||
@if (milestone.subSteps.length > 0) {
|
||||
<div class="milestone-substeps">
|
||||
@for (step of milestone.subSteps; track trackMarkdownLine($index, step)) {
|
||||
<div
|
||||
class="substep-row"
|
||||
[ngClass]="step.type"
|
||||
[style.padding-left.px]="(step.indent > 2 ? (step.indent - 2) * 12 : 0)"
|
||||
>
|
||||
@switch (step.type) {
|
||||
@case ('checked') {
|
||||
<span class="material-symbols-outlined check-icon checked">check_box</span>
|
||||
}
|
||||
@case ('progress') {
|
||||
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
|
||||
}
|
||||
@case ('unchecked') {
|
||||
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
|
||||
}
|
||||
@case ('list-item') {
|
||||
<span class="bullet">•</span>
|
||||
}
|
||||
}
|
||||
@if (step.type !== 'empty') {
|
||||
<span class="line-text">
|
||||
@for (seg of step.segments; track seg) {
|
||||
<span
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Sub-steps list if any -->
|
||||
<div class="milestone-substeps" *ngIf="milestone.subSteps.length > 0">
|
||||
<div
|
||||
*ngFor="let step of milestone.subSteps; trackBy: trackMarkdownLine"
|
||||
class="substep-row"
|
||||
[ngClass]="step.type"
|
||||
[style.padding-left.px]="(step.indent > 2 ? (step.indent - 2) * 12 : 0)"
|
||||
>
|
||||
<ng-container [ngSwitch]="step.type">
|
||||
<span *ngSwitchCase="'checked'" class="material-symbols-outlined check-icon checked">check_box</span>
|
||||
<span *ngSwitchCase="'progress'" class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
|
||||
<span *ngSwitchCase="'unchecked'" class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
|
||||
<span *ngSwitchCase="'list-item'" class="bullet">•</span>
|
||||
</ng-container>
|
||||
<span class="line-text" *ngIf="step.type !== 'empty'">
|
||||
<span
|
||||
*ngFor="let seg of step.segments"
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Render any other lines (e.g. text/markdown blocks at the end) -->
|
||||
@if (parsedNote().otherLines.length > 0) {
|
||||
<div class="other-lines-container">
|
||||
@for (line of parsedNote().otherLines; track trackMarkdownLine($index, line)) {
|
||||
<div
|
||||
class="md-line"
|
||||
[ngClass]="line.type"
|
||||
[style.margin-left.px]="line.indent * 12"
|
||||
>
|
||||
@switch (line.type) {
|
||||
@case ('checked') {
|
||||
<span class="material-symbols-outlined check-icon checked">check_box</span>
|
||||
}
|
||||
@case ('progress') {
|
||||
<span class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
|
||||
}
|
||||
@case ('unchecked') {
|
||||
<span class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
|
||||
}
|
||||
@case ('list-item') {
|
||||
<span class="bullet">•</span>
|
||||
}
|
||||
}
|
||||
@if (line.type !== 'empty') {
|
||||
<span class="line-text">
|
||||
@for (seg of line.segments; track seg) {
|
||||
<span
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
}
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<!-- Render any other lines (e.g. text/markdown blocks at the end) -->
|
||||
<div class="other-lines-container" *ngIf="parsedNote().otherLines.length > 0">
|
||||
<div
|
||||
*ngFor="let line of parsedNote().otherLines; trackBy: trackMarkdownLine"
|
||||
class="md-line"
|
||||
[ngClass]="line.type"
|
||||
[style.margin-left.px]="line.indent * 12"
|
||||
>
|
||||
<ng-container [ngSwitch]="line.type">
|
||||
<span *ngSwitchCase="'checked'" class="material-symbols-outlined check-icon checked">check_box</span>
|
||||
<span *ngSwitchCase="'progress'" class="material-symbols-outlined check-icon progress">indeterminate_check_box</span>
|
||||
<span *ngSwitchCase="'unchecked'" class="material-symbols-outlined check-icon unchecked">check_box_outline_blank</span>
|
||||
<span *ngSwitchCase="'list-item'" class="bullet">•</span>
|
||||
</ng-container>
|
||||
<span class="line-text" *ngIf="line.type !== 'empty'">
|
||||
<span
|
||||
*ngFor="let seg of line.segments"
|
||||
[class.bold-segment]="seg.bold"
|
||||
[class.code-segment]="seg.code"
|
||||
>{{ seg.text }}</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-container>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, inject, computed } from '@angular/core';
|
||||
import { Component, ChangeDetectionStrategy, inject, computed, signal } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
@@ -29,14 +29,20 @@ export type { MarkdownSegment, MarkdownLine, NoteMilestone, ParsedNote };
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './chat-interface.component.html',
|
||||
styleUrl: './chat-interface.component.scss'
|
||||
styleUrl: './chat-interface.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class ChatInterfaceComponent {
|
||||
public agentService = inject(AgentService);
|
||||
|
||||
public taskInput: string = '';
|
||||
public isSubmitting: boolean = false;
|
||||
public errorMessage: string | null = null;
|
||||
// Signals so async completion handlers refresh this OnPush view.
|
||||
public isSubmitting = signal<boolean>(false);
|
||||
public errorMessage = signal<string | null>(null);
|
||||
|
||||
// Device-serial resolution can require a JSON.parse of device_info; memoize
|
||||
// it per session object so template re-evaluation stays cheap.
|
||||
private deviceSerialCache = new WeakMap<Session, string | null>();
|
||||
|
||||
/**
|
||||
* Filtered computed list of active tasks (running or pending) sorted by status and submission order
|
||||
@@ -76,23 +82,23 @@ export class ChatInterfaceComponent {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
|
||||
this.agentService.runTask(goal).subscribe({
|
||||
next: (res) => {
|
||||
this.taskInput = '';
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
// Fetch status to refresh sessions list and select new session
|
||||
this.agentService.fetchStatus();
|
||||
},
|
||||
error: (err) => {
|
||||
console.error('Failed to submit task:', err);
|
||||
this.isSubmitting = false;
|
||||
this.errorMessage = err.error?.detail || 'The runner is busy. Please wait for the current task to finish.';
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || 'The runner is busy. Please wait for the current task to finish.');
|
||||
// Auto-dismiss error banner after 5 seconds
|
||||
setTimeout(() => {
|
||||
this.errorMessage = null;
|
||||
this.errorMessage.set(null);
|
||||
}, 5000);
|
||||
}
|
||||
});
|
||||
@@ -103,11 +109,11 @@ export class ChatInterfaceComponent {
|
||||
*/
|
||||
public stopTask(sessionId: string, event: MouseEvent): void {
|
||||
event.stopPropagation();
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.agentService.stopTask(sessionId, false);
|
||||
setTimeout(() => {
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
@@ -115,11 +121,11 @@ export class ChatInterfaceComponent {
|
||||
* Stop the currently running task and optionally clear the backend queue
|
||||
*/
|
||||
public stopTasks(stopAll: boolean = false): void {
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.agentService.stopTask(stopAll);
|
||||
setTimeout(() => {
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
}, 400);
|
||||
}
|
||||
|
||||
@@ -130,16 +136,16 @@ export class ChatInterfaceComponent {
|
||||
if (!confirm('Are you sure you want to clear all tasks and history? This cannot be undone.')) {
|
||||
return;
|
||||
}
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.agentService.clearAllHistory().subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error('Failed to clear history:', err);
|
||||
this.isSubmitting = false;
|
||||
this.errorMessage = err.error?.detail || 'Failed to clear history.';
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || 'Failed to clear history.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -152,16 +158,16 @@ export class ChatInterfaceComponent {
|
||||
if (!confirm(`Are you sure you want to delete this task? This cannot be undone.`)) {
|
||||
return;
|
||||
}
|
||||
this.isSubmitting = true;
|
||||
this.errorMessage = null;
|
||||
this.isSubmitting.set(true);
|
||||
this.errorMessage.set(null);
|
||||
this.agentService.deleteSession(sessionId).subscribe({
|
||||
next: () => {
|
||||
this.isSubmitting = false;
|
||||
this.isSubmitting.set(false);
|
||||
},
|
||||
error: (err: any) => {
|
||||
console.error(`Failed to delete task ${sessionId}:`, err);
|
||||
this.isSubmitting = false;
|
||||
this.errorMessage = err.error?.detail || 'Failed to delete task.';
|
||||
this.isSubmitting.set(false);
|
||||
this.errorMessage.set(err.error?.detail || 'Failed to delete task.');
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -189,22 +195,26 @@ export class ChatInterfaceComponent {
|
||||
* Determine the device serial number for the session
|
||||
*/
|
||||
public getDeviceSerial(session: Session): string | null {
|
||||
if (this.deviceSerialCache.has(session)) {
|
||||
return this.deviceSerialCache.get(session) ?? null;
|
||||
}
|
||||
let resolved: string | null = null;
|
||||
const serial = session.device_serial || session.device_id;
|
||||
if (serial && serial !== 'pending' && serial !== 'null' && serial !== 'undefined') {
|
||||
return serial;
|
||||
}
|
||||
if (session.device_info) {
|
||||
resolved = serial;
|
||||
} else if (session.device_info) {
|
||||
try {
|
||||
const info = typeof session.device_info === 'string' ? JSON.parse(session.device_info) : session.device_info;
|
||||
const s = info?.device_id || info?.device_serial;
|
||||
if (s && s !== 'pending' && s !== 'null' && s !== 'undefined') {
|
||||
return s;
|
||||
resolved = s;
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return null;
|
||||
this.deviceSerialCache.set(session, resolved);
|
||||
return resolved;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+414
-411
@@ -1,430 +1,433 @@
|
||||
<!--
|
||||
Copyright 2026 Google LLC
|
||||
Copyright 2026 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<!-- Backdrop overlay only when in theater / modal mode -->
|
||||
<div
|
||||
class="theater-backdrop"
|
||||
*ngIf="agentService.isVideoWindowOpen() && isTheaterMode()"
|
||||
(click)="isTheaterMode.set(false)"
|
||||
></div>
|
||||
@if (agentService.isVideoWindowOpen() && isTheaterMode()) {
|
||||
<div
|
||||
class="theater-backdrop"
|
||||
(click)="isTheaterMode.set(false)"
|
||||
></div>
|
||||
}
|
||||
|
||||
<div
|
||||
class="floating-video-wrapper"
|
||||
*ngIf="agentService.isVideoWindowOpen()"
|
||||
[class.minimized]="agentService.isVideoMinimized()"
|
||||
[class.theater]="isTheaterMode()"
|
||||
[style.left.px]="!isTheaterMode() ? posX() : null"
|
||||
[style.top.px]="!isTheaterMode() ? posY() : null"
|
||||
>
|
||||
<!-- Window Header (Draggable Handle) -->
|
||||
<header class="window-header" (mousedown)="startDrag($event)">
|
||||
<div class="header-left">
|
||||
<span class="material-symbols-outlined header-icon">
|
||||
{{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.playerMode() === 'steps' ? 'slideshow' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display')) }}
|
||||
</span>
|
||||
<span class="header-title" [title]="agentService.activeVideoTitle()">
|
||||
{{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.playerMode() === 'steps' ? ('Step Replay (' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording'))) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
||||
<!-- Mode Toggle Group: Video vs. Step Replay -->
|
||||
<div class="mode-toggle-group" *ngIf="agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()">
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'video'"
|
||||
(click)="agentService.setPlayerMode('video')"
|
||||
title="Video Recording"
|
||||
>
|
||||
<span class="material-symbols-outlined mode-icon">videocam</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'steps'"
|
||||
(click)="agentService.setPlayerMode('steps')"
|
||||
title="Step Screenshot Replay"
|
||||
>
|
||||
<span class="material-symbols-outlined mode-icon">slideshow</span>
|
||||
<span>Steps</span>
|
||||
</button>
|
||||
@if (agentService.isVideoWindowOpen()) {
|
||||
<div
|
||||
class="floating-video-wrapper"
|
||||
[class.minimized]="agentService.isVideoMinimized()"
|
||||
[class.theater]="isTheaterMode()"
|
||||
[style.left.px]="!isTheaterMode() ? posX() : null"
|
||||
[style.top.px]="!isTheaterMode() ? posY() : null"
|
||||
>
|
||||
<!-- Window Header (Draggable Handle) -->
|
||||
<header class="window-header" (mousedown)="startDrag($event)">
|
||||
<div class="header-left">
|
||||
<span class="material-symbols-outlined header-icon">
|
||||
{{ agentService.recordingPlaybackStatus() === 'live' ? 'sensors' : (agentService.playerMode() === 'steps' ? 'slideshow' : (agentService.recordingPlaybackStatus() === 'processing' ? 'progress_activity' : 'smart_display')) }}
|
||||
</span>
|
||||
<span class="header-title" [title]="agentService.activeVideoTitle()">
|
||||
{{ agentService.recordingPlaybackStatus() === 'live' ? 'Live Device Screen' : (agentService.playerMode() === 'steps' ? ('Step Replay (' + (totalStepFrames() > 0 ? (activeStepIndex() + 1) + '/' + totalStepFrames() : '0') + ')') : (agentService.recordingPlaybackStatus() === 'processing' ? 'Preparing Recording' : (agentService.activeVideoTitle() || 'Screen Recording'))) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Minimize / Restore Toggle -->
|
||||
<button
|
||||
class="header-btn"
|
||||
(click)="toggleMinimize()"
|
||||
[title]="agentService.isVideoMinimized() ? 'Restore' : 'Minimize'"
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<span class="material-symbols-outlined">{{ agentService.isVideoMinimized() ? 'expand_less' : 'remove' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Theater / Fullscreen Toggle -->
|
||||
<button
|
||||
class="header-btn"
|
||||
(click)="toggleTheater()"
|
||||
[title]="isTheaterMode() ? 'Exit Theater' : 'Theater Mode'"
|
||||
*ngIf="!agentService.isVideoMinimized()"
|
||||
aria-label="Theater"
|
||||
>
|
||||
<span class="material-symbols-outlined">{{ isTheaterMode() ? 'close_fullscreen' : 'open_in_full' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Close Window -->
|
||||
<button
|
||||
class="header-btn close-btn"
|
||||
(click)="agentService.closeVideoPlayer()"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Video Main Content Area -->
|
||||
<div class="video-body" *ngIf="!agentService.isVideoMinimized()">
|
||||
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
||||
<ng-container *ngIf="agentService.recordingPlaybackStatus() === 'live'">
|
||||
<div class="live-stream-container" *ngIf="!liveStreamError()">
|
||||
<img
|
||||
class="live-screen-img"
|
||||
[src]="liveStreamUrl()"
|
||||
(error)="onLiveStreamError()"
|
||||
alt="Live Device Screen"
|
||||
/>
|
||||
<div class="live-status-overlay">
|
||||
<div class="live-badge">
|
||||
<span class="pulse-dot"></span>
|
||||
<span>LIVE</span>
|
||||
<div class="header-actions" (mousedown)="$event.stopPropagation()">
|
||||
<!-- Mode Toggle Group: Video vs. Step Replay -->
|
||||
@if (agentService.recordingPlaybackStatus() === 'ready' && agentService.hasCurrentSessionStepFrames() && !agentService.isVideoMinimized()) {
|
||||
<div class="mode-toggle-group">
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'video'"
|
||||
(click)="agentService.setPlayerMode('video')"
|
||||
title="Video Recording"
|
||||
>
|
||||
<span class="material-symbols-outlined mode-icon">videocam</span>
|
||||
<span>Video</span>
|
||||
</button>
|
||||
<button
|
||||
class="mode-btn"
|
||||
[class.active]="agentService.playerMode() === 'steps'"
|
||||
(click)="agentService.setPlayerMode('steps')"
|
||||
title="Step Screenshot Replay"
|
||||
>
|
||||
<span class="material-symbols-outlined mode-icon">slideshow</span>
|
||||
<span>Steps</span>
|
||||
</button>
|
||||
</div>
|
||||
<button class="live-refresh-btn" (click)="retryLiveStream()" title="Refresh Live Stream">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="empty-video-fallback" *ngIf="liveStreamError()">
|
||||
<div class="fallback-icon-wrap">
|
||||
<span class="material-symbols-outlined fallback-icon">phonelink_off</span>
|
||||
</div>
|
||||
<h4 class="fallback-title">Device Stream Connecting</h4>
|
||||
<p class="fallback-desc">Waiting for ADB device screen stream...</p>
|
||||
<button class="retry-btn" (click)="retryLiveStream()">
|
||||
<span class="material-symbols-outlined">refresh</span> Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- 2. RECORDING PLAYBACK / STEP REPLAY (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
|
||||
<ng-container *ngIf="agentService.recordingPlaybackStatus() !== 'live'">
|
||||
<!-- Loading State (when in video mode and processing) -->
|
||||
<div class="video-loading-state" *ngIf="agentService.playerMode() === 'video' && (agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading())">
|
||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||
<span>{{ agentService.recordingPlaybackMessage() || 'Preparing screen recording...' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Option A: STEP REPLAY VIEW (ACTIVE IN STEP MODE) -->
|
||||
<ng-container *ngIf="agentService.playerMode() === 'steps' && totalStepFrames() > 0">
|
||||
<div class="step-replay-container">
|
||||
<!-- Fallback info banner if recording had failed -->
|
||||
<div class="step-replay-notice" *ngIf="agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()">
|
||||
<span class="material-symbols-outlined notice-icon">info</span>
|
||||
<span>Screen recording unavailable. Replaying step-by-step screenshots.</span>
|
||||
</div>
|
||||
|
||||
<!-- Step Image Viewport with Action Coordinates Overlay -->
|
||||
<div
|
||||
class="step-image-viewport"
|
||||
(mouseenter)="onViewportMouseEnter()"
|
||||
(mouseleave)="onViewportMouseLeave()"
|
||||
}
|
||||
<!-- Minimize / Restore Toggle -->
|
||||
<button
|
||||
class="header-btn"
|
||||
(click)="toggleMinimize()"
|
||||
[title]="agentService.isVideoMinimized() ? 'Restore' : 'Minimize'"
|
||||
aria-label="Minimize"
|
||||
>
|
||||
<!-- Loading Skeleton (Clean Light Spinner) -->
|
||||
<div class="step-image-loading-state" *ngIf="isStepImageLoading()">
|
||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||
<span>Loading screenshot...</span>
|
||||
</div>
|
||||
|
||||
<!-- Error / Missing Image Fallback -->
|
||||
<div class="step-image-error-state" *ngIf="stepImageError() && !isStepImageLoading()">
|
||||
<span class="material-symbols-outlined broken-icon">broken_image</span>
|
||||
<span>Screenshot preview unavailable for this step</span>
|
||||
</div>
|
||||
|
||||
<!-- Device Screen Frame & Action Coordinates Overlay -->
|
||||
<div class="step-image-frame-wrap" *ngIf="!stepImageError()">
|
||||
<img
|
||||
#stepImgRef
|
||||
class="step-frame-img"
|
||||
[src]="currentStepImageUrl()"
|
||||
[alt]="currentStepFrame()?.title"
|
||||
(load)="onStepImageLoad()"
|
||||
(error)="onStepImageError()"
|
||||
(click)="openImageInNewTab()"
|
||||
title="Click to view image in new tab"
|
||||
/>
|
||||
|
||||
<!-- Action Visual Coordinates Overlay (Touch point, swipe arrow, sequence path) -->
|
||||
<div
|
||||
#stepOverlayRef
|
||||
class="overlay-layer"
|
||||
[style.display]="viewMode() === 'post' ? 'none' : 'block'"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Hideable Floating Detail Card (Appears on hover or when pinned) -->
|
||||
<div
|
||||
class="step-frame-badge"
|
||||
*ngIf="currentStepFrame() as frame"
|
||||
[class.visible]="isCardVisible()"
|
||||
(click)="$event.stopPropagation()"
|
||||
<span class="material-symbols-outlined">{{ agentService.isVideoMinimized() ? 'expand_less' : 'remove' }}</span>
|
||||
</button>
|
||||
<!-- Theater / Fullscreen Toggle -->
|
||||
@if (!agentService.isVideoMinimized()) {
|
||||
<button
|
||||
class="header-btn"
|
||||
(click)="toggleTheater()"
|
||||
[title]="isTheaterMode() ? 'Exit Theater' : 'Theater Mode'"
|
||||
aria-label="Theater"
|
||||
>
|
||||
<div class="badge-content-left">
|
||||
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
|
||||
<span class="badge-action-desc" [title]="frame.actionText || frame.title">
|
||||
<span class="material-symbols-outlined action-type-icon" *ngIf="frame.action">
|
||||
{{ getStepActionIcon(frame.action) }}
|
||||
</span>
|
||||
{{ frame.actionText || frame.title }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="badge-actions-right">
|
||||
<!-- Pre/Post Screenshot Switcher (Only if post-screenshot is available) -->
|
||||
<div class="pre-post-toggle" *ngIf="frame.postImageUrl">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'pre'"
|
||||
(click)="setStepViewMode('pre', $event)"
|
||||
title="View screen before action (with touch coordinates)"
|
||||
>
|
||||
Pre
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'post'"
|
||||
(click)="setStepViewMode('post', $event)"
|
||||
title="View resulting screen after action executed"
|
||||
>
|
||||
Post
|
||||
<span class="material-symbols-outlined">{{ isTheaterMode() ? 'close_fullscreen' : 'open_in_full' }}</span>
|
||||
</button>
|
||||
}
|
||||
<!-- Close Window -->
|
||||
<button
|
||||
class="header-btn close-btn"
|
||||
(click)="agentService.closeVideoPlayer()"
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
>
|
||||
<span class="material-symbols-outlined">close</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<!-- Video Main Content Area -->
|
||||
@if (!agentService.isVideoMinimized()) {
|
||||
<div class="video-body">
|
||||
<!-- 1. LIVE SCREEN STREAM (AUTOMATICALLY ACTIVE DURING RUNNING TEST) -->
|
||||
@if (agentService.recordingPlaybackStatus() === 'live') {
|
||||
@if (!liveStreamError()) {
|
||||
<div class="live-stream-container">
|
||||
<img
|
||||
class="live-screen-img"
|
||||
[src]="liveStreamUrl()"
|
||||
(error)="onLiveStreamError()"
|
||||
alt="Live Device Screen"
|
||||
/>
|
||||
<div class="live-status-overlay">
|
||||
<div class="live-badge">
|
||||
<span class="pulse-dot"></span>
|
||||
<span>LIVE</span>
|
||||
</div>
|
||||
<button class="live-refresh-btn" (click)="retryLiveStream()" title="Refresh Live Stream">
|
||||
<span class="material-symbols-outlined">refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Pin Card Button -->
|
||||
<button
|
||||
class="pin-card-btn"
|
||||
[class.pinned]="isCardPinned()"
|
||||
(click)="toggleCardPin($event)"
|
||||
[title]="isCardPinned() ? 'Unpin details card (auto-hide on mouse leave)' : 'Pin details card (keep always visible)'"
|
||||
>
|
||||
<span class="material-symbols-outlined">{{ isCardPinned() ? 'push_pin' : 'keep' }}</span>
|
||||
</div>
|
||||
}
|
||||
@if (liveStreamError()) {
|
||||
<div class="empty-video-fallback">
|
||||
<div class="fallback-icon-wrap">
|
||||
<span class="material-symbols-outlined fallback-icon">phonelink_off</span>
|
||||
</div>
|
||||
<h4 class="fallback-title">Device Stream Connecting</h4>
|
||||
<p class="fallback-desc">Waiting for ADB device screen stream...</p>
|
||||
<button class="retry-btn" (click)="retryLiveStream()">
|
||||
<span class="material-symbols-outlined">refresh</span> Reconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step Replay Controls Footer -->
|
||||
<div class="video-controls-footer">
|
||||
<!-- Timeline Scrubber Bar with Step Ticks -->
|
||||
<div class="scrubber-bar-container">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="totalStepFrames() - 1"
|
||||
[step]="1"
|
||||
[value]="activeStepIndex()"
|
||||
(input)="onStepScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="stepProgressPercent()"></div>
|
||||
<!-- Step Tick Markers -->
|
||||
<div class="step-ticks-wrapper" *ngIf="totalStepFrames() > 1">
|
||||
<span
|
||||
*ngFor="let f of stepFrames(); let i = index"
|
||||
class="step-tick"
|
||||
[class.active]="i === activeStepIndex()"
|
||||
[class.passed]="i < activeStepIndex()"
|
||||
[style.left.%]="(i / (totalStepFrames() - 1)) * 100"
|
||||
(click)="seekStepFrame(i); $event.stopPropagation()"
|
||||
[title]="'Step ' + (i + 1) + ': ' + (f.actionText || f.title)"
|
||||
></span>
|
||||
}
|
||||
}
|
||||
<!-- 2. RECORDING PLAYBACK / STEP REPLAY (AUTOMATICALLY ACTIVE WHEN TEST FINISHED) -->
|
||||
@if (agentService.recordingPlaybackStatus() !== 'live') {
|
||||
<!-- Loading State (when in video mode and processing) -->
|
||||
@if (agentService.playerMode() === 'video' && (agentService.recordingPlaybackStatus() === 'processing' || agentService.isVideoLoading())) {
|
||||
<div class="video-loading-state">
|
||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||
<span>{{ agentService.recordingPlaybackMessage() || 'Preparing screen recording...' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Controls Row -->
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<!-- Play / Pause Button -->
|
||||
<button class="ctrl-btn play-btn" (click)="toggleStepPlay()" [title]="isStepPlaying() ? 'Pause' : 'Play Slideshow'">
|
||||
<span class="material-symbols-outlined">{{ isStepPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Previous Frame Button -->
|
||||
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Step">
|
||||
<span class="material-symbols-outlined">skip_previous</span>
|
||||
</button>
|
||||
|
||||
<!-- Next Frame Button -->
|
||||
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Step">
|
||||
<span class="material-symbols-outlined">skip_next</span>
|
||||
</button>
|
||||
|
||||
<!-- Step Replay / Reset -->
|
||||
<button class="ctrl-btn" (click)="restartStep()" title="Replay from start">
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
|
||||
<!-- Step Counter Display -->
|
||||
<span class="time-display">
|
||||
Step {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
|
||||
</span>
|
||||
}
|
||||
<!-- Option A: STEP REPLAY VIEW (ACTIVE IN STEP MODE) -->
|
||||
@if (agentService.playerMode() === 'steps' && totalStepFrames() > 0) {
|
||||
<div class="step-replay-container">
|
||||
<!-- Fallback info banner if recording had failed -->
|
||||
@if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) {
|
||||
<div class="step-replay-notice">
|
||||
<span class="material-symbols-outlined notice-icon">info</span>
|
||||
<span>Screen recording unavailable. Replaying step-by-step screenshots.</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Step Image Viewport with Action Coordinates Overlay -->
|
||||
<div
|
||||
class="step-image-viewport"
|
||||
(mouseenter)="onViewportMouseEnter()"
|
||||
(mouseleave)="onViewportMouseLeave()"
|
||||
>
|
||||
<!-- Loading Skeleton (Clean Light Spinner) -->
|
||||
@if (isStepImageLoading()) {
|
||||
<div class="step-image-loading-state">
|
||||
<span class="material-symbols-outlined spinning-icon">progress_activity</span>
|
||||
<span>Loading screenshot...</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Error / Missing Image Fallback -->
|
||||
@if (stepImageError() && !isStepImageLoading()) {
|
||||
<div class="step-image-error-state">
|
||||
<span class="material-symbols-outlined broken-icon">broken_image</span>
|
||||
<span>Screenshot preview unavailable for this step</span>
|
||||
</div>
|
||||
}
|
||||
<!-- Device Screen Frame & Action Coordinates Overlay -->
|
||||
@if (!stepImageError()) {
|
||||
<div class="step-image-frame-wrap">
|
||||
<img
|
||||
#stepImgRef
|
||||
class="step-frame-img"
|
||||
[src]="currentStepImageUrl()"
|
||||
[alt]="$safeNavigationMigration(currentStepFrame()?.title)"
|
||||
(load)="onStepImageLoad()"
|
||||
(error)="onStepImageError()"
|
||||
(click)="openImageInNewTab()"
|
||||
title="Click to view image in new tab"
|
||||
/>
|
||||
<!-- Action Visual Coordinates Overlay (Touch point, swipe arrow, sequence path) -->
|
||||
<div
|
||||
#stepOverlayRef
|
||||
class="overlay-layer"
|
||||
[style.display]="viewMode() === 'post' ? 'none' : 'block'"
|
||||
></div>
|
||||
</div>
|
||||
}
|
||||
<!-- Hideable Floating Detail Card (Appears on hover or when pinned) -->
|
||||
@if (currentStepFrame(); as frame) {
|
||||
<div
|
||||
class="step-frame-badge"
|
||||
[class.visible]="isCardVisible()"
|
||||
(click)="$event.stopPropagation()"
|
||||
>
|
||||
<div class="badge-content-left">
|
||||
<span class="badge-step-no">Step {{ frame.stepNumber }}</span>
|
||||
<span class="badge-action-desc" [title]="frame.actionText || frame.title">
|
||||
@if (frame.action) {
|
||||
<span class="material-symbols-outlined action-type-icon">
|
||||
{{ getStepActionIcon(frame.action) }}
|
||||
</span>
|
||||
}
|
||||
{{ frame.actionText || frame.title }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="badge-actions-right">
|
||||
<!-- Pre/Post Screenshot Switcher (Only if post-screenshot is available) -->
|
||||
@if (frame.postImageUrl) {
|
||||
<div class="pre-post-toggle">
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'pre'"
|
||||
(click)="setStepViewMode('pre', $event)"
|
||||
title="View screen before action (with touch coordinates)"
|
||||
>
|
||||
Pre
|
||||
</button>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
[class.active]="viewMode() === 'post'"
|
||||
(click)="setStepViewMode('post', $event)"
|
||||
title="View resulting screen after action executed"
|
||||
>
|
||||
Post
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<!-- Pin Card Button -->
|
||||
<button
|
||||
class="pin-card-btn"
|
||||
[class.pinned]="isCardPinned()"
|
||||
(click)="toggleCardPin($event)"
|
||||
[title]="isCardPinned() ? 'Unpin details card (auto-hide on mouse leave)' : 'Pin details card (keep always visible)'"
|
||||
>
|
||||
<span class="material-symbols-outlined">{{ isCardPinned() ? 'push_pin' : 'keep' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Step Replay Controls Footer -->
|
||||
<div class="video-controls-footer">
|
||||
<!-- Timeline Scrubber Bar with Step Ticks -->
|
||||
<div class="scrubber-bar-container">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="totalStepFrames() - 1"
|
||||
[step]="1"
|
||||
[value]="activeStepIndex()"
|
||||
(input)="onStepScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="stepProgressPercent()"></div>
|
||||
<!-- Step Tick Markers -->
|
||||
@if (totalStepFrames() > 1) {
|
||||
<div class="step-ticks-wrapper">
|
||||
@for (f of stepFrames(); track trackStepFrame(i, f); let i = $index) {
|
||||
<span
|
||||
class="step-tick"
|
||||
[class.active]="i === activeStepIndex()"
|
||||
[class.passed]="i < activeStepIndex()"
|
||||
[style.left.%]="(i / (totalStepFrames() - 1)) * 100"
|
||||
(click)="seekStepFrame(i); $event.stopPropagation()"
|
||||
[title]="'Step ' + (i + 1) + ': ' + (f.actionText || f.title)"
|
||||
></span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<!-- Bottom Controls Row -->
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<!-- Play / Pause Button -->
|
||||
<button class="ctrl-btn play-btn" (click)="toggleStepPlay()" [title]="isStepPlaying() ? 'Pause' : 'Play Slideshow'">
|
||||
<span class="material-symbols-outlined">{{ isStepPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
<!-- Previous Frame Button -->
|
||||
<button class="ctrl-btn" (click)="prevStepFrame()" [disabled]="activeStepIndex() <= 0" title="Previous Step">
|
||||
<span class="material-symbols-outlined">skip_previous</span>
|
||||
</button>
|
||||
<!-- Next Frame Button -->
|
||||
<button class="ctrl-btn" (click)="nextStepFrame()" [disabled]="activeStepIndex() >= totalStepFrames() - 1" title="Next Step">
|
||||
<span class="material-symbols-outlined">skip_next</span>
|
||||
</button>
|
||||
<!-- Step Replay / Reset -->
|
||||
<button class="ctrl-btn" (click)="restartStep()" title="Replay from start">
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
<!-- Step Counter Display -->
|
||||
<span class="time-display">
|
||||
Step {{ activeStepIndex() + 1 }} / {{ totalStepFrames() }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||||
<button
|
||||
class="ctrl-btn speed-pill-btn"
|
||||
(click)="cycleSpeed()"
|
||||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||||
>
|
||||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||||
</button>
|
||||
<!-- Loop Toggle -->
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||||
>
|
||||
<span class="material-symbols-outlined">repeat</span>
|
||||
</button>
|
||||
<!-- Open in new tab -->
|
||||
<button class="ctrl-btn" (click)="openImageInNewTab()" title="Open image in new tab">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Option B: ACTIVE VIDEO PLAYBACK VIEW -->
|
||||
@if (agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl(); as videoUrl) {
|
||||
@if (!videoLoadError()) {
|
||||
<div class="video-container">
|
||||
<video
|
||||
#videoRef
|
||||
[src]="currentVideoUrl()"
|
||||
playsinline
|
||||
(loadedmetadata)="onLoadedMetadata()"
|
||||
(timeupdate)="onTimeUpdate()"
|
||||
(play)="onPlay()"
|
||||
(pause)="onPause()"
|
||||
(ended)="onEnded()"
|
||||
(error)="onError()"
|
||||
(click)="togglePlay()"
|
||||
></video>
|
||||
</div>
|
||||
}
|
||||
<!-- Dedicated Bottom Controls Bar for Video -->
|
||||
@if (!videoLoadError()) {
|
||||
<div class="video-controls-footer">
|
||||
<!-- Timeline Scrubber Bar -->
|
||||
<div class="scrubber-bar-container">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="duration() || 100"
|
||||
[step]="0.1"
|
||||
[value]="currentTime()"
|
||||
(input)="onScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||
</div>
|
||||
<!-- Bottom Controls Row -->
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<!-- Play / Pause Button -->
|
||||
<button class="ctrl-btn play-btn" (click)="togglePlay()" [title]="isPlaying() ? 'Pause' : 'Play'">
|
||||
<span class="material-symbols-outlined">{{ isPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
<!-- Replay / Reset -->
|
||||
<button class="ctrl-btn" (click)="restart()" title="Replay from start">
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
<!-- Time Display -->
|
||||
<span class="time-display">
|
||||
{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="controls-right">
|
||||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||||
<button
|
||||
class="ctrl-btn speed-pill-btn"
|
||||
(click)="cycleSpeed()"
|
||||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||||
>
|
||||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||||
</button>
|
||||
<!-- Loop Toggle -->
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||||
>
|
||||
<span class="material-symbols-outlined">repeat</span>
|
||||
</button>
|
||||
<!-- Mute / Unmute Toggle -->
|
||||
<button class="ctrl-btn" (click)="toggleMute()" [title]="isMuted() ? 'Unmute' : 'Mute'">
|
||||
<span class="material-symbols-outlined">{{ isMuted() ? 'volume_off' : 'volume_up' }}</span>
|
||||
</button>
|
||||
<!-- Open in new tab -->
|
||||
<button class="ctrl-btn" (click)="openInNewTab()" title="Open video in new tab">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
<!-- Option C: EMPTY / FALLBACK STATE (Only when no video AND no step frames) -->
|
||||
@if (!agentService.isVideoLoading() && (agentService.playerMode() === 'video' || totalStepFrames() === 0) && (agentService.recordingPlaybackStatus() === 'failed' || agentService.recordingPlaybackStatus() === 'unavailable' || videoLoadError()) && totalStepFrames() === 0) {
|
||||
<div class="empty-video-fallback">
|
||||
<div class="fallback-icon-wrap">
|
||||
<span class="material-symbols-outlined fallback-icon">videocam_off</span>
|
||||
</div>
|
||||
<h4 class="fallback-title">
|
||||
{{ agentService.recordingPlaybackStatus() === 'failed' || videoLoadError() ? 'Recording Playback Failed' : 'No Video Recording Available' }}
|
||||
</h4>
|
||||
<p class="fallback-desc">
|
||||
{{ videoLoadError() ? 'The finalized recording could not be played.' : (agentService.recordingPlaybackMessage() || 'No screen recording was found for this task.') }}
|
||||
</p>
|
||||
@if (agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()) {
|
||||
<button class="retry-btn" (click)="videoLoadError.set(false); agentService.retryVideoRecording()">
|
||||
<span class="material-symbols-outlined">refresh</span> Retry
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||||
<button
|
||||
class="ctrl-btn speed-pill-btn"
|
||||
(click)="cycleSpeed()"
|
||||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||||
>
|
||||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||||
</button>
|
||||
|
||||
<!-- Loop Toggle -->
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||||
>
|
||||
<span class="material-symbols-outlined">repeat</span>
|
||||
</button>
|
||||
|
||||
<!-- Open in new tab -->
|
||||
<button class="ctrl-btn" (click)="openImageInNewTab()" title="Open image in new tab">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Option B: ACTIVE VIDEO PLAYBACK VIEW -->
|
||||
<ng-container *ngIf="agentService.playerMode() === 'video' && agentService.recordingPlaybackStatus() === 'ready' && !agentService.isVideoLoading() && agentService.activeVideoUrl() as videoUrl">
|
||||
<div class="video-container" *ngIf="!videoLoadError()">
|
||||
<video
|
||||
#videoRef
|
||||
[src]="currentVideoUrl()"
|
||||
playsinline
|
||||
(loadedmetadata)="onLoadedMetadata()"
|
||||
(timeupdate)="onTimeUpdate()"
|
||||
(play)="onPlay()"
|
||||
(pause)="onPause()"
|
||||
(ended)="onEnded()"
|
||||
(error)="onError()"
|
||||
(click)="togglePlay()"
|
||||
></video>
|
||||
</div>
|
||||
|
||||
<!-- Dedicated Bottom Controls Bar for Video -->
|
||||
<div class="video-controls-footer" *ngIf="!videoLoadError()">
|
||||
<!-- Timeline Scrubber Bar -->
|
||||
<div class="scrubber-bar-container">
|
||||
<input
|
||||
type="range"
|
||||
class="scrubber-range"
|
||||
[min]="0"
|
||||
[max]="duration() || 100"
|
||||
[step]="0.1"
|
||||
[value]="currentTime()"
|
||||
(input)="onScrub($event)"
|
||||
/>
|
||||
<div class="scrubber-progress-fill" [style.width.%]="progressPercent()"></div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Controls Row -->
|
||||
<div class="controls-row">
|
||||
<div class="controls-left">
|
||||
<!-- Play / Pause Button -->
|
||||
<button class="ctrl-btn play-btn" (click)="togglePlay()" [title]="isPlaying() ? 'Pause' : 'Play'">
|
||||
<span class="material-symbols-outlined">{{ isPlaying() ? 'pause' : 'play_arrow' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Replay / Reset -->
|
||||
<button class="ctrl-btn" (click)="restart()" title="Replay from start">
|
||||
<span class="material-symbols-outlined">replay</span>
|
||||
</button>
|
||||
|
||||
<!-- Time Display -->
|
||||
<span class="time-display">
|
||||
{{ formatTime(currentTime()) }} / {{ formatTime(duration()) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="controls-right">
|
||||
<!-- Speed Button (Cycle 0.5x, 1x, 1.5x, 2x, 4x) -->
|
||||
<button
|
||||
class="ctrl-btn speed-pill-btn"
|
||||
(click)="cycleSpeed()"
|
||||
[title]="'Speed: ' + playbackRate() + 'x (Click to cycle)'"
|
||||
>
|
||||
<span class="speed-label">{{ playbackRate() }}x</span>
|
||||
</button>
|
||||
|
||||
<!-- Loop Toggle -->
|
||||
<button
|
||||
class="ctrl-btn"
|
||||
[class.active]="isLooping()"
|
||||
(click)="toggleLoop()"
|
||||
[title]="isLooping() ? 'Loop enabled' : 'Enable loop'"
|
||||
>
|
||||
<span class="material-symbols-outlined">repeat</span>
|
||||
</button>
|
||||
|
||||
<!-- Mute / Unmute Toggle -->
|
||||
<button class="ctrl-btn" (click)="toggleMute()" [title]="isMuted() ? 'Unmute' : 'Mute'">
|
||||
<span class="material-symbols-outlined">{{ isMuted() ? 'volume_off' : 'volume_up' }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Open in new tab -->
|
||||
<button class="ctrl-btn" (click)="openInNewTab()" title="Open video in new tab">
|
||||
<span class="material-symbols-outlined">open_in_new</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ng-container>
|
||||
|
||||
<!-- Option C: EMPTY / FALLBACK STATE (Only when no video AND no step frames) -->
|
||||
<div class="empty-video-fallback" *ngIf="!agentService.isVideoLoading() && (agentService.playerMode() === 'video' || totalStepFrames() === 0) && (agentService.recordingPlaybackStatus() === 'failed' || agentService.recordingPlaybackStatus() === 'unavailable' || videoLoadError()) && totalStepFrames() === 0">
|
||||
<div class="fallback-icon-wrap">
|
||||
<span class="material-symbols-outlined fallback-icon">videocam_off</span>
|
||||
</div>
|
||||
<h4 class="fallback-title">
|
||||
{{ agentService.recordingPlaybackStatus() === 'failed' || videoLoadError() ? 'Recording Playback Failed' : 'No Video Recording Available' }}
|
||||
</h4>
|
||||
<p class="fallback-desc">
|
||||
{{ videoLoadError() ? 'The finalized recording could not be played.' : (agentService.recordingPlaybackMessage() || 'No screen recording was found for this task.') }}
|
||||
</p>
|
||||
<button class="retry-btn" *ngIf="agentService.recordingPlaybackStatus() === 'failed' || videoLoadError()" (click)="videoLoadError.set(false); agentService.retryVideoRecording()">
|
||||
<span class="material-symbols-outlined">refresh</span> Retry
|
||||
</button>
|
||||
</div>
|
||||
</ng-container>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
+27
-10
@@ -16,6 +16,8 @@
|
||||
|
||||
import {
|
||||
Component,
|
||||
ChangeDetectionStrategy,
|
||||
NgZone,
|
||||
inject,
|
||||
signal,
|
||||
computed,
|
||||
@@ -25,7 +27,7 @@ import {
|
||||
effect,
|
||||
OnDestroy
|
||||
} from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
import { StepReplayFrame } from '../../core/models/stream.model';
|
||||
@@ -35,12 +37,14 @@ import { getActionIcon } from '../../utils/action-formatter.util';
|
||||
@Component({
|
||||
selector: 'app-floating-video-player',
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
imports: [FormsModule],
|
||||
templateUrl: './floating-video-player.component.html',
|
||||
styleUrl: './floating-video-player.component.scss'
|
||||
styleUrl: './floating-video-player.component.scss',
|
||||
changeDetection: ChangeDetectionStrategy.OnPush
|
||||
})
|
||||
export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
public agentService = inject(AgentService);
|
||||
private zone = inject(NgZone);
|
||||
|
||||
@ViewChild('videoRef') videoRef?: ElementRef<HTMLVideoElement>;
|
||||
@ViewChild('stepImgRef') stepImgRef?: ElementRef<HTMLImageElement>;
|
||||
@@ -217,7 +221,9 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
});
|
||||
|
||||
/**
|
||||
* Start dragging the window from header
|
||||
* Start dragging the window from header. The move/up listeners are attached
|
||||
* only for the duration of the drag and live outside the Angular zone, so
|
||||
* ordinary mouse movement over the page never touches change detection.
|
||||
*/
|
||||
public startDrag(event: MouseEvent): void {
|
||||
if (this.isTheaterMode()) return;
|
||||
@@ -227,10 +233,14 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
this.initialPosX = this.posX();
|
||||
this.initialPosY = this.posY();
|
||||
event.preventDefault();
|
||||
|
||||
this.zone.runOutsideAngular(() => {
|
||||
document.addEventListener('mousemove', this.onDrag);
|
||||
document.addEventListener('mouseup', this.stopDrag);
|
||||
});
|
||||
}
|
||||
|
||||
@HostListener('window:mousemove', ['$event'])
|
||||
public onDrag(event: MouseEvent): void {
|
||||
private onDrag = (event: MouseEvent): void => {
|
||||
if (!this.isDragging) return;
|
||||
const deltaX = event.clientX - this.dragStartX;
|
||||
const deltaY = event.clientY - this.dragStartY;
|
||||
@@ -245,12 +255,13 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
|
||||
this.posX.set(targetX);
|
||||
this.posY.set(targetY);
|
||||
}
|
||||
};
|
||||
|
||||
@HostListener('window:mouseup')
|
||||
public stopDrag(): void {
|
||||
private stopDrag = (): void => {
|
||||
this.isDragging = false;
|
||||
}
|
||||
document.removeEventListener('mousemove', this.onDrag);
|
||||
document.removeEventListener('mouseup', this.stopDrag);
|
||||
};
|
||||
|
||||
@HostListener('window:resize')
|
||||
public onResize(): void {
|
||||
@@ -593,10 +604,16 @@ export class FloatingVideoPlayerComponent implements OnDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
public trackStepFrame(index: number, frame: StepReplayFrame): string {
|
||||
return frame.stepId || String(index);
|
||||
}
|
||||
|
||||
public ngOnDestroy(): void {
|
||||
if (this.stepTimer) {
|
||||
clearTimeout(this.stepTimer);
|
||||
this.stepTimer = null;
|
||||
}
|
||||
document.removeEventListener('mousemove', this.onDrag);
|
||||
document.removeEventListener('mouseup', this.stopDrag);
|
||||
}
|
||||
}
|
||||
+98
-84
@@ -1,17 +1,17 @@
|
||||
<!--
|
||||
Copyright 2026 Google LLC
|
||||
Copyright 2026 Google LLC
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<div class="agent-stream-container">
|
||||
@@ -29,14 +29,15 @@
|
||||
[value]="agentService.currentSessionId() || ''"
|
||||
(change)="onSessionChange($event)"
|
||||
class="session-dropdown"
|
||||
>
|
||||
<option value="" disabled selected>-- Select a Session --</option>
|
||||
<option
|
||||
*ngFor="let session of agentService.sessions()"
|
||||
[value]="session.session_id"
|
||||
>
|
||||
{{ session.initial_goal || 'Unnamed Goal' }} ({{ session.device_serial ? session.device_serial + ' · ' : '' }}{{ session.start_time * 1000 | date:'short' }})
|
||||
</option>
|
||||
<option value="" disabled selected>-- Select a Session --</option>
|
||||
@for (session of agentService.sessions(); track session) {
|
||||
<option
|
||||
[value]="session.session_id"
|
||||
>
|
||||
{{ session.initial_goal || 'Unnamed Goal' }} ({{ session.device_serial ? session.device_serial + ' · ' : '' }}{{ session.start_time * 1000 | date:'short' }})
|
||||
</option>
|
||||
}
|
||||
</select>
|
||||
<button class="btn-refresh" (click)="agentService.fetchSessions()">🔄 Refresh</button>
|
||||
</div>
|
||||
@@ -44,73 +45,86 @@
|
||||
|
||||
<!-- Event Logs Section -->
|
||||
<main class="stream-logs-content">
|
||||
<div *ngIf="agentService.sessionLogs().length === 0" class="empty-state">
|
||||
<div class="empty-icon">📡</div>
|
||||
<h3>No Stream Activity Selected</h3>
|
||||
<p>Please select a running session from the dropdown above to view real-time log payloads.</p>
|
||||
</div>
|
||||
|
||||
<div *ngIf="agentService.sessionLogs().length > 0" class="logs-list">
|
||||
<div
|
||||
*ngFor="let log of agentService.sessionLogs(); let i = index"
|
||||
class="log-card"
|
||||
[ngClass]="['type-' + log.type, isStepEvent(log.type) ? 'step-event-card' : 'raw-event-card']"
|
||||
>
|
||||
<div class="log-card-header">
|
||||
<div class="badge-group">
|
||||
<span class="index-badge">#{{ i + 1 }}</span>
|
||||
<span class="type-badge" [ngClass]="log.type">{{ log.type }}</span>
|
||||
</div>
|
||||
<span class="timestamp">{{ log.timestamp | date:'HH:mm:ss.SSS' }}</span>
|
||||
</div>
|
||||
|
||||
<!-- Render Step Events in a beautiful, structured layout -->
|
||||
<div *ngIf="isStepEvent(log.type)" class="log-card-body step-body">
|
||||
<!-- Step Summary (Message to User) -->
|
||||
<div class="step-summary-section" *ngIf="log.data.summary">
|
||||
<div class="section-title">💬 Message to User / Summary:</div>
|
||||
<div class="summary-bubble">{{ log.data.summary }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Model thinking process (collapsible) -->
|
||||
<details class="thinking-details" *ngIf="log.data.operator_native_thinking">
|
||||
<summary class="details-summary">🧠 Model Thinking Process (Click to expand)</summary>
|
||||
<div class="details-content thinking-text">{{ log.data.operator_native_thinking }}</div>
|
||||
</details>
|
||||
|
||||
<!-- Raw thinking process (collapsible) -->
|
||||
<details class="planning-details" *ngIf="log.data.operator_raw_thinking">
|
||||
<summary class="details-summary">📝 Raw Plan / Thoughts (Click to expand)</summary>
|
||||
<div class="details-content planning-text">{{ log.data.operator_raw_thinking }}</div>
|
||||
</details>
|
||||
|
||||
<!-- Tool Call (collapsible) -->
|
||||
<details class="tool-details" *ngIf="log.data.action_taken" [open]="isShortContent(log.data.action_taken)">
|
||||
<summary class="details-summary">🛠️ Tool Call Details (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data.action_taken) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- Tool Call Result (collapsible) -->
|
||||
<details class="result-details" *ngIf="log.data.last_execution_result">
|
||||
<summary class="details-summary">✓ Execution Result (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data.last_execution_result) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Render other event types as raw JSON (collapsible) -->
|
||||
<div *ngIf="!isStepEvent(log.type)" class="log-card-body raw-body">
|
||||
<details class="raw-details" [open]="isShortContent(log.data)">
|
||||
<summary class="details-summary">📄 Raw Payload Data (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
@if (agentService.sessionLogs().length === 0) {
|
||||
<div class="empty-state">
|
||||
<div class="empty-icon">📡</div>
|
||||
<h3>No Stream Activity Selected</h3>
|
||||
<p>Please select a running session from the dropdown above to view real-time log payloads.</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (agentService.sessionLogs().length > 0) {
|
||||
<div class="logs-list">
|
||||
@for (log of agentService.sessionLogs(); track log; let i = $index) {
|
||||
<div
|
||||
class="log-card"
|
||||
[ngClass]="['type-' + log.type, isStepEvent(log.type) ? 'step-event-card' : 'raw-event-card']"
|
||||
>
|
||||
<div class="log-card-header">
|
||||
<div class="badge-group">
|
||||
<span class="index-badge">#{{ i + 1 }}</span>
|
||||
<span class="type-badge" [ngClass]="log.type">{{ log.type }}</span>
|
||||
</div>
|
||||
<span class="timestamp">{{ log.timestamp | date:'HH:mm:ss.SSS' }}</span>
|
||||
</div>
|
||||
<!-- Render Step Events in a beautiful, structured layout -->
|
||||
@if (isStepEvent(log.type)) {
|
||||
<div class="log-card-body step-body">
|
||||
<!-- Step Summary (Message to User) -->
|
||||
@if (log.data.summary) {
|
||||
<div class="step-summary-section">
|
||||
<div class="section-title">💬 Message to User / Summary:</div>
|
||||
<div class="summary-bubble">{{ log.data.summary }}</div>
|
||||
</div>
|
||||
}
|
||||
<!-- Model thinking process (collapsible) -->
|
||||
@if (log.data.operator_native_thinking) {
|
||||
<details class="thinking-details">
|
||||
<summary class="details-summary">🧠 Model Thinking Process (Click to expand)</summary>
|
||||
<div class="details-content thinking-text">{{ log.data.operator_native_thinking }}</div>
|
||||
</details>
|
||||
}
|
||||
<!-- Raw thinking process (collapsible) -->
|
||||
@if (log.data.operator_raw_thinking) {
|
||||
<details class="planning-details">
|
||||
<summary class="details-summary">📝 Raw Plan / Thoughts (Click to expand)</summary>
|
||||
<div class="details-content planning-text">{{ log.data.operator_raw_thinking }}</div>
|
||||
</details>
|
||||
}
|
||||
<!-- Tool Call (collapsible) -->
|
||||
@if (log.data.action_taken) {
|
||||
<details class="tool-details" [open]="isShortContent(log.data.action_taken)">
|
||||
<summary class="details-summary">🛠️ Tool Call Details (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data.action_taken) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
<!-- Tool Call Result (collapsible) -->
|
||||
@if (log.data.last_execution_result) {
|
||||
<details class="result-details">
|
||||
<summary class="details-summary">✓ Execution Result (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data.last_execution_result) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<!-- Render other event types as raw JSON (collapsible) -->
|
||||
@if (!isStepEvent(log.type)) {
|
||||
<div class="log-card-body raw-body">
|
||||
<details class="raw-details" [open]="isShortContent(log.data)">
|
||||
<summary class="details-summary">📄 Raw Payload Data (Click to expand)</summary>
|
||||
<div class="details-content">
|
||||
<pre class="json-code"><code>{{ formatJson(log.data) }}</code></pre>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</main>
|
||||
</div>
|
||||
+2
-1
@@ -14,7 +14,7 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { FormsModule } from '@angular/forms';
|
||||
import { AgentService } from '../../services/agent.service';
|
||||
@@ -24,6 +24,7 @@ import { AgentService } from '../../services/agent.service';
|
||||
standalone: true,
|
||||
imports: [CommonModule, FormsModule],
|
||||
templateUrl: './legacy-agent-stream.component.html',
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrl: './legacy-agent-stream.component.scss'
|
||||
})
|
||||
export class LegacyAgentStreamComponent {
|
||||
|
||||
@@ -33,7 +33,9 @@
|
||||
box-shadow: 0 2px 8px -1px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(0, 0, 0, 0.02), inset 0 1px 1px rgba(255, 255, 255, 0.9);
|
||||
user-select: none;
|
||||
font-family: 'Google Sans', 'Google Sans Text', 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: background 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.25s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
border-color 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&:hover {
|
||||
background: rgba(255, 255, 255, 0.85);
|
||||
@@ -56,7 +58,10 @@
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
transition: background 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
color 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
box-shadow 0.2s cubic-bezier(0.4, 0, 0.2, 1),
|
||||
transform 0.2s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
outline: none;
|
||||
white-space: nowrap;
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Component } from '@angular/core';
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, ChangeDetectionStrategy } from '@angular/core';
|
||||
|
||||
import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
|
||||
@Component({
|
||||
selector: 'app-nav-switcher',
|
||||
standalone: true,
|
||||
imports: [CommonModule, RouterLink, RouterLinkActive],
|
||||
imports: [RouterLink, RouterLinkActive],
|
||||
template: `
|
||||
<nav class="floating-nav-switcher" aria-label="Main Navigation">
|
||||
<a
|
||||
@@ -45,6 +45,7 @@ import { RouterLink, RouterLinkActive } from '@angular/router';
|
||||
</a>
|
||||
</nav>
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.Eager,
|
||||
styleUrls: ['./nav-switcher.component.scss']
|
||||
})
|
||||
export class NavSwitcherComponent {}
|
||||
@@ -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') {
|
||||
|
||||
@@ -11,5 +11,13 @@
|
||||
],
|
||||
"include": [
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"extendedDiagnostics": {
|
||||
"checks": {
|
||||
"nullishCoalescingNotNullable": "suppress",
|
||||
"optionalChainNotNullable": "suppress"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -11,5 +11,13 @@
|
||||
"include": [
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.d.ts"
|
||||
]
|
||||
],
|
||||
"angularCompilerOptions": {
|
||||
"extendedDiagnostics": {
|
||||
"checks": {
|
||||
"nullishCoalescingNotNullable": "suppress",
|
||||
"optionalChainNotNullable": "suppress"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user