feat(dashboard): add click-outside dismissal for autocomplete dropdown

Closes bd-3ny. Added mousedown listener that dismisses the dropdown when
clicking outside both the dropdown and textarea. Uses early return to avoid
registering listeners when dropdown is already closed.
This commit is contained in:
teernisse
2026-02-26 16:52:36 -05:00
parent ba16daac2a
commit db3d2a2e31
35 changed files with 5560 additions and 104 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -24,6 +24,39 @@ class SessionControlMixin:
session_file.unlink(missing_ok=True)
self._send_json(200, {"ok": True})
def _dismiss_dead_sessions(self):
"""Delete all dead session files (clear all from dashboard).
Note: is_dead is computed dynamically, not stored on disk, so we must
recompute it here using the same logic as _collect_sessions.
"""
# Get liveness data (same as _collect_sessions)
active_zellij_sessions = self._get_active_zellij_sessions()
active_transcript_files = self._get_active_transcript_files()
dismissed_count = 0
for f in SESSIONS_DIR.glob("*.json"):
try:
data = json.loads(f.read_text())
if not isinstance(data, dict):
continue
# Recompute is_dead (it's not persisted to disk)
is_dead = self._is_session_dead(
data, active_zellij_sessions, active_transcript_files
)
if is_dead:
safe_id = f.stem
# Track dismissed Codex sessions
while len(_dismissed_codex_ids) >= _DISMISSED_MAX:
oldest_key = next(iter(_dismissed_codex_ids))
del _dismissed_codex_ids[oldest_key]
_dismissed_codex_ids[safe_id] = True
f.unlink(missing_ok=True)
dismissed_count += 1
except (json.JSONDecodeError, OSError):
continue
self._send_json(200, {"ok": True, "dismissed": dismissed_count})
def _respond_to_session(self, session_id):
"""Inject a response into the session's Zellij pane."""
safe_id = os.path.basename(session_id)

View File

