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:
BIN
amc_server/mixins/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/control.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/control.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/conversation.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/conversation.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/discovery.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/discovery.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/http.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/http.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/parsing.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/parsing.cpython-313.pyc
Normal file
Binary file not shown.
BIN
amc_server/mixins/__pycache__/state.cpython-313.pyc
Normal file
BIN
amc_server/mixins/__pycache__/state.cpython-313.pyc
Normal file
Binary file not shown.
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")}
|
||||
|
||||
Reference in New Issue
Block a user