@@ -5,6 +5,7 @@ from pathlib import Path
from amc_server.context import (
CLAUDE_PROJECTS_DIR,
CODEX_SESSIONS_DIR,
_CODEX_CACHE_MAX,
_CONTEXT_CACHE_MAX,
_codex_transcript_cache,
_context_usage_cache,
@@ -44,6 +45,11 @@ class SessionParsingMixin:
try:
for jsonl_file in CODEX_SESSIONS_DIR.rglob(f"*{session_id}*.jsonl"):
# Evict old entries if cache is full (simple FIFO)
if len(_codex_transcript_cache) >= _CODEX_CACHE_MAX:
keys_to_remove = list(_codex_transcript_cache.keys())[: _CODEX_CACHE_MAX // 5]
for k in keys_to_remove:
_codex_transcript_cache.pop(k, None)
_codex_transcript_cache[session_id] = str(jsonl_file)
return jsonl_file
except OSError:

View File

@@ -100,6 +100,9 @@ class StateMixin:
# Get active Zellij sessions for liveness check
active_zellij_sessions = self._get_active_zellij_sessions()
# Get set of transcript files with active processes (for dead detection)
active_transcript_files = self._get_active_transcript_files()
for f in SESSIONS_DIR.glob("*.json"):
try:
data = json.loads(f.read_text())
@@ -120,11 +123,31 @@ class StateMixin:
if context_usage:
data["context_usage"] = context_usage
# Capture turn token baseline on UserPromptSubmit (for per-turn token display)
# Only write once when the turn starts and we have token data
if (
data.get("last_event") == "UserPromptSubmit"
and "turn_start_tokens" not in data
and context_usage
and context_usage.get("current_tokens") is not None
):
data["turn_start_tokens"] = context_usage["current_tokens"]
# Persist to session file so it survives server restarts
try:
f.write_text(json.dumps(data, indent=2))
except OSError:
pass
# Track conversation file mtime for real-time update detection
conv_mtime = self._get_conversation_mtime(data)
if conv_mtime:
data["conversation_mtime_ns"] = conv_mtime
# Determine if session is "dead" (no longer interactable)
data["is_dead"] = self._is_session_dead(
data, active_zellij_sessions, active_transcript_files
)
sessions.append(data)
except (json.JSONDecodeError, OSError):
continue
@@ -132,8 +155,8 @@ class StateMixin:
LOGGER.exception("Failed processing session file %s", f)
continue
# Sort by last_event_at descending
sessions.sort(key=lambda s: s.get("last_event_at", ""), reverse=True)
# Sort by session_id for stable, deterministic ordering (no visual jumping)
sessions.sort(key=lambda s: s.get("session_id", ""))
# Clean orphan event logs (sessions persist until manually dismissed or SessionEnd)
self._cleanup_stale(sessions)
@@ -204,6 +227,133 @@ class StateMixin:
return None
def _get_active_transcript_files(self):
"""Get set of transcript files that have active processes.
Uses a batched lsof call to efficiently check which Codex transcript
files are currently open by a process.
Returns:
set: Absolute paths of transcript files with active processes.
"""
from amc_server.context import CODEX_SESSIONS_DIR
if not CODEX_SESSIONS_DIR.exists():
return set()
# Find all recent transcript files
transcript_files = []
now = time.time()
cutoff = now - 3600 # Only check files modified in the last hour
for jsonl_file in CODEX_SESSIONS_DIR.rglob("*.jsonl"):
try:
if jsonl_file.stat().st_mtime > cutoff:
transcript_files.append(str(jsonl_file))
except OSError:
continue
if not transcript_files:
return set()
# Batch lsof check for all transcript files
active_files = set()
try:
# lsof with multiple files: returns PIDs for any that are open
result = subprocess.run(
["lsof", "-t"] + transcript_files,
capture_output=True,
text=True,
timeout=5,
)
# If any file is open, lsof returns 0
# We need to check which specific files are open
if result.returncode == 0 and result.stdout.strip():
# At least one file is open - check each one
for tf in transcript_files:
try:
check = subprocess.run(
["lsof", "-t", tf],
capture_output=True,
text=True,
timeout=2,
)
if check.returncode == 0 and check.stdout.strip():
active_files.add(tf)
except (subprocess.TimeoutExpired, Exception):
continue
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
pass
return active_files
def _is_session_dead(self, session_data, active_zellij_sessions, active_transcript_files):
"""Determine if a session is 'dead' (no longer interactable).
A dead session cannot receive input and won't produce more output.
These should be shown separately from active sessions in the UI.
Args:
session_data: The session dict
active_zellij_sessions: Set of active zellij session names (or None)
active_transcript_files: Set of transcript file paths with active processes
Returns:
bool: True if the session is dead
"""
agent = session_data.get("agent")
zellij_session = session_data.get("zellij_session", "")
status = session_data.get("status", "")
# Sessions that are still starting are not dead (yet)
if status == "starting":
return False
if agent == "codex":
# Codex session is dead if no process has the transcript file open
transcript_path = session_data.get("transcript_path", "")
if not transcript_path:
return True # No transcript path = malformed, treat as dead
# Check cached set first (covers recently-modified files)
if transcript_path in active_transcript_files:
return False # Process is running
# For older files not in cached set, do explicit lsof check
# This handles long-idle but still-running processes
if self._is_file_open(transcript_path):
return False # Process is running
# No process running - it's dead
return True
elif agent == "claude":
# Claude session is dead if:
# 1. No zellij session attached, OR
# 2. The zellij session no longer exists
if not zellij_session:
return True
if active_zellij_sessions is not None:
return zellij_session not in active_zellij_sessions
# If we couldn't query zellij, assume alive (don't false-positive)
return False
# Unknown agent type - assume alive
return False
def _is_file_open(self, file_path):
"""Check if any process has a file open using lsof."""
try:
result = subprocess.run(
["lsof", "-t", file_path],
capture_output=True,
text=True,
timeout=2,
)
return result.returncode == 0 and result.stdout.strip()
except (subprocess.TimeoutExpired, FileNotFoundError, Exception):
return False # Assume not open on error (conservative)
def _cleanup_stale(self, sessions):
"""Remove orphan event logs >24h and stale 'starting' sessions >1h."""
active_ids = {s.get("session_id") for s in sessions if s.get("session_id")}