Compare commits
10 Commits
9cd91f6b4e
...
31862f3a40
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
31862f3a40 | ||
|
|
4740922b8d | ||
|
|
8578a19330 | ||
|
|
754e85445a | ||
|
|
183942fbaa | ||
|
|
2f80995f8d | ||
|
|
8224acbba7 | ||
|
|
7cf51427b7 | ||
|
|
be2dd6a4fb | ||
|
|
da08d7a588 |
20
README.md
20
README.md
@@ -93,7 +93,7 @@ AMC requires Claude Code hooks to report session state. Add this to your `~/.cla
|
||||
| `bin/amc` | Launcher script — start/stop/status commands |
|
||||
| `bin/amc-server` | Python HTTP server serving the API and dashboard |
|
||||
| `bin/amc-hook` | Hook script called by Claude Code to write session state |
|
||||
| `dashboard-preact.html` | Single-file Preact dashboard |
|
||||
| `dashboard/` | Modular Preact dashboard (index.html, components/, lib/, utils/) |
|
||||
|
||||
### Data Storage
|
||||
|
||||
@@ -134,8 +134,10 @@ The `/api/respond/{id}` endpoint injects text into a session's Zellij pane. Requ
|
||||
- `optionCount` — Number of options in the current question (used for freeform)
|
||||
|
||||
Response injection works via:
|
||||
1. **Zellij plugin** (`~/.config/zellij/plugins/zellij-send-keys.wasm`) — Preferred, no focus change
|
||||
2. **write-chars fallback** — Uses `zellij action write-chars`, changes focus
|
||||
1. **Zellij plugin** (`~/.config/zellij/plugins/zellij-send-keys.wasm`) — Required for pane-targeted sends and Enter submission
|
||||
2. **Optional unsafe fallback** (`AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK=1`) — Uses focused-pane `write-chars` only when explicitly enabled
|
||||
|
||||
AMC resolves the Zellij binary from PATH plus common Homebrew locations (`/opt/homebrew/bin/zellij`, `/usr/local/bin/zellij`) so response injection still works when started via `launchctl`.
|
||||
|
||||
## Session Statuses
|
||||
|
||||
@@ -159,9 +161,17 @@ Response injection works via:
|
||||
- Zellij (for response injection)
|
||||
- Claude Code with hooks support
|
||||
|
||||
## Optional: Zellij Plugin
|
||||
## Testing
|
||||
|
||||
For seamless response injection without focus changes, install the `zellij-send-keys` plugin:
|
||||
Run the server test suite:
|
||||
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
## Zellij Plugin
|
||||
|
||||
For pane-targeted response injection (including reliable Enter submission), install the `zellij-send-keys` plugin:
|
||||
|
||||
```bash
|
||||
# Build and install the plugin
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import threading
|
||||
|
||||
@@ -10,6 +11,27 @@ CODEX_SESSIONS_DIR = Path.home() / ".codex" / "sessions"
|
||||
# Plugin path for zellij-send-keys
|
||||
ZELLIJ_PLUGIN = Path.home() / ".config" / "zellij" / "plugins" / "zellij-send-keys.wasm"
|
||||
|
||||
|
||||
def _resolve_zellij_bin():
|
||||
"""Resolve zellij binary even when PATH is minimal (eg launchctl)."""
|
||||
from_path = shutil.which("zellij")
|
||||
if from_path:
|
||||
return from_path
|
||||
|
||||
common_paths = (
|
||||
"/opt/homebrew/bin/zellij", # Apple Silicon Homebrew
|
||||
"/usr/local/bin/zellij", # Intel Homebrew
|
||||
"/usr/bin/zellij",
|
||||
)
|
||||
for candidate in common_paths:
|
||||
p = Path(candidate)
|
||||
if p.exists() and p.is_file():
|
||||
return str(p)
|
||||
return "zellij" # Fallback for explicit error reporting by subprocess
|
||||
|
||||
|
||||
ZELLIJ_BIN = _resolve_zellij_bin()
|
||||
|
||||
# Runtime data lives in XDG data dir
|
||||
DATA_DIR = Path.home() / ".local" / "share" / "amc"
|
||||
SESSIONS_DIR = DATA_DIR / "sessions"
|
||||
|
||||
@@ -3,11 +3,14 @@ import os
|
||||
import subprocess
|
||||
import time
|
||||
|
||||
from amc_server.context import SESSIONS_DIR, ZELLIJ_PLUGIN, _DISMISSED_MAX, _dismissed_codex_ids
|
||||
from amc_server.context import SESSIONS_DIR, ZELLIJ_BIN, ZELLIJ_PLUGIN, _DISMISSED_MAX, _dismissed_codex_ids
|
||||
from amc_server.logging_utils import LOGGER
|
||||
|
||||
|
||||
class SessionControlMixin:
|
||||
_FREEFORM_MODE_SWITCH_DELAY_SEC = 0.30
|
||||
_DEFAULT_SUBMIT_ENTER_DELAY_SEC = 0.20
|
||||
|
||||
def _dismiss_session(self, session_id):
|
||||
"""Delete a session file (manual dismiss from dashboard)."""
|
||||
safe_id = os.path.basename(session_id)
|
||||
@@ -87,16 +90,40 @@ class SessionControlMixin:
|
||||
self._send_json(500, {"ok": False, "error": f"Failed to activate freeform mode: {result['error']}"})
|
||||
return
|
||||
# Delay for Claude Code to switch to text input mode
|
||||
time.sleep(0.3)
|
||||
time.sleep(self._FREEFORM_MODE_SWITCH_DELAY_SEC)
|
||||
|
||||
# Inject the actual text (with Enter)
|
||||
result = self._inject_to_pane(zellij_session, pane_id, text, send_enter=True)
|
||||
# Inject the actual text first, then submit with delayed Enter.
|
||||
result = self._inject_text_then_enter(zellij_session, pane_id, text)
|
||||
|
||||
if result["ok"]:
|
||||
self._send_json(200, {"ok": True})
|
||||
else:
|
||||
self._send_json(500, {"ok": False, "error": result["error"]})
|
||||
|
||||
def _inject_text_then_enter(self, zellij_session, pane_id, text):
|
||||
"""Send text and trigger Enter in two steps to avoid newline-only races."""
|
||||
result = self._inject_to_pane(zellij_session, pane_id, text, send_enter=False)
|
||||
if not result["ok"]:
|
||||
return result
|
||||
|
||||
time.sleep(self._get_submit_enter_delay_sec())
|
||||
# Send Enter as its own action after the text has landed.
|
||||
return self._inject_to_pane(zellij_session, pane_id, "", send_enter=True)
|
||||
|
||||
def _get_submit_enter_delay_sec(self):
|
||||
raw = os.environ.get("AMC_SUBMIT_ENTER_DELAY_MS", "").strip()
|
||||
if not raw:
|
||||
return self._DEFAULT_SUBMIT_ENTER_DELAY_SEC
|
||||
try:
|
||||
ms = float(raw)
|
||||
if ms < 0:
|
||||
return 0.0
|
||||
if ms > 2000:
|
||||
ms = 2000
|
||||
return ms / 1000.0
|
||||
except ValueError:
|
||||
return self._DEFAULT_SUBMIT_ENTER_DELAY_SEC
|
||||
|
||||
def _parse_pane_id(self, zellij_pane):
|
||||
"""Extract numeric pane ID from various formats."""
|
||||
if not zellij_pane:
|
||||
@@ -127,7 +154,7 @@ class SessionControlMixin:
|
||||
|
||||
# Pane-accurate routing requires the plugin.
|
||||
if ZELLIJ_PLUGIN.exists():
|
||||
result = self._try_plugin_inject(env, pane_id, text, send_enter)
|
||||
result = self._try_plugin_inject(env, zellij_session, pane_id, text, send_enter)
|
||||
if result["ok"]:
|
||||
return result
|
||||
LOGGER.warning(
|
||||
@@ -142,7 +169,7 @@ class SessionControlMixin:
|
||||
# `write-chars` targets whichever pane is focused, which is unsafe for AMC.
|
||||
if self._allow_unsafe_write_chars_fallback():
|
||||
LOGGER.warning("Using unsafe write-chars fallback (focused pane only)")
|
||||
return self._try_write_chars_inject(env, text, send_enter)
|
||||
return self._try_write_chars_inject(env, zellij_session, text, send_enter)
|
||||
|
||||
return {
|
||||
"ok": False,
|
||||
@@ -156,7 +183,7 @@ class SessionControlMixin:
|
||||
value = os.environ.get("AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK", "").strip().lower()
|
||||
return value in ("1", "true", "yes", "on")
|
||||
|
||||
def _try_plugin_inject(self, env, pane_id, text, send_enter=True):
|
||||
def _try_plugin_inject(self, env, zellij_session, pane_id, text, send_enter=True):
|
||||
"""Try injecting via zellij-send-keys plugin (no focus change)."""
|
||||
payload = json.dumps({
|
||||
"pane_id": pane_id,
|
||||
@@ -167,7 +194,9 @@ class SessionControlMixin:
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"zellij",
|
||||
ZELLIJ_BIN,
|
||||
"--session",
|
||||
zellij_session,
|
||||
"action",
|
||||
"pipe",
|
||||
"--plugin",
|
||||
@@ -194,12 +223,12 @@ class SessionControlMixin:
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
def _try_write_chars_inject(self, env, text, send_enter=True):
|
||||
def _try_write_chars_inject(self, env, zellij_session, text, send_enter=True):
|
||||
"""Inject via write-chars (UNSAFE: writes to focused pane)."""
|
||||
try:
|
||||
# Write the text
|
||||
result = subprocess.run(
|
||||
["zellij", "action", "write-chars", text],
|
||||
[ZELLIJ_BIN, "--session", zellij_session, "action", "write-chars", text],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -212,7 +241,7 @@ class SessionControlMixin:
|
||||
# Send Enter if requested
|
||||
if send_enter:
|
||||
result = subprocess.run(
|
||||
["zellij", "action", "write", "13"], # 13 = Enter
|
||||
[ZELLIJ_BIN, "--session", zellij_session, "action", "write", "13"], # 13 = Enter
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
@@ -227,6 +256,6 @@ class SessionControlMixin:
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"ok": False, "error": "write-chars timed out"}
|
||||
except FileNotFoundError:
|
||||
return {"ok": False, "error": "zellij not found in PATH"}
|
||||
return {"ok": False, "error": f"zellij not found (resolved binary: {ZELLIJ_BIN})"}
|
||||
except Exception as e:
|
||||
return {"ok": False, "error": str(e)}
|
||||
|
||||
@@ -108,8 +108,15 @@ class ConversationMixin:
|
||||
return messages
|
||||
|
||||
def _parse_codex_conversation(self, session_id):
|
||||
"""Parse Codex JSONL conversation format."""
|
||||
"""Parse Codex JSONL conversation format.
|
||||
|
||||
Codex uses separate response_items for different content types:
|
||||
- message: user/assistant text messages
|
||||
- function_call: tool invocations (name, arguments, call_id)
|
||||
- reasoning: thinking summaries (encrypted content, visible summary)
|
||||
"""
|
||||
messages = []
|
||||
pending_tool_calls = [] # Accumulate tool calls to attach to next assistant message
|
||||
|
||||
conv_file = self._find_codex_transcript_file(session_id)
|
||||
|
||||
@@ -123,16 +130,54 @@ class ConversationMixin:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
|
||||
# Codex format: type="response_item", payload.type="message"
|
||||
if entry.get("type") != "response_item":
|
||||
continue
|
||||
|
||||
payload = entry.get("payload", {})
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if payload.get("type") != "message":
|
||||
|
||||
payload_type = payload.get("type")
|
||||
timestamp = entry.get("timestamp", "")
|
||||
|
||||
# Handle function_call (tool invocations)
|
||||
if payload_type == "function_call":
|
||||
tool_call = {
|
||||
"name": payload.get("name", "unknown"),
|
||||
"input": self._parse_codex_arguments(payload.get("arguments", "{}")),
|
||||
}
|
||||
pending_tool_calls.append(tool_call)
|
||||
continue
|
||||
|
||||
# Handle reasoning (thinking summaries)
|
||||
if payload_type == "reasoning":
|
||||
summary_parts = payload.get("summary", [])
|
||||
if summary_parts:
|
||||
thinking_text = []
|
||||
for part in summary_parts:
|
||||
if isinstance(part, dict) and part.get("type") == "summary_text":
|
||||
thinking_text.append(part.get("text", ""))
|
||||
if thinking_text:
|
||||
# Flush any pending tool calls first
|
||||
if pending_tool_calls:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": pending_tool_calls,
|
||||
"timestamp": timestamp,
|
||||
})
|
||||
pending_tool_calls = []
|
||||
# Add thinking as assistant message
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"thinking": "\n".join(thinking_text),
|
||||
"timestamp": timestamp,
|
||||
})
|
||||
continue
|
||||
|
||||
# Handle message (user/assistant text)
|
||||
if payload_type == "message":
|
||||
role = payload.get("role", "")
|
||||
content_parts = payload.get("content", [])
|
||||
if not isinstance(content_parts, list):
|
||||
@@ -146,7 +191,6 @@ class ConversationMixin:
|
||||
text_parts = []
|
||||
for part in content_parts:
|
||||
if isinstance(part, dict):
|
||||
# Codex uses "input_text" for user, "output_text" for assistant
|
||||
text = part.get("text", "")
|
||||
if text:
|
||||
# Skip injected context (AGENTS.md, environment, permissions)
|
||||
@@ -160,16 +204,58 @@ class ConversationMixin:
|
||||
continue
|
||||
text_parts.append(text)
|
||||
|
||||
if text_parts and role in ("user", "assistant"):
|
||||
if role == "user" and text_parts:
|
||||
# Flush any pending tool calls before user message
|
||||
if pending_tool_calls:
|
||||
messages.append({
|
||||
"role": role,
|
||||
"content": "\n".join(text_parts),
|
||||
"timestamp": entry.get("timestamp", ""),
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": pending_tool_calls,
|
||||
"timestamp": timestamp,
|
||||
})
|
||||
pending_tool_calls = []
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": "\n".join(text_parts),
|
||||
"timestamp": timestamp,
|
||||
})
|
||||
elif role == "assistant":
|
||||
msg = {
|
||||
"role": "assistant",
|
||||
"content": "\n".join(text_parts) if text_parts else "",
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
# Attach any pending tool calls to this assistant message
|
||||
if pending_tool_calls:
|
||||
msg["tool_calls"] = pending_tool_calls
|
||||
pending_tool_calls = []
|
||||
if text_parts or msg.get("tool_calls"):
|
||||
messages.append(msg)
|
||||
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
|
||||
# Flush any remaining pending tool calls
|
||||
if pending_tool_calls:
|
||||
messages.append({
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"tool_calls": pending_tool_calls,
|
||||
"timestamp": "",
|
||||
})
|
||||
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
return messages
|
||||
|
||||
def _parse_codex_arguments(self, arguments_str):
|
||||
"""Parse Codex function_call arguments (JSON string or dict)."""
|
||||
if isinstance(arguments_str, dict):
|
||||
return arguments_str
|
||||
if isinstance(arguments_str, str):
|
||||
try:
|
||||
return json.loads(arguments_str)
|
||||
except json.JSONDecodeError:
|
||||
return {"raw": arguments_str}
|
||||
return {}
|
||||
|
||||
@@ -9,6 +9,7 @@ from amc_server.context import (
|
||||
SESSIONS_DIR,
|
||||
STALE_EVENT_AGE,
|
||||
STALE_STARTING_AGE,
|
||||
ZELLIJ_BIN,
|
||||
_state_lock,
|
||||
_zellij_cache,
|
||||
)
|
||||
@@ -143,7 +144,7 @@ class StateMixin:
|
||||
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["zellij", "list-sessions", "--no-formatting"],
|
||||
[ZELLIJ_BIN, "list-sessions", "--no-formatting"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=2,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
1470
dashboard.html
1470
dashboard.html
File diff suppressed because it is too large
Load Diff
392
dashboard/components/App.js
Normal file
392
dashboard/components/App.js
Normal file
@@ -0,0 +1,392 @@
|
||||
import { html, useState, useEffect, useCallback, useMemo, useRef } from '../lib/preact.js';
|
||||
import { API_STATE, API_STREAM, API_DISMISS, API_RESPOND, API_CONVERSATION, POLL_MS, fetchWithTimeout } from '../utils/api.js';
|
||||
import { groupSessionsByProject } from '../utils/status.js';
|
||||
import { Sidebar } from './Sidebar.js';
|
||||
import { SessionCard } from './SessionCard.js';
|
||||
import { Modal } from './Modal.js';
|
||||
import { EmptyState } from './EmptyState.js';
|
||||
|
||||
export function App() {
|
||||
const [sessions, setSessions] = useState([]);
|
||||
const [modalSession, setModalSession] = useState(null);
|
||||
const [conversations, setConversations] = useState({});
|
||||
const [conversationLoading, setConversationLoading] = useState(false);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [selectedProject, setSelectedProject] = useState(null);
|
||||
const [sseConnected, setSseConnected] = useState(false);
|
||||
|
||||
// Silent conversation refresh (no loading state, used for background polling)
|
||||
// Defined early so fetchState can reference it
|
||||
const refreshConversationSilent = useCallback(async (sessionId, projectDir, agent = 'claude') => {
|
||||
try {
|
||||
let url = API_CONVERSATION + encodeURIComponent(sessionId);
|
||||
const params = new URLSearchParams();
|
||||
if (projectDir) params.set('project_dir', projectDir);
|
||||
if (agent) params.set('agent', agent);
|
||||
if (params.toString()) url += '?' + params.toString();
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) return;
|
||||
const data = await response.json();
|
||||
setConversations(prev => ({
|
||||
...prev,
|
||||
[sessionId]: data.messages || []
|
||||
}));
|
||||
} catch (err) {
|
||||
// Silent failure for background refresh
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Track last_event_at for each session to detect actual changes
|
||||
const lastEventAtRef = useRef({});
|
||||
|
||||
// Apply state payload from polling or SSE stream
|
||||
const applyStateData = useCallback((data) => {
|
||||
const newSessions = data.sessions || [];
|
||||
setSessions(newSessions);
|
||||
setError(null);
|
||||
|
||||
// Update modalSession if it's still open (to get latest pending_questions, etc.)
|
||||
let modalId = null;
|
||||
setModalSession(prev => {
|
||||
if (!prev) return null;
|
||||
modalId = prev.session_id;
|
||||
const updatedSession = newSessions.find(s => s.session_id === prev.session_id);
|
||||
return updatedSession || prev;
|
||||
});
|
||||
|
||||
// Only refresh conversations for sessions that have actually changed
|
||||
// (compare last_event_at to avoid flooding the API)
|
||||
const prevEventMap = lastEventAtRef.current;
|
||||
const nextEventMap = {};
|
||||
|
||||
for (const session of newSessions) {
|
||||
const id = session.session_id;
|
||||
const newEventAt = session.last_event_at || '';
|
||||
nextEventMap[id] = newEventAt;
|
||||
|
||||
// Only refresh if:
|
||||
// 1. Session is active/attention AND
|
||||
// 2. last_event_at has actually changed OR it's the currently open modal
|
||||
if (session.status === 'active' || session.status === 'needs_attention') {
|
||||
const oldEventAt = prevEventMap[id] || '';
|
||||
if (newEventAt !== oldEventAt || id === modalId) {
|
||||
refreshConversationSilent(session.session_id, session.project_dir, session.agent || 'claude');
|
||||
}
|
||||
}
|
||||
}
|
||||
lastEventAtRef.current = nextEventMap;
|
||||
|
||||
setLoading(false);
|
||||
}, [refreshConversationSilent]);
|
||||
|
||||
// Fetch state from API
|
||||
const fetchState = useCallback(async () => {
|
||||
try {
|
||||
const response = await fetchWithTimeout(API_STATE);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
applyStateData(data);
|
||||
} catch (err) {
|
||||
const msg = err.name === 'AbortError' ? 'Request timed out' : err.message;
|
||||
console.error('Failed to fetch state:', msg);
|
||||
setError(msg);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [applyStateData]);
|
||||
|
||||
// Fetch conversation for a session
|
||||
const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', showLoading = false, force = false) => {
|
||||
// Skip if already fetched and not forcing refresh
|
||||
if (!force && conversations[sessionId]) return;
|
||||
|
||||
if (showLoading) {
|
||||
setConversationLoading(true);
|
||||
}
|
||||
|
||||
try {
|
||||
let url = API_CONVERSATION + encodeURIComponent(sessionId);
|
||||
const params = new URLSearchParams();
|
||||
if (projectDir) params.set('project_dir', projectDir);
|
||||
if (agent) params.set('agent', agent);
|
||||
if (params.toString()) url += '?' + params.toString();
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
console.warn('Failed to fetch conversation for', sessionId);
|
||||
return;
|
||||
}
|
||||
const data = await response.json();
|
||||
setConversations(prev => ({
|
||||
...prev,
|
||||
[sessionId]: data.messages || []
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('Error fetching conversation:', err);
|
||||
} finally {
|
||||
if (showLoading) {
|
||||
setConversationLoading(false);
|
||||
}
|
||||
}
|
||||
}, [conversations]);
|
||||
|
||||
// Respond to a session's pending question
|
||||
const respondToSession = useCallback(async (sessionId, text, isFreeform = false, optionCount = 0) => {
|
||||
const payload = { text };
|
||||
if (isFreeform) {
|
||||
payload.freeform = true;
|
||||
payload.optionCount = optionCount;
|
||||
}
|
||||
try {
|
||||
const res = await fetch(API_RESPOND + encodeURIComponent(sessionId), {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
// Trigger refresh
|
||||
fetchState();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error responding to session:', err);
|
||||
}
|
||||
}, [fetchState]);
|
||||
|
||||
// Dismiss a session
|
||||
const dismissSession = useCallback(async (sessionId) => {
|
||||
try {
|
||||
const res = await fetch(API_DISMISS + encodeURIComponent(sessionId), {
|
||||
method: 'POST'
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.ok) {
|
||||
// Trigger refresh
|
||||
fetchState();
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error dismissing session:', err);
|
||||
}
|
||||
}, [fetchState]);
|
||||
|
||||
// Subscribe to live state updates via SSE
|
||||
useEffect(() => {
|
||||
let eventSource = null;
|
||||
let reconnectTimer = null;
|
||||
let stopped = false;
|
||||
|
||||
const connect = () => {
|
||||
if (stopped) return;
|
||||
|
||||
try {
|
||||
eventSource = new EventSource(API_STREAM);
|
||||
} catch (err) {
|
||||
console.error('Failed to initialize EventSource:', err);
|
||||
setSseConnected(false);
|
||||
reconnectTimer = setTimeout(connect, 2000);
|
||||
return;
|
||||
}
|
||||
|
||||
eventSource.addEventListener('open', () => {
|
||||
if (stopped) return;
|
||||
setSseConnected(true);
|
||||
setError(null);
|
||||
});
|
||||
|
||||
eventSource.addEventListener('state', (event) => {
|
||||
if (stopped) return;
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
applyStateData(data);
|
||||
} catch (err) {
|
||||
console.error('Failed to parse SSE state payload:', err);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.addEventListener('error', () => {
|
||||
if (stopped) return;
|
||||
setSseConnected(false);
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
eventSource = null;
|
||||
}
|
||||
if (!reconnectTimer) {
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, 2000);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
connect();
|
||||
|
||||
return () => {
|
||||
stopped = true;
|
||||
if (reconnectTimer) {
|
||||
clearTimeout(reconnectTimer);
|
||||
}
|
||||
if (eventSource) {
|
||||
eventSource.close();
|
||||
}
|
||||
};
|
||||
}, [applyStateData]);
|
||||
|
||||
// Poll for updates only when SSE is disconnected (fallback mode)
|
||||
useEffect(() => {
|
||||
if (sseConnected) return;
|
||||
|
||||
fetchState();
|
||||
const interval = setInterval(fetchState, POLL_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchState, sseConnected]);
|
||||
|
||||
// Group sessions by project
|
||||
const projectGroups = groupSessionsByProject(sessions);
|
||||
|
||||
// Filter sessions based on selected project
|
||||
const filteredGroups = useMemo(() => {
|
||||
if (selectedProject === null) {
|
||||
return projectGroups;
|
||||
}
|
||||
return projectGroups.filter(g => g.projectDir === selectedProject);
|
||||
}, [projectGroups, selectedProject]);
|
||||
|
||||
// Handle card click - open modal and fetch conversation if not cached
|
||||
const handleCardClick = useCallback(async (session) => {
|
||||
setModalSession(session);
|
||||
|
||||
// Fetch conversation if not already cached
|
||||
if (!conversations[session.session_id]) {
|
||||
await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude', true);
|
||||
}
|
||||
}, [conversations, fetchConversation]);
|
||||
|
||||
// Refresh conversation (force re-fetch, used after sending messages)
|
||||
const refreshConversation = useCallback(async (sessionId, projectDir, agent = 'claude') => {
|
||||
// Force refresh by clearing cache first
|
||||
setConversations(prev => {
|
||||
const updated = { ...prev };
|
||||
delete updated[sessionId];
|
||||
return updated;
|
||||
});
|
||||
await fetchConversation(sessionId, projectDir, agent, false, true);
|
||||
}, [fetchConversation]);
|
||||
|
||||
const handleCloseModal = useCallback(() => {
|
||||
setModalSession(null);
|
||||
}, []);
|
||||
|
||||
const handleSelectProject = useCallback((projectDir) => {
|
||||
setSelectedProject(projectDir);
|
||||
}, []);
|
||||
|
||||
return html`
|
||||
<!-- Sidebar -->
|
||||
<${Sidebar}
|
||||
projectGroups=${projectGroups}
|
||||
selectedProject=${selectedProject}
|
||||
onSelectProject=${handleSelectProject}
|
||||
totalSessions=${sessions.length}
|
||||
/>
|
||||
|
||||
<!-- Main Content (offset for sidebar) -->
|
||||
<div class="ml-80 min-h-screen pb-10">
|
||||
<!-- Compact Header -->
|
||||
<header class="sticky top-0 z-30 border-b border-selection/50 bg-surface/95 px-6 py-4 backdrop-blur-sm">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 class="font-display text-lg font-semibold text-bright">
|
||||
${selectedProject === null ? 'All Projects' : filteredGroups[0]?.projectName || 'Project'}
|
||||
</h2>
|
||||
<p class="mt-0.5 font-mono text-micro text-dim">
|
||||
${filteredGroups.reduce((sum, g) => sum + g.sessions.length, 0)} session${filteredGroups.reduce((sum, g) => sum + g.sessions.length, 0) === 1 ? '' : 's'}
|
||||
${selectedProject !== null && filteredGroups[0]?.projectDir ? html` in <span class="text-dim/80">${filteredGroups[0].projectDir}</span>` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<!-- Status summary chips -->
|
||||
<div class="flex items-center gap-2">
|
||||
${(() => {
|
||||
const counts = { needs_attention: 0, active: 0, starting: 0, done: 0 };
|
||||
for (const g of filteredGroups) {
|
||||
for (const s of g.sessions) {
|
||||
counts[s.status] = (counts[s.status] || 0) + 1;
|
||||
}
|
||||
}
|
||||
return html`
|
||||
${counts.needs_attention > 0 && html`
|
||||
<div class="rounded-lg border border-attention/40 bg-attention/12 px-2.5 py-1 text-attention">
|
||||
<span class="font-mono text-sm font-medium tabular-nums">${counts.needs_attention}</span>
|
||||
<span class="ml-1 text-micro uppercase tracking-wider">attention</span>
|
||||
</div>
|
||||
`}
|
||||
${counts.active > 0 && html`
|
||||
<div class="rounded-lg border border-active/40 bg-active/12 px-2.5 py-1 text-active">
|
||||
<span class="font-mono text-sm font-medium tabular-nums">${counts.active}</span>
|
||||
<span class="ml-1 text-micro uppercase tracking-wider">active</span>
|
||||
</div>
|
||||
`}
|
||||
${counts.starting > 0 && html`
|
||||
<div class="rounded-lg border border-starting/40 bg-starting/12 px-2.5 py-1 text-starting">
|
||||
<span class="font-mono text-sm font-medium tabular-nums">${counts.starting}</span>
|
||||
<span class="ml-1 text-micro uppercase tracking-wider">starting</span>
|
||||
</div>
|
||||
`}
|
||||
${counts.done > 0 && html`
|
||||
<div class="rounded-lg border border-done/40 bg-done/12 px-2.5 py-1 text-done">
|
||||
<span class="font-mono text-sm font-medium tabular-nums">${counts.done}</span>
|
||||
<span class="ml-1 text-micro uppercase tracking-wider">done</span>
|
||||
</div>
|
||||
`}
|
||||
`;
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="px-6 pb-6 pt-6">
|
||||
${loading ? html`
|
||||
<div class="glass-panel animate-fade-in-up flex items-center justify-center rounded-2xl py-24">
|
||||
<div class="font-mono text-dim">Loading sessions...</div>
|
||||
</div>
|
||||
` : error ? html`
|
||||
<div class="glass-panel animate-fade-in-up flex items-center justify-center rounded-2xl py-24">
|
||||
<div class="text-center">
|
||||
<p class="mb-2 font-display text-lg text-attention">Failed to connect to API</p>
|
||||
<p class="font-mono text-sm text-dim">${error}</p>
|
||||
</div>
|
||||
</div>
|
||||
` : filteredGroups.length === 0 ? html`
|
||||
<${EmptyState} />
|
||||
` : html`
|
||||
<!-- Sessions Grid (no project grouping header since sidebar shows selection) -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
${filteredGroups.flatMap(group =>
|
||||
group.sessions.map(session => html`
|
||||
<${SessionCard}
|
||||
key=${session.session_id}
|
||||
session=${session}
|
||||
onClick=${handleCardClick}
|
||||
conversation=${conversations[session.session_id]}
|
||||
onFetchConversation=${fetchConversation}
|
||||
onRespond=${respondToSession}
|
||||
onDismiss=${dismissSession}
|
||||
/>
|
||||
`)
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<${Modal}
|
||||
session=${modalSession}
|
||||
conversations=${conversations}
|
||||
conversationLoading=${conversationLoading}
|
||||
onClose=${handleCloseModal}
|
||||
onSendMessage=${respondToSession}
|
||||
onRefreshConversation=${refreshConversation}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
32
dashboard/components/ChatMessages.js
Normal file
32
dashboard/components/ChatMessages.js
Normal file
@@ -0,0 +1,32 @@
|
||||
import { html } from '../lib/preact.js';
|
||||
import { getUserMessageBg } from '../utils/status.js';
|
||||
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
|
||||
|
||||
export function ChatMessages({ messages, status }) {
|
||||
const userBgClass = getUserMessageBg(status);
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
return html`
|
||||
<div class="flex h-full items-center justify-center rounded-xl border border-dashed border-selection/70 bg-bg/30 px-4 text-center text-sm text-dim">
|
||||
No messages yet
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const allDisplayMessages = filterDisplayMessages(messages);
|
||||
const displayMessages = allDisplayMessages.slice(-20);
|
||||
const offset = allDisplayMessages.length - displayMessages.length;
|
||||
|
||||
return html`
|
||||
<div class="space-y-2.5">
|
||||
${displayMessages.map((msg, i) => html`
|
||||
<${MessageBubble}
|
||||
key=${`${msg.role}-${msg.timestamp || (offset + i)}`}
|
||||
msg=${msg}
|
||||
userBg=${userBgClass}
|
||||
compact=${true}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
18
dashboard/components/EmptyState.js
Normal file
18
dashboard/components/EmptyState.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { html } from '../lib/preact.js';
|
||||
|
||||
export function EmptyState() {
|
||||
return html`
|
||||
<div class="glass-panel mx-auto flex max-w-2xl flex-col items-center justify-center rounded-3xl px-8 py-20 text-center">
|
||||
<div class="mb-6 flex h-20 w-20 items-center justify-center rounded-2xl border border-selection/80 bg-bg/40 shadow-halo">
|
||||
<svg class="h-9 w-9 text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mb-2 font-display text-2xl font-semibold text-bright">No Active Sessions</h2>
|
||||
<p class="max-w-lg text-dim">
|
||||
Agent sessions will appear here when they connect. Start a Claude Code session to see it in the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
58
dashboard/components/Header.js
Normal file
58
dashboard/components/Header.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { html, useState, useEffect } from '../lib/preact.js';
|
||||
|
||||
export function Header({ sessions }) {
|
||||
const [clock, setClock] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setClock(new Date()), 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const counts = {
|
||||
attention: sessions.filter(s => s.status === 'needs_attention').length,
|
||||
active: sessions.filter(s => s.status === 'active').length,
|
||||
starting: sessions.filter(s => s.status === 'starting').length,
|
||||
done: sessions.filter(s => s.status === 'done').length,
|
||||
};
|
||||
const total = sessions.length;
|
||||
|
||||
return html`
|
||||
<header class="sticky top-0 z-50 px-4 pt-4 sm:px-6 sm:pt-6">
|
||||
<div class="glass-panel rounded-2xl px-4 py-4 sm:px-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-starting/40 bg-starting/10 px-3 py-1 text-micro font-medium uppercase tracking-[0.24em] text-starting">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-starting animate-float"></span>
|
||||
Control Plane
|
||||
</div>
|
||||
<h1 class="mt-3 truncate font-display text-xl font-semibold text-bright sm:text-2xl">
|
||||
Agent Mission Control
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-dim">
|
||||
${total} live session${total === 1 ? '' : 's'} • Updated ${clock.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4 sm:gap-3">
|
||||
<div class="rounded-xl border border-attention/40 bg-attention/12 px-3 py-2 text-attention">
|
||||
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.attention}</div>
|
||||
<div class="text-micro uppercase tracking-[0.16em]">Attention</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-active/40 bg-active/12 px-3 py-2 text-active">
|
||||
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.active}</div>
|
||||
<div class="text-micro uppercase tracking-[0.16em]">Active</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-starting/40 bg-starting/12 px-3 py-2 text-starting">
|
||||
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.starting}</div>
|
||||
<div class="text-micro uppercase tracking-[0.16em]">Starting</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-done/40 bg-done/12 px-3 py-2 text-done">
|
||||
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.done}</div>
|
||||
<div class="text-micro uppercase tracking-[0.16em]">Done</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
54
dashboard/components/MessageBubble.js
Normal file
54
dashboard/components/MessageBubble.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { html } from '../lib/preact.js';
|
||||
import { renderContent, renderToolCalls, renderThinking } from '../lib/markdown.js';
|
||||
|
||||
/**
|
||||
* Single message bubble used by both the card chat view and modal view.
|
||||
* All message rendering logic lives here — card and modal only differ in
|
||||
* container layout, not in how individual messages are rendered.
|
||||
*
|
||||
* @param {object} msg - Message object: { role, content, thinking, tool_calls, timestamp }
|
||||
* @param {string} userBg - Tailwind classes for user message background
|
||||
* @param {boolean} compact - true = card view (smaller), false = modal view (larger)
|
||||
* @param {function} formatTime - Optional timestamp formatter (modal only)
|
||||
*/
|
||||
export function MessageBubble({ msg, userBg, compact = false, formatTime }) {
|
||||
const isUser = msg.role === 'user';
|
||||
const pad = compact ? 'px-3 py-2.5' : 'px-4 py-3';
|
||||
const maxW = compact ? 'max-w-[92%]' : 'max-w-[86%]';
|
||||
|
||||
return html`
|
||||
<div class="flex ${isUser ? 'justify-end' : 'justify-start'} ${compact ? '' : 'animate-fade-in-up'}">
|
||||
<div
|
||||
class="${maxW} rounded-2xl ${pad} ${
|
||||
isUser
|
||||
? `${userBg} rounded-br-md shadow-[0_3px_8px_rgba(16,24,36,0.22)]`
|
||||
: 'border border-selection/75 bg-surface2/75 text-fg rounded-bl-md'
|
||||
}"
|
||||
>
|
||||
<div class="mb-1 font-mono text-micro uppercase tracking-[0.14em] text-dim">
|
||||
${isUser ? 'Operator' : 'Agent'}
|
||||
</div>
|
||||
${msg.thinking && renderThinking(msg.thinking)}
|
||||
<div class="whitespace-pre-wrap break-words text-ui font-chat">
|
||||
${renderContent(msg.content)}
|
||||
</div>
|
||||
${renderToolCalls(msg.tool_calls)}
|
||||
${formatTime && msg.timestamp && html`
|
||||
<div class="mt-2 font-mono text-label text-dim">
|
||||
${formatTime(msg.timestamp)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter messages for display — removes empty assistant messages
|
||||
* (no content, thinking, or tool_calls) that would render as empty bubbles.
|
||||
*/
|
||||
export function filterDisplayMessages(messages) {
|
||||
return messages.filter(msg =>
|
||||
msg.content || msg.thinking || msg.tool_calls?.length || msg.role === 'user'
|
||||
);
|
||||
}
|
||||
227
dashboard/components/Modal.js
Normal file
227
dashboard/components/Modal.js
Normal file
@@ -0,0 +1,227 @@
|
||||
import { html, useState, useEffect, useRef, useCallback } from '../lib/preact.js';
|
||||
import { getStatusMeta, getUserMessageBg } from '../utils/status.js';
|
||||
import { formatDuration, formatTime } from '../utils/formatting.js';
|
||||
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
|
||||
|
||||
export function Modal({ session, conversations, conversationLoading, onClose, onSendMessage, onRefreshConversation }) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
const [closing, setClosing] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
const wasAtBottomRef = useRef(true);
|
||||
const prevConversationLenRef = useRef(0);
|
||||
const chatContainerRef = useRef(null);
|
||||
|
||||
const conversation = session ? (conversations[session.session_id] || []) : [];
|
||||
|
||||
// Reset state when session changes
|
||||
useEffect(() => {
|
||||
setClosing(false);
|
||||
prevConversationLenRef.current = 0;
|
||||
}, [session?.session_id]);
|
||||
|
||||
// Animated close handler
|
||||
const handleClose = useCallback(() => {
|
||||
setClosing(true);
|
||||
setTimeout(() => {
|
||||
setClosing(false);
|
||||
onClose();
|
||||
}, 200);
|
||||
}, [onClose]);
|
||||
|
||||
// Track scroll position
|
||||
useEffect(() => {
|
||||
const container = chatContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const threshold = 50;
|
||||
wasAtBottomRef.current = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Only scroll to bottom on NEW messages, and only if user was already at bottom
|
||||
useEffect(() => {
|
||||
const container = chatContainerRef.current;
|
||||
if (!container || !conversation) return;
|
||||
|
||||
const hasNewMessages = conversation.length > prevConversationLenRef.current;
|
||||
prevConversationLenRef.current = conversation.length;
|
||||
|
||||
if (hasNewMessages && wasAtBottomRef.current) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [conversation]);
|
||||
|
||||
// Focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [session?.session_id]);
|
||||
|
||||
// Lock body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, [!!session]);
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
if (!session) return;
|
||||
const handleKeyDown = (e) => {
|
||||
if (e.key === 'Escape') handleClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [!!session, handleClose]);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const hasPendingQuestions = session.pending_questions && session.pending_questions.length > 0;
|
||||
const optionCount = hasPendingQuestions ? session.pending_questions[0]?.options?.length || 0 : 0;
|
||||
const status = getStatusMeta(session.status);
|
||||
const agent = session.agent === 'codex' ? 'codex' : 'claude';
|
||||
const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude';
|
||||
|
||||
const handleInputKeyDown = (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSend = async () => {
|
||||
const text = inputValue.trim();
|
||||
if (!text || sending) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
if (onSendMessage) {
|
||||
await onSendMessage(session.session_id, text, true, optionCount);
|
||||
}
|
||||
setInputValue('');
|
||||
if (onRefreshConversation) {
|
||||
await onRefreshConversation(session.session_id, session.project_dir, agent);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const displayMessages = filterDisplayMessages(conversation);
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm ${closing ? 'modal-backdrop-out' : 'modal-backdrop-in'}"
|
||||
onClick=${(e) => e.target === e.currentTarget && handleClose()}
|
||||
>
|
||||
<div
|
||||
class="glass-panel flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-selection/80 ${closing ? 'modal-panel-out' : 'modal-panel-in'}"
|
||||
style=${{ borderLeftWidth: '3px', borderLeftColor: status.borderColor }}
|
||||
onClick=${(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between border-b px-5 py-4 ${agentHeaderClass}">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="mb-1 flex items-center gap-3">
|
||||
<h2 class="truncate font-display text-xl font-semibold text-bright">${session.project || session.name || session.session_id}</h2>
|
||||
<div class="flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${status.badge}">
|
||||
<span class="h-2 w-2 rounded-full ${status.dot} ${status.spinning ? 'spinner-dot' : ''}" style=${{ color: status.borderColor }}></span>
|
||||
<span class="font-mono uppercase tracking-[0.14em]">${status.label}</span>
|
||||
</div>
|
||||
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${agent === 'codex' ? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300' : 'border-violet-400/45 bg-violet-500/14 text-violet-300'}">
|
||||
${agent}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm text-dim">
|
||||
<span class="truncate font-mono">${session.cwd || 'No working directory'}</span>
|
||||
${session.started_at && html`
|
||||
<span class="flex-shrink-0 font-mono">Running ${formatDuration(session.started_at)}</span>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="ml-4 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-selection/70 text-dim transition-colors duration-150 hover:border-done/35 hover:bg-done/10 hover:text-bright"
|
||||
onClick=${handleClose}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div ref=${chatContainerRef} class="flex-1 overflow-y-auto overflow-x-hidden bg-surface p-5">
|
||||
${conversationLoading ? html`
|
||||
<div class="flex items-center justify-center py-12 animate-fade-in-up">
|
||||
<div class="font-mono text-dim">Loading conversation...</div>
|
||||
</div>
|
||||
` : displayMessages.length > 0 ? html`
|
||||
<div class="space-y-4">
|
||||
${displayMessages.map((msg, i) => html`
|
||||
<${MessageBubble}
|
||||
key=${`${msg.role}-${msg.timestamp || i}`}
|
||||
msg=${msg}
|
||||
userBg=${getUserMessageBg(session.status)}
|
||||
compact=${false}
|
||||
formatTime=${formatTime}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
` : html`
|
||||
<p class="text-dim text-center py-12">No conversation messages</p>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="border-t border-selection/70 bg-bg/55 p-4">
|
||||
${hasPendingQuestions && html`
|
||||
<div class="mb-3 rounded-xl border border-attention/40 bg-attention/12 px-3 py-2 text-sm text-attention">
|
||||
Agent is waiting for a response
|
||||
</div>
|
||||
`}
|
||||
<div class="flex items-end gap-2.5">
|
||||
<textarea
|
||||
ref=${inputRef}
|
||||
value=${inputValue}
|
||||
onInput=${(e) => {
|
||||
setInputValue(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 150) + 'px';
|
||||
}}
|
||||
onKeyDown=${handleInputKeyDown}
|
||||
onFocus=${() => setInputFocused(true)}
|
||||
onBlur=${() => setInputFocused(false)}
|
||||
placeholder=${hasPendingQuestions ? "Type your response..." : "Send a message..."}
|
||||
rows="1"
|
||||
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-4 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
||||
style=${{ minHeight: '42px', maxHeight: '150px', borderColor: inputFocused ? status.borderColor : undefined }}
|
||||
disabled=${sending}
|
||||
/>
|
||||
<button
|
||||
class="rounded-xl px-4 py-2 font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style=${{ backgroundColor: status.borderColor, color: '#0a0f18' }}
|
||||
onClick=${handleSend}
|
||||
disabled=${sending || !inputValue.trim()}
|
||||
>
|
||||
${sending ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 font-mono text-label text-dim">
|
||||
Press Enter to send, Shift+Enter for new line, Escape to close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
18
dashboard/components/OptionButton.js
Normal file
18
dashboard/components/OptionButton.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { html } from '../lib/preact.js';
|
||||
|
||||
export function OptionButton({ number, label, description, onClick }) {
|
||||
return html`
|
||||
<button
|
||||
onClick=${onClick}
|
||||
class="group w-full rounded-xl border border-selection/70 bg-surface2/55 p-3.5 text-left transition-[transform,border-color,background-color,box-shadow] duration-200 hover:-translate-y-0.5 hover:border-starting/55 hover:bg-surface2/90 hover:shadow-halo"
|
||||
>
|
||||
<div class="flex items-baseline gap-2.5">
|
||||
<span class="font-mono text-starting">${number}.</span>
|
||||
<span class="font-medium text-bright">${label}</span>
|
||||
</div>
|
||||
${description && html`
|
||||
<p class="mt-1 pl-5 text-sm text-dim">${description}</p>
|
||||
`}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
94
dashboard/components/QuestionBlock.js
Normal file
94
dashboard/components/QuestionBlock.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { html, useState } from '../lib/preact.js';
|
||||
import { getStatusMeta } from '../utils/status.js';
|
||||
import { OptionButton } from './OptionButton.js';
|
||||
|
||||
export function QuestionBlock({ questions, sessionId, status, onRespond }) {
|
||||
const [freeformText, setFreeformText] = useState('');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const meta = getStatusMeta(status);
|
||||
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
// Only show the first question (sequential, not parallel)
|
||||
const question = questions[0];
|
||||
const remainingCount = questions.length - 1;
|
||||
const options = question.options || [];
|
||||
|
||||
const handleOptionClick = (optionLabel) => {
|
||||
onRespond(sessionId, optionLabel, false, options.length);
|
||||
};
|
||||
|
||||
const handleFreeformSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (freeformText.trim()) {
|
||||
onRespond(sessionId, freeformText.trim(), true, options.length);
|
||||
setFreeformText('');
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="space-y-3" onClick=${(e) => e.stopPropagation()}>
|
||||
<!-- Question Header Badge -->
|
||||
${question.header && html`
|
||||
<span class="inline-flex rounded-full border px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] ${meta.badge}">
|
||||
${question.header}
|
||||
</span>
|
||||
`}
|
||||
|
||||
<!-- Question Text -->
|
||||
<p class="font-medium text-bright">${question.question || question.text}</p>
|
||||
|
||||
<!-- Options -->
|
||||
${options.length > 0 && html`
|
||||
<div class="space-y-2">
|
||||
${options.map((opt, i) => html`
|
||||
<${OptionButton}
|
||||
key=${i}
|
||||
number=${i + 1}
|
||||
label=${opt.label || opt}
|
||||
description=${opt.description}
|
||||
onClick=${() => handleOptionClick(opt.label || opt)}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<!-- Freeform Input -->
|
||||
<form onSubmit=${handleFreeformSubmit} class="flex items-end gap-2.5">
|
||||
<textarea
|
||||
value=${freeformText}
|
||||
onInput=${(e) => {
|
||||
setFreeformText(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
}}
|
||||
onKeyDown=${(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleFreeformSubmit(e);
|
||||
}
|
||||
}}
|
||||
onFocus=${() => setFocused(true)}
|
||||
onBlur=${() => setFocused(false)}
|
||||
placeholder="Type a response..."
|
||||
rows="1"
|
||||
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
||||
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110"
|
||||
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- More Questions Indicator -->
|
||||
${remainingCount > 0 && html`
|
||||
<p class="font-mono text-label text-dim">+ ${remainingCount} more question${remainingCount > 1 ? 's' : ''} after this</p>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
111
dashboard/components/SessionCard.js
Normal file
111
dashboard/components/SessionCard.js
Normal file
@@ -0,0 +1,111 @@
|
||||
import { html, useEffect, useRef } from '../lib/preact.js';
|
||||
import { getStatusMeta } from '../utils/status.js';
|
||||
import { formatDuration, getContextUsageSummary } from '../utils/formatting.js';
|
||||
import { ChatMessages } from './ChatMessages.js';
|
||||
import { QuestionBlock } from './QuestionBlock.js';
|
||||
import { SimpleInput } from './SimpleInput.js';
|
||||
|
||||
export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss }) {
|
||||
const hasQuestions = session.pending_questions && session.pending_questions.length > 0;
|
||||
const statusMeta = getStatusMeta(session.status);
|
||||
const agent = session.agent === 'codex' ? 'codex' : 'claude';
|
||||
const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude';
|
||||
const contextUsage = getContextUsageSummary(session.context_usage);
|
||||
|
||||
// Fetch conversation when card mounts
|
||||
useEffect(() => {
|
||||
if (!conversation && onFetchConversation) {
|
||||
onFetchConversation(session.session_id, session.project_dir, agent);
|
||||
}
|
||||
}, [session.session_id, session.project_dir, agent, conversation, onFetchConversation]);
|
||||
|
||||
const chatPaneRef = useRef(null);
|
||||
|
||||
// Scroll chat pane to bottom when conversation loads or updates
|
||||
useEffect(() => {
|
||||
const el = chatPaneRef.current;
|
||||
if (el) el.scrollTop = el.scrollHeight;
|
||||
}, [conversation]);
|
||||
|
||||
const handleDismissClick = (e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(session.session_id);
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="glass-panel flex h-[850px] max-h-[850px] w-[600px] cursor-pointer flex-col overflow-hidden rounded-xl border border-selection/70 transition-[border-color,box-shadow] duration-200 hover:border-starting/35 hover:shadow-panel"
|
||||
style=${{ borderLeftWidth: '3px', borderLeftColor: statusMeta.borderColor }}
|
||||
onClick=${() => onClick(session)}
|
||||
>
|
||||
<!-- Card Header -->
|
||||
<div class="shrink-0 border-b px-4 py-3 ${agentHeaderClass}">
|
||||
<div class="flex items-start justify-between gap-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full ${statusMeta.dot} ${statusMeta.spinning ? 'spinner-dot' : ''}" style=${{ color: statusMeta.borderColor }}></span>
|
||||
<span class="truncate font-display text-base font-medium text-bright">${session.project || session.name || 'Session'}</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${statusMeta.badge}">
|
||||
${statusMeta.label}
|
||||
</span>
|
||||
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${agent === 'codex' ? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300' : 'border-violet-400/45 bg-violet-500/14 text-violet-300'}">
|
||||
${agent}
|
||||
</span>
|
||||
${session.cwd && html`
|
||||
<span class="truncate rounded-full border border-selection bg-bg/40 px-2.5 py-1 font-mono text-micro text-dim">
|
||||
${session.cwd.split('/').slice(-2).join('/')}
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
${contextUsage && html`
|
||||
<div class="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-selection/80 bg-bg/45 px-2.5 py-1.5 font-mono text-label text-dim" title=${contextUsage.title}>
|
||||
<span class="text-bright">${contextUsage.headline}</span>
|
||||
<span class="truncate">${contextUsage.detail}</span>
|
||||
${contextUsage.trail && html`<span class="text-dim/80">${contextUsage.trail}</span>`}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0 pt-0.5">
|
||||
<span class="font-mono text-xs tabular-nums text-dim">${formatDuration(session.started_at)}</span>
|
||||
${session.status === 'done' && html`
|
||||
<button
|
||||
onClick=${handleDismissClick}
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg border border-selection/80 text-dim transition-colors hover:border-done/40 hover:bg-done/10 hover:text-bright"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Content Area (Chat) -->
|
||||
<div ref=${chatPaneRef} class="flex-1 min-h-0 overflow-y-auto bg-surface px-4 py-4">
|
||||
<${ChatMessages} messages=${conversation || []} status=${session.status} />
|
||||
</div>
|
||||
|
||||
<!-- Card Footer (Input or Questions) -->
|
||||
<div class="shrink-0 border-t border-selection/70 bg-bg/55 p-4 ${hasQuestions ? 'max-h-[300px] overflow-y-auto' : ''}">
|
||||
${hasQuestions ? html`
|
||||
<${QuestionBlock}
|
||||
questions=${session.pending_questions}
|
||||
sessionId=${session.session_id}
|
||||
status=${session.status}
|
||||
onRespond=${onRespond}
|
||||
/>
|
||||
` : html`
|
||||
<${SimpleInput}
|
||||
sessionId=${session.session_id}
|
||||
status=${session.status}
|
||||
onRespond=${onRespond}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
56
dashboard/components/SessionGroup.js
Normal file
56
dashboard/components/SessionGroup.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { html } from '../lib/preact.js';
|
||||
import { getStatusMeta, STATUS_PRIORITY } from '../utils/status.js';
|
||||
import { SessionCard } from './SessionCard.js';
|
||||
|
||||
export function SessionGroup({ projectName, projectDir, sessions, onCardClick, conversations, onFetchConversation, onRespond, onDismiss }) {
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
// Status summary for chips
|
||||
const statusCounts = {};
|
||||
for (const s of sessions) {
|
||||
statusCounts[s.status] = (statusCounts[s.status] || 0) + 1;
|
||||
}
|
||||
|
||||
// Group header dot uses the most urgent status
|
||||
const worstStatus = sessions.reduce((worst, s) => {
|
||||
return (STATUS_PRIORITY[s.status] ?? 99) < (STATUS_PRIORITY[worst] ?? 99) ? s.status : worst;
|
||||
}, 'done');
|
||||
const worstMeta = getStatusMeta(worstStatus);
|
||||
|
||||
return html`
|
||||
<section class="mb-12">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2.5 border-b border-selection/50 pb-3">
|
||||
<span class="h-2.5 w-2.5 rounded-full ${worstMeta.dot}"></span>
|
||||
<h2 class="font-display text-body font-semibold text-bright">${projectName}</h2>
|
||||
<span class="rounded-full border border-selection/80 bg-bg/55 px-2 py-0.5 font-mono text-micro text-dim">
|
||||
${sessions.length} agent${sessions.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
${Object.entries(statusCounts).map(([status, count]) => {
|
||||
const meta = getStatusMeta(status);
|
||||
return html`
|
||||
<span key=${status} class="rounded-full border px-2 py-0.5 font-mono text-micro ${meta.badge}">
|
||||
${count} ${meta.label.toLowerCase()}
|
||||
</span>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
${projectDir && projectDir !== 'unknown' && html`
|
||||
<div class="-mt-2 mb-3 truncate font-mono text-micro text-dim/60">${projectDir}</div>
|
||||
`}
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
${sessions.map(session => html`
|
||||
<${SessionCard}
|
||||
key=${session.session_id}
|
||||
session=${session}
|
||||
onClick=${onCardClick}
|
||||
conversation=${conversations[session.session_id]}
|
||||
onFetchConversation=${onFetchConversation}
|
||||
onRespond=${onRespond}
|
||||
onDismiss=${onDismiss}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
102
dashboard/components/Sidebar.js
Normal file
102
dashboard/components/Sidebar.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { html } from '../lib/preact.js';
|
||||
import { getStatusMeta, STATUS_PRIORITY } from '../utils/status.js';
|
||||
|
||||
export function Sidebar({ projectGroups, selectedProject, onSelectProject, totalSessions }) {
|
||||
// Calculate totals for "All Projects"
|
||||
const allStatusCounts = { needs_attention: 0, active: 0, starting: 0, done: 0 };
|
||||
for (const group of projectGroups) {
|
||||
for (const s of group.sessions) {
|
||||
allStatusCounts[s.status] = (allStatusCounts[s.status] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Worst status across all projects
|
||||
const allWorstStatus = totalSessions > 0
|
||||
? Object.keys(allStatusCounts).reduce((worst, status) =>
|
||||
allStatusCounts[status] > 0 && (STATUS_PRIORITY[status] ?? 99) < (STATUS_PRIORITY[worst] ?? 99) ? status : worst
|
||||
, 'done')
|
||||
: 'done';
|
||||
const allWorstMeta = getStatusMeta(allWorstStatus);
|
||||
|
||||
// Tiny inline status indicator
|
||||
const StatusPips = ({ counts }) => html`
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
${counts.needs_attention > 0 && html`<span class="rounded-full bg-attention/20 px-1.5 py-0.5 font-mono text-micro tabular-nums text-attention">${counts.needs_attention}</span>`}
|
||||
${counts.active > 0 && html`<span class="rounded-full bg-active/20 px-1.5 py-0.5 font-mono text-micro tabular-nums text-active">${counts.active}</span>`}
|
||||
${counts.starting > 0 && html`<span class="rounded-full bg-starting/20 px-1.5 py-0.5 font-mono text-micro tabular-nums text-starting">${counts.starting}</span>`}
|
||||
${counts.done > 0 && html`<span class="rounded-full bg-done/15 px-1.5 py-0.5 font-mono text-micro tabular-nums text-done/70">${counts.done}</span>`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<aside class="fixed left-0 top-0 z-40 flex h-screen w-80 flex-col border-r border-selection/50 bg-surface/95 backdrop-blur-sm">
|
||||
<!-- Sidebar Header -->
|
||||
<div class="shrink-0 border-b border-selection/50 px-5 py-4">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-starting/40 bg-starting/10 px-2.5 py-0.5 text-micro font-medium uppercase tracking-[0.2em] text-starting">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-starting animate-float"></span>
|
||||
Control Plane
|
||||
</div>
|
||||
<h1 class="mt-2 font-display text-lg font-semibold text-bright">
|
||||
Agent Mission Control
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Project List -->
|
||||
<nav class="flex-1 overflow-y-auto px-3 py-3">
|
||||
<!-- All Projects -->
|
||||
<button
|
||||
onClick=${() => onSelectProject(null)}
|
||||
class="group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left transition-colors duration-150 ${
|
||||
selectedProject === null
|
||||
? 'bg-selection/50'
|
||||
: 'hover:bg-selection/25'
|
||||
}"
|
||||
>
|
||||
<span class="h-2 w-2 shrink-0 rounded-full ${allWorstMeta.dot}"></span>
|
||||
<span class="flex-1 truncate font-medium text-bright">All Projects</span>
|
||||
<${StatusPips} counts=${allStatusCounts} />
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-2 border-t border-selection/30"></div>
|
||||
|
||||
<!-- Individual Projects -->
|
||||
${projectGroups.map(group => {
|
||||
const statusCounts = { needs_attention: 0, active: 0, starting: 0, done: 0 };
|
||||
for (const s of group.sessions) {
|
||||
statusCounts[s.status] = (statusCounts[s.status] || 0) + 1;
|
||||
}
|
||||
|
||||
const worstStatus = group.sessions.reduce((worst, s) =>
|
||||
(STATUS_PRIORITY[s.status] ?? 99) < (STATUS_PRIORITY[worst] ?? 99) ? s.status : worst
|
||||
, 'done');
|
||||
const worstMeta = getStatusMeta(worstStatus);
|
||||
const isSelected = selectedProject === group.projectDir;
|
||||
|
||||
return html`
|
||||
<button
|
||||
key=${group.projectDir}
|
||||
onClick=${() => onSelectProject(group.projectDir)}
|
||||
class="group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left transition-colors duration-150 ${
|
||||
isSelected
|
||||
? 'bg-selection/50'
|
||||
: 'hover:bg-selection/25'
|
||||
}"
|
||||
>
|
||||
<span class="h-2 w-2 shrink-0 rounded-full ${worstMeta.dot}"></span>
|
||||
<span class="flex-1 truncate text-fg">${group.projectName}</span>
|
||||
<${StatusPips} counts=${statusCounts} />
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar Footer -->
|
||||
<div class="shrink-0 border-t border-selection/50 px-5 py-3">
|
||||
<div class="font-mono text-micro text-dim">
|
||||
${totalSessions} session${totalSessions === 1 ? '' : 's'} total
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
49
dashboard/components/SimpleInput.js
Normal file
49
dashboard/components/SimpleInput.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { html, useState } from '../lib/preact.js';
|
||||
import { getStatusMeta } from '../utils/status.js';
|
||||
|
||||
export function SimpleInput({ sessionId, status, onRespond }) {
|
||||
const [text, setText] = useState('');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const meta = getStatusMeta(status);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (text.trim()) {
|
||||
onRespond(sessionId, text.trim(), true, 0);
|
||||
setText('');
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<form onSubmit=${handleSubmit} class="flex items-end gap-2.5" onClick=${(e) => e.stopPropagation()}>
|
||||
<textarea
|
||||
value=${text}
|
||||
onInput=${(e) => {
|
||||
setText(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
}}
|
||||
onKeyDown=${(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
onFocus=${() => setFocused(true)}
|
||||
onBlur=${() => setFocused(false)}
|
||||
placeholder="Send a message..."
|
||||
rows="1"
|
||||
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg transition-colors duration-150 placeholder:text-dim focus:outline-none"
|
||||
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-[transform,filter] duration-150 hover:-translate-y-0.5 hover:brightness-110"
|
||||
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
107
dashboard/index.html
Normal file
107
dashboard/index.html
Normal file
@@ -0,0 +1,107 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Agent Mission Control</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&family=Space+Grotesk:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
|
||||
<!-- Tailwind CDN -->
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
bg: '#01040b',
|
||||
surface: '#070d18',
|
||||
surface2: '#0d1830',
|
||||
selection: '#223454',
|
||||
fg: '#e0ebff',
|
||||
bright: '#fbfdff',
|
||||
dim: '#8ba3cc',
|
||||
active: '#5fd0a4',
|
||||
attention: '#e0b45e',
|
||||
starting: '#7cb2ff',
|
||||
done: '#e39a8c',
|
||||
},
|
||||
fontFamily: {
|
||||
display: ['Space Grotesk', 'IBM Plex Sans', 'sans-serif'],
|
||||
sans: ['IBM Plex Sans', 'system-ui', 'sans-serif'],
|
||||
mono: ['IBM Plex Mono', 'SFMono-Regular', 'Menlo', 'monospace'],
|
||||
chat: ['JetBrains Mono', 'IBM Plex Mono', 'monospace'],
|
||||
},
|
||||
fontSize: {
|
||||
micro: ['clamp(0.68rem, 0.05vw + 0.66rem, 0.78rem)', { lineHeight: '1.35' }],
|
||||
label: ['clamp(0.76rem, 0.07vw + 0.74rem, 0.86rem)', { lineHeight: '1.4' }],
|
||||
ui: ['clamp(0.84rem, 0.09vw + 0.81rem, 0.94rem)', { lineHeight: '1.45' }],
|
||||
body: ['clamp(0.88rem, 0.1vw + 0.85rem, 0.98rem)', { lineHeight: '1.55' }],
|
||||
chat: ['clamp(0.92rem, 0.12vw + 0.89rem, 1.02rem)', { lineHeight: '1.6' }],
|
||||
},
|
||||
boxShadow: {
|
||||
panel: '0 8px 18px rgba(10, 14, 20, 0.28)',
|
||||
halo: '0 0 0 1px rgba(117, 138, 166, 0.12), 0 6px 14px rgba(10, 14, 20, 0.24)',
|
||||
},
|
||||
keyframes: {
|
||||
float: {
|
||||
'0%, 100%': { transform: 'translateY(0)' },
|
||||
'50%': { transform: 'translateY(-4px)' },
|
||||
},
|
||||
fadeInUp: {
|
||||
'0%': { opacity: '0', transform: 'translateY(10px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
float: 'float 6s ease-in-out infinite',
|
||||
'fade-in-up': 'fadeInUp 0.35s ease-out',
|
||||
},
|
||||
}
|
||||
},
|
||||
safelist: [
|
||||
'bg-attention/30',
|
||||
'bg-active/30',
|
||||
'bg-starting/30',
|
||||
'bg-done/30',
|
||||
'bg-attention/18',
|
||||
'bg-active/18',
|
||||
'bg-starting/18',
|
||||
'bg-done/18',
|
||||
'bg-selection/80',
|
||||
'border-attention/40',
|
||||
'border-active/40',
|
||||
'border-starting/40',
|
||||
'border-done/40',
|
||||
'border-l-attention',
|
||||
'border-l-active',
|
||||
'border-l-starting',
|
||||
'border-l-done',
|
||||
'text-attention',
|
||||
'text-active',
|
||||
'text-starting',
|
||||
'text-done',
|
||||
'border-emerald-500/30',
|
||||
'bg-emerald-500/10',
|
||||
'text-emerald-400',
|
||||
'border-emerald-400/45',
|
||||
'bg-emerald-500/14',
|
||||
'text-emerald-300',
|
||||
'border-violet-500/30',
|
||||
'bg-violet-500/10',
|
||||
'text-violet-400',
|
||||
'border-violet-400/45',
|
||||
'bg-violet-500/14',
|
||||
'text-violet-300',
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body class="min-h-screen text-fg antialiased">
|
||||
<div id="app"></div>
|
||||
<script type="module" src="main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
159
dashboard/lib/markdown.js
Normal file
159
dashboard/lib/markdown.js
Normal file
@@ -0,0 +1,159 @@
|
||||
// Markdown rendering with syntax highlighting
|
||||
import { h } from 'https://esm.sh/preact@10.19.3';
|
||||
import { marked } from 'https://esm.sh/marked@15.0.7';
|
||||
import DOMPurify from 'https://esm.sh/dompurify@3.2.4';
|
||||
import hljs from 'https://esm.sh/highlight.js@11.11.1/lib/core';
|
||||
import langJavascript from 'https://esm.sh/highlight.js@11.11.1/lib/languages/javascript';
|
||||
import langTypescript from 'https://esm.sh/highlight.js@11.11.1/lib/languages/typescript';
|
||||
import langBash from 'https://esm.sh/highlight.js@11.11.1/lib/languages/bash';
|
||||
import langJson from 'https://esm.sh/highlight.js@11.11.1/lib/languages/json';
|
||||
import langPython from 'https://esm.sh/highlight.js@11.11.1/lib/languages/python';
|
||||
import langRust from 'https://esm.sh/highlight.js@11.11.1/lib/languages/rust';
|
||||
import langCss from 'https://esm.sh/highlight.js@11.11.1/lib/languages/css';
|
||||
import langXml from 'https://esm.sh/highlight.js@11.11.1/lib/languages/xml';
|
||||
import langSql from 'https://esm.sh/highlight.js@11.11.1/lib/languages/sql';
|
||||
import langYaml from 'https://esm.sh/highlight.js@11.11.1/lib/languages/yaml';
|
||||
import htm from 'https://esm.sh/htm@3.1.1';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
// Register highlight.js languages
|
||||
hljs.registerLanguage('javascript', langJavascript);
|
||||
hljs.registerLanguage('js', langJavascript);
|
||||
hljs.registerLanguage('typescript', langTypescript);
|
||||
hljs.registerLanguage('ts', langTypescript);
|
||||
hljs.registerLanguage('bash', langBash);
|
||||
hljs.registerLanguage('sh', langBash);
|
||||
hljs.registerLanguage('shell', langBash);
|
||||
hljs.registerLanguage('json', langJson);
|
||||
hljs.registerLanguage('python', langPython);
|
||||
hljs.registerLanguage('py', langPython);
|
||||
hljs.registerLanguage('rust', langRust);
|
||||
hljs.registerLanguage('css', langCss);
|
||||
hljs.registerLanguage('html', langXml);
|
||||
hljs.registerLanguage('xml', langXml);
|
||||
hljs.registerLanguage('sql', langSql);
|
||||
hljs.registerLanguage('yaml', langYaml);
|
||||
hljs.registerLanguage('yml', langYaml);
|
||||
|
||||
// Configure marked with highlight.js using custom renderer (v15 API)
|
||||
const renderer = {
|
||||
code(token) {
|
||||
const code = token.text;
|
||||
const lang = token.lang || '';
|
||||
let highlighted;
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
highlighted = hljs.highlight(code, { language: lang }).value;
|
||||
} else {
|
||||
highlighted = hljs.highlightAuto(code).value;
|
||||
}
|
||||
return `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>`;
|
||||
}
|
||||
};
|
||||
marked.use({ renderer, breaks: false, gfm: true });
|
||||
|
||||
// Render markdown content with syntax highlighting
|
||||
// All HTML is sanitized with DOMPurify before rendering to prevent XSS
|
||||
export function renderContent(content) {
|
||||
if (!content) return '';
|
||||
const rawHtml = marked.parse(content);
|
||||
const safeHtml = DOMPurify.sanitize(rawHtml);
|
||||
return html`<div class="md-content" dangerouslySetInnerHTML=${{ __html: safeHtml }} />`;
|
||||
}
|
||||
|
||||
// Generate a short summary for a tool call based on name + input.
|
||||
// Uses heuristics to extract meaningful info from common input patterns
|
||||
// rather than hardcoding specific tool names.
|
||||
function getToolSummary(name, input) {
|
||||
if (!input || typeof input !== 'object') return name;
|
||||
|
||||
// Helper to safely get string value and slice it
|
||||
const str = (val, len) => typeof val === 'string' ? val.slice(0, len) : null;
|
||||
|
||||
// Try to extract a meaningful summary from common input patterns
|
||||
// Priority order matters - more specific/useful fields first
|
||||
|
||||
// 1. Explicit description or summary
|
||||
let s = str(input.description, 60) || str(input.summary, 60);
|
||||
if (s) return s;
|
||||
|
||||
// 2. Command/shell execution
|
||||
s = str(input.command, 60) || str(input.cmd, 60);
|
||||
if (s) return s;
|
||||
|
||||
// 3. File paths - show last 2 segments for context
|
||||
const pathKeys = ['file_path', 'path', 'file', 'filename', 'filepath'];
|
||||
for (const key of pathKeys) {
|
||||
if (typeof input[key] === 'string' && input[key]) {
|
||||
return input[key].split('/').slice(-2).join('/');
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Search patterns
|
||||
if (typeof input.pattern === 'string' && input.pattern) {
|
||||
const glob = typeof input.glob === 'string' ? ` ${input.glob}` : '';
|
||||
return `/${input.pattern.slice(0, 40)}/${glob}`.trim();
|
||||
}
|
||||
s = str(input.query, 50) || str(input.search, 50);
|
||||
if (s) return s;
|
||||
if (typeof input.regex === 'string' && input.regex) return `/${input.regex.slice(0, 40)}/`;
|
||||
|
||||
// 5. URL/endpoint
|
||||
s = str(input.url, 60) || str(input.endpoint, 60);
|
||||
if (s) return s;
|
||||
|
||||
// 6. Name/title fields
|
||||
if (typeof input.name === 'string' && input.name && input.name !== name) return input.name.slice(0, 50);
|
||||
s = str(input.title, 50);
|
||||
if (s) return s;
|
||||
|
||||
// 7. Message/content (for chat/notification tools)
|
||||
s = str(input.message, 50) || str(input.content, 50);
|
||||
if (s) return s;
|
||||
|
||||
// 8. First string value as fallback (skip very long values)
|
||||
for (const [key, value] of Object.entries(input)) {
|
||||
if (typeof value === 'string' && value.length > 0 && value.length < 100) {
|
||||
return value.slice(0, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// No useful summary found
|
||||
return name;
|
||||
}
|
||||
|
||||
// Render tool call pills (summary mode)
|
||||
export function renderToolCalls(toolCalls) {
|
||||
if (!toolCalls || toolCalls.length === 0) return '';
|
||||
return html`
|
||||
<div class="flex flex-wrap gap-1.5 mt-1.5">
|
||||
${toolCalls.map(tc => {
|
||||
const summary = getToolSummary(tc.name, tc.input);
|
||||
return html`
|
||||
<span class="inline-flex items-center gap-1 rounded-md border border-starting/30 bg-starting/10 px-2 py-0.5 font-mono text-label text-starting">
|
||||
<span class="font-medium">${tc.name}</span>
|
||||
${summary !== tc.name && html`<span class="text-starting/65 truncate max-w-[200px]">${summary}</span>`}
|
||||
</span>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render thinking block (full content, open by default)
|
||||
// Content is sanitized with DOMPurify before rendering
|
||||
export function renderThinking(thinking) {
|
||||
if (!thinking) return '';
|
||||
const rawHtml = marked.parse(thinking);
|
||||
const safeHtml = DOMPurify.sanitize(rawHtml);
|
||||
return html`
|
||||
<details class="mt-2 rounded-lg border border-violet-400/25 bg-violet-500/8" open>
|
||||
<summary class="cursor-pointer select-none px-3 py-1.5 font-mono text-label uppercase tracking-[0.14em] text-violet-300/80 hover:text-violet-200">
|
||||
Thinking
|
||||
</summary>
|
||||
<div class="border-t border-violet-400/15 px-3 py-2 text-label text-dim/90 font-chat leading-relaxed">
|
||||
<div class="md-content" dangerouslySetInnerHTML=${{ __html: safeHtml }} />
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
7
dashboard/lib/preact.js
Normal file
7
dashboard/lib/preact.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Re-export Preact and htm for consistent imports across components
|
||||
export { h, render } from 'https://esm.sh/preact@10.19.3';
|
||||
export { useState, useEffect, useRef, useCallback, useMemo } from 'https://esm.sh/preact@10.19.3/hooks';
|
||||
import { h } from 'https://esm.sh/preact@10.19.3';
|
||||
import htm from 'https://esm.sh/htm@3.1.1';
|
||||
|
||||
export const html = htm.bind(h);
|
||||
7
dashboard/main.js
Normal file
7
dashboard/main.js
Normal file
@@ -0,0 +1,7 @@
|
||||
// Dashboard entry point
|
||||
import { render } from './lib/preact.js';
|
||||
import { html } from './lib/preact.js';
|
||||
import { App } from './components/App.js';
|
||||
|
||||
// Mount the app
|
||||
render(html`<${App} />`, document.getElementById('app'));
|
||||
396
dashboard/styles.css
Normal file
396
dashboard/styles.css
Normal file
@@ -0,0 +1,396 @@
|
||||
/* AMC Dashboard Styles */
|
||||
|
||||
html {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
:root {
|
||||
--bg-flat: #01040b;
|
||||
--glass-border: rgba(116, 154, 214, 0.22);
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #445f8e #0a1222;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
||||
background: var(--bg-flat);
|
||||
min-height: 100vh;
|
||||
color: #e0ebff;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
#app {
|
||||
position: relative;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
#app > *:not(.fixed) {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: #0a1222;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #445f8e;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #5574aa;
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes pulse-attention {
|
||||
0%, 100% {
|
||||
opacity: 1;
|
||||
transform: scale(1) translateY(0);
|
||||
}
|
||||
50% {
|
||||
opacity: 0.62;
|
||||
transform: scale(1.04) translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-attention {
|
||||
animation: pulse-attention 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Active session spinner */
|
||||
@keyframes spin-ring {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.spinner-dot {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.spinner-dot::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
inset: -2px;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid transparent;
|
||||
border-top-color: currentColor;
|
||||
will-change: transform;
|
||||
animation: spin-ring 0.9s cubic-bezier(0.645, 0.045, 0.355, 1) infinite;
|
||||
}
|
||||
|
||||
/* Working indicator at bottom of chat */
|
||||
@keyframes bounce-dot {
|
||||
0%, 80%, 100% { transform: translateY(0); }
|
||||
40% { transform: translateY(-4px); }
|
||||
}
|
||||
|
||||
.working-dots span {
|
||||
display: inline-block;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.working-dots span:nth-child(1) { animation: bounce-dot 1.2s ease-out infinite; }
|
||||
.working-dots span:nth-child(2) { animation: bounce-dot 1.2s ease-out 0.15s infinite; }
|
||||
.working-dots span:nth-child(3) { animation: bounce-dot 1.2s ease-out 0.3s infinite; }
|
||||
|
||||
/* Modal entrance/exit animations */
|
||||
@keyframes modalBackdropIn {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
@keyframes modalBackdropOut {
|
||||
from { opacity: 1; }
|
||||
to { opacity: 0; }
|
||||
}
|
||||
@keyframes modalPanelIn {
|
||||
from { opacity: 0; transform: scale(0.96) translateY(8px); }
|
||||
to { opacity: 1; transform: scale(1) translateY(0); }
|
||||
}
|
||||
@keyframes modalPanelOut {
|
||||
from { opacity: 1; transform: scale(1) translateY(0); }
|
||||
to { opacity: 0; transform: scale(0.96) translateY(8px); }
|
||||
}
|
||||
|
||||
.modal-backdrop-in {
|
||||
animation: modalBackdropIn 200ms ease-out;
|
||||
}
|
||||
.modal-backdrop-out {
|
||||
animation: modalBackdropOut 200ms ease-in forwards;
|
||||
}
|
||||
.modal-panel-in {
|
||||
animation: modalPanelIn 200ms ease-out;
|
||||
}
|
||||
.modal-panel-out {
|
||||
animation: modalPanelOut 200ms ease-in forwards;
|
||||
}
|
||||
|
||||
/* Accessibility: disable continuous animations for motion-sensitive users */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.spinner-dot::after {
|
||||
animation: none;
|
||||
}
|
||||
.working-dots span {
|
||||
animation: none;
|
||||
}
|
||||
.pulse-attention {
|
||||
animation: none;
|
||||
}
|
||||
.modal-backdrop-in,
|
||||
.modal-backdrop-out,
|
||||
.modal-panel-in,
|
||||
.modal-panel-out {
|
||||
animation: none;
|
||||
}
|
||||
.animate-float,
|
||||
.animate-fade-in-up {
|
||||
animation: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Glass panel effect */
|
||||
.glass-panel {
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--glass-border);
|
||||
background: rgba(7, 13, 24, 0.95);
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.36), inset 0 1px 0 rgba(151, 185, 245, 0.05);
|
||||
}
|
||||
|
||||
/* Agent header variants */
|
||||
.agent-header-codex {
|
||||
background: rgba(20, 60, 54, 0.4);
|
||||
border-bottom-color: rgba(116, 227, 196, 0.34);
|
||||
}
|
||||
|
||||
.agent-header-claude {
|
||||
background: rgba(45, 36, 78, 0.42);
|
||||
border-bottom-color: rgba(179, 154, 255, 0.36);
|
||||
}
|
||||
|
||||
/* Markdown content styling */
|
||||
.md-content {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.md-content > *:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.md-content > *:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.md-content h1, .md-content h2, .md-content h3,
|
||||
.md-content h4, .md-content h5, .md-content h6 {
|
||||
font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif;
|
||||
font-weight: 600;
|
||||
color: #fbfdff;
|
||||
margin: 0.6em 0 0.25em;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.md-content h1 { font-size: 1.4em; }
|
||||
.md-content h2 { font-size: 1.25em; }
|
||||
.md-content h3 { font-size: 1.1em; }
|
||||
.md-content h4, .md-content h5, .md-content h6 { font-size: 1em; }
|
||||
|
||||
.md-content p {
|
||||
margin: 0.25em 0;
|
||||
}
|
||||
|
||||
.md-content p:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.md-content strong {
|
||||
color: #fbfdff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.md-content em {
|
||||
color: #c8d8f0;
|
||||
}
|
||||
|
||||
.md-content a {
|
||||
color: #7cb2ff;
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 2px;
|
||||
}
|
||||
|
||||
.md-content a:hover {
|
||||
color: #a8ccff;
|
||||
}
|
||||
|
||||
.md-content code {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 0.9em;
|
||||
background: rgba(1, 4, 11, 0.55);
|
||||
border: 1px solid rgba(34, 52, 84, 0.8);
|
||||
border-radius: 4px;
|
||||
padding: 0.15em 0.4em;
|
||||
}
|
||||
|
||||
.md-content pre {
|
||||
margin: 0.4em 0;
|
||||
padding: 0.6rem 0.8rem;
|
||||
background: rgba(1, 4, 11, 0.65);
|
||||
border: 1px solid rgba(34, 52, 84, 0.75);
|
||||
border-radius: 0.75rem;
|
||||
overflow-x: auto;
|
||||
font-size: 0.85em;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.md-content pre code {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
.md-content ul, .md-content ol {
|
||||
margin: 0.35em 0;
|
||||
padding-left: 1.5em;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.md-content li {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.md-content li p {
|
||||
margin: 0;
|
||||
display: inline;
|
||||
}
|
||||
|
||||
.md-content li > ul, .md-content li > ol {
|
||||
margin: 0.1em 0;
|
||||
}
|
||||
|
||||
.md-content blockquote {
|
||||
margin: 0.4em 0;
|
||||
padding: 0.4em 0.8em;
|
||||
border-left: 3px solid rgba(124, 178, 255, 0.5);
|
||||
background: rgba(34, 52, 84, 0.25);
|
||||
border-radius: 0 0.5rem 0.5rem 0;
|
||||
color: #c8d8f0;
|
||||
}
|
||||
|
||||
.md-content hr {
|
||||
border: none;
|
||||
border-top: 1px solid rgba(34, 52, 84, 0.6);
|
||||
margin: 0.6em 0;
|
||||
}
|
||||
|
||||
.md-content table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 0.75em 0;
|
||||
font-size: 0.9em;
|
||||
}
|
||||
|
||||
.md-content th, .md-content td {
|
||||
border: 1px solid rgba(34, 52, 84, 0.6);
|
||||
padding: 0.5em 0.75em;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.md-content th {
|
||||
background: rgba(34, 52, 84, 0.35);
|
||||
font-weight: 600;
|
||||
color: #fbfdff;
|
||||
}
|
||||
|
||||
.md-content tr:nth-child(even) {
|
||||
background: rgba(34, 52, 84, 0.15);
|
||||
}
|
||||
|
||||
/* Highlight.js syntax theme (dark) */
|
||||
.hljs {
|
||||
color: #e0ebff;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-built_in,
|
||||
.hljs-name,
|
||||
.hljs-tag {
|
||||
color: #c792ea;
|
||||
}
|
||||
|
||||
.hljs-string,
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-attribute,
|
||||
.hljs-literal,
|
||||
.hljs-template-tag,
|
||||
.hljs-template-variable,
|
||||
.hljs-type,
|
||||
.hljs-addition {
|
||||
color: #c3e88d;
|
||||
}
|
||||
|
||||
.hljs-comment,
|
||||
.hljs-quote,
|
||||
.hljs-deletion,
|
||||
.hljs-meta {
|
||||
color: #697098;
|
||||
}
|
||||
|
||||
.hljs-keyword,
|
||||
.hljs-selector-tag,
|
||||
.hljs-literal,
|
||||
.hljs-title,
|
||||
.hljs-section,
|
||||
.hljs-doctag,
|
||||
.hljs-type,
|
||||
.hljs-name,
|
||||
.hljs-strong {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hljs-number,
|
||||
.hljs-selector-id,
|
||||
.hljs-selector-class,
|
||||
.hljs-quote,
|
||||
.hljs-template-tag,
|
||||
.hljs-deletion {
|
||||
color: #f78c6c;
|
||||
}
|
||||
|
||||
.hljs-title.function_,
|
||||
.hljs-subst,
|
||||
.hljs-symbol,
|
||||
.hljs-bullet,
|
||||
.hljs-link {
|
||||
color: #82aaff;
|
||||
}
|
||||
|
||||
.hljs-selector-attr,
|
||||
.hljs-selector-pseudo,
|
||||
.hljs-variable,
|
||||
.hljs-template-variable {
|
||||
color: #ffcb6b;
|
||||
}
|
||||
|
||||
.hljs-attr {
|
||||
color: #89ddff;
|
||||
}
|
||||
|
||||
.hljs-regexp,
|
||||
.hljs-link {
|
||||
color: #89ddff;
|
||||
}
|
||||
|
||||
.hljs-emphasis {
|
||||
font-style: italic;
|
||||
}
|
||||
20
dashboard/utils/api.js
Normal file
20
dashboard/utils/api.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// API Constants
|
||||
export const API_STATE = '/api/state';
|
||||
export const API_STREAM = '/api/stream';
|
||||
export const API_DISMISS = '/api/dismiss/';
|
||||
export const API_RESPOND = '/api/respond/';
|
||||
export const API_CONVERSATION = '/api/conversation/';
|
||||
export const POLL_MS = 3000;
|
||||
export const API_TIMEOUT_MS = 10000;
|
||||
|
||||
// Fetch with timeout to prevent hanging requests
|
||||
export async function fetchWithTimeout(url, options = {}, timeoutMs = API_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
66
dashboard/utils/formatting.js
Normal file
66
dashboard/utils/formatting.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// Formatting utilities
|
||||
|
||||
export function formatDuration(isoStart) {
|
||||
if (!isoStart) return '';
|
||||
const start = new Date(isoStart);
|
||||
const now = new Date();
|
||||
const mins = Math.max(0, Math.floor((now - start) / 60000));
|
||||
if (mins < 60) return mins + 'm';
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
return hrs + 'h ' + remainMins + 'm';
|
||||
}
|
||||
|
||||
export function formatTime(isoTime) {
|
||||
if (!isoTime) return '';
|
||||
const date = new Date(isoTime);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTokenCount(value) {
|
||||
if (!Number.isFinite(value)) return '';
|
||||
return Math.round(value).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function getContextUsageSummary(usage) {
|
||||
if (!usage || typeof usage !== 'object') return null;
|
||||
|
||||
const current = Number(usage.current_tokens);
|
||||
const windowTokens = Number(usage.window_tokens);
|
||||
const sessionTotal = usage.session_total_tokens != null ? Number(usage.session_total_tokens) : null;
|
||||
const hasCurrent = Number.isFinite(current) && current > 0;
|
||||
const hasWindow = Number.isFinite(windowTokens) && windowTokens > 0;
|
||||
const hasSessionTotal = sessionTotal != null && Number.isFinite(sessionTotal) && sessionTotal > 0;
|
||||
|
||||
if (hasCurrent && hasWindow) {
|
||||
const percent = (current / windowTokens) * 100;
|
||||
return {
|
||||
headline: `${percent >= 10 ? percent.toFixed(0) : percent.toFixed(1)}% ctx`,
|
||||
detail: `${formatTokenCount(current)} / ${formatTokenCount(windowTokens)}`,
|
||||
trail: hasSessionTotal ? `Σ ${formatTokenCount(sessionTotal)}` : '',
|
||||
title: `Context window usage: ${formatTokenCount(current)} / ${formatTokenCount(windowTokens)} tokens`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasCurrent) {
|
||||
const inputTokens = Number(usage.input_tokens);
|
||||
const outputTokens = Number(usage.output_tokens);
|
||||
const hasInput = Number.isFinite(inputTokens);
|
||||
const hasOutput = Number.isFinite(outputTokens);
|
||||
const ioDetail = hasInput || hasOutput
|
||||
? ` • in ${formatTokenCount(hasInput ? inputTokens : 0)} out ${formatTokenCount(hasOutput ? outputTokens : 0)}`
|
||||
: '';
|
||||
return {
|
||||
headline: 'Ctx usage',
|
||||
detail: `${formatTokenCount(current)} tok${ioDetail}`,
|
||||
trail: '',
|
||||
title: `Token usage: ${formatTokenCount(current)} tokens`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
100
dashboard/utils/status.js
Normal file
100
dashboard/utils/status.js
Normal file
@@ -0,0 +1,100 @@
|
||||
// Status-related utilities
|
||||
|
||||
export const STATUS_PRIORITY = {
|
||||
needs_attention: 0,
|
||||
active: 1,
|
||||
starting: 2,
|
||||
done: 3
|
||||
};
|
||||
|
||||
export function getStatusMeta(status) {
|
||||
switch (status) {
|
||||
case 'needs_attention':
|
||||
return {
|
||||
label: 'Needs attention',
|
||||
dot: 'bg-attention pulse-attention',
|
||||
badge: 'bg-attention/18 text-attention border-attention/40',
|
||||
borderColor: '#e0b45e',
|
||||
};
|
||||
case 'active':
|
||||
return {
|
||||
label: 'Active',
|
||||
dot: 'bg-active',
|
||||
badge: 'bg-active/18 text-active border-active/40',
|
||||
borderColor: '#5fd0a4',
|
||||
spinning: true,
|
||||
};
|
||||
case 'starting':
|
||||
return {
|
||||
label: 'Starting',
|
||||
dot: 'bg-starting',
|
||||
badge: 'bg-starting/18 text-starting border-starting/40',
|
||||
borderColor: '#7cb2ff',
|
||||
spinning: true,
|
||||
};
|
||||
case 'done':
|
||||
return {
|
||||
label: 'Done',
|
||||
dot: 'bg-done',
|
||||
badge: 'bg-done/18 text-done border-done/40',
|
||||
borderColor: '#e39a8c',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: status || 'Unknown',
|
||||
dot: 'bg-dim',
|
||||
badge: 'bg-selection text-dim border-selection',
|
||||
borderColor: '#223454',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserMessageBg(status) {
|
||||
switch (status) {
|
||||
case 'needs_attention': return 'bg-attention/20 border border-attention/35 text-bright';
|
||||
case 'active': return 'bg-active/20 border border-active/30 text-bright';
|
||||
case 'starting': return 'bg-starting/20 border border-starting/30 text-bright';
|
||||
case 'done': return 'bg-done/20 border border-done/30 text-bright';
|
||||
default: return 'bg-selection/80 border border-selection text-bright';
|
||||
}
|
||||
}
|
||||
|
||||
export function groupSessionsByProject(sessions) {
|
||||
const groups = new Map();
|
||||
|
||||
for (const session of sessions) {
|
||||
const key = session.project_dir || session.cwd || 'unknown';
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
projectDir: key,
|
||||
projectName: session.project || key.split('/').pop() || 'Unknown',
|
||||
sessions: [],
|
||||
});
|
||||
}
|
||||
groups.get(key).sessions.push(session);
|
||||
}
|
||||
|
||||
const result = Array.from(groups.values());
|
||||
|
||||
// Sort groups: most urgent status first, then most recent activity
|
||||
result.sort((a, b) => {
|
||||
const aWorst = Math.min(...a.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
|
||||
const bWorst = Math.min(...b.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
|
||||
if (aWorst !== bWorst) return aWorst - bWorst;
|
||||
const aRecent = Math.max(...a.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
|
||||
const bRecent = Math.max(...b.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
|
||||
return bRecent - aRecent;
|
||||
});
|
||||
|
||||
// Sort sessions within each group: urgent first, then most recent
|
||||
for (const group of result) {
|
||||
group.sessions.sort((a, b) => {
|
||||
const aPri = STATUS_PRIORITY[a.status] ?? 99;
|
||||
const bPri = STATUS_PRIORITY[b.status] ?? 99;
|
||||
if (aPri !== bPri) return aPri - bPri;
|
||||
return (b.last_event_at || '').localeCompare(a.last_event_at || '');
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
20
tests/test_context.py
Normal file
20
tests/test_context.py
Normal file
@@ -0,0 +1,20 @@
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
from amc_server.context import _resolve_zellij_bin
|
||||
|
||||
|
||||
class ContextTests(unittest.TestCase):
|
||||
def test_resolve_zellij_bin_prefers_which(self):
|
||||
with patch("amc_server.context.shutil.which", return_value="/custom/bin/zellij"):
|
||||
self.assertEqual(_resolve_zellij_bin(), "/custom/bin/zellij")
|
||||
|
||||
def test_resolve_zellij_bin_falls_back_to_default_name(self):
|
||||
with patch("amc_server.context.shutil.which", return_value=None), patch(
|
||||
"amc_server.context.Path.exists", return_value=False
|
||||
), patch("amc_server.context.Path.is_file", return_value=False):
|
||||
self.assertEqual(_resolve_zellij_bin(), "zellij")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
176
tests/test_control.py
Normal file
176
tests/test_control.py
Normal file
@@ -0,0 +1,176 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import amc_server.mixins.control as control
|
||||
from amc_server.mixins.control import SessionControlMixin
|
||||
|
||||
|
||||
class DummyControlHandler(SessionControlMixin):
|
||||
def __init__(self, body=None):
|
||||
if body is None:
|
||||
body = {}
|
||||
raw = json.dumps(body).encode("utf-8")
|
||||
self.headers = {"Content-Length": str(len(raw))}
|
||||
self.rfile = io.BytesIO(raw)
|
||||
self.sent = []
|
||||
self.errors = []
|
||||
|
||||
def _send_json(self, code, payload):
|
||||
self.sent.append((code, payload))
|
||||
|
||||
def _json_error(self, code, message):
|
||||
self.errors.append((code, message))
|
||||
|
||||
|
||||
class SessionControlMixinTests(unittest.TestCase):
|
||||
def _write_session(self, sessions_dir: Path, session_id: str, zellij_session="infra", zellij_pane="21"):
|
||||
sessions_dir.mkdir(parents=True, exist_ok=True)
|
||||
session_file = sessions_dir / f"{session_id}.json"
|
||||
session_file.write_text(
|
||||
json.dumps({
|
||||
"session_id": session_id,
|
||||
"zellij_session": zellij_session,
|
||||
"zellij_pane": zellij_pane,
|
||||
})
|
||||
)
|
||||
|
||||
def test_inject_text_then_enter_is_two_step_with_delay(self):
|
||||
handler = DummyControlHandler()
|
||||
calls = []
|
||||
|
||||
def fake_inject(zellij_session, pane_id, text, send_enter=True):
|
||||
calls.append((zellij_session, pane_id, text, send_enter))
|
||||
return {"ok": True}
|
||||
|
||||
handler._inject_to_pane = fake_inject
|
||||
|
||||
with patch("amc_server.mixins.control.time.sleep") as sleep_mock, patch.dict(os.environ, {}, clear=True):
|
||||
result = handler._inject_text_then_enter("infra", 24, "testing")
|
||||
|
||||
self.assertEqual(result, {"ok": True})
|
||||
self.assertEqual(
|
||||
calls,
|
||||
[
|
||||
("infra", 24, "testing", False),
|
||||
("infra", 24, "", True),
|
||||
],
|
||||
)
|
||||
sleep_mock.assert_called_once_with(0.20)
|
||||
|
||||
def test_inject_text_then_enter_delay_honors_environment_override(self):
|
||||
handler = DummyControlHandler()
|
||||
handler._inject_to_pane = MagicMock(return_value={"ok": True})
|
||||
|
||||
with patch("amc_server.mixins.control.time.sleep") as sleep_mock, patch.dict(
|
||||
os.environ, {"AMC_SUBMIT_ENTER_DELAY_MS": "350"}, clear=True
|
||||
):
|
||||
result = handler._inject_text_then_enter("infra", 9, "hello")
|
||||
|
||||
self.assertEqual(result, {"ok": True})
|
||||
sleep_mock.assert_called_once_with(0.35)
|
||||
|
||||
def test_respond_to_session_freeform_selects_other_then_submits_text(self):
|
||||
session_id = "abc123"
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sessions_dir = Path(tmpdir)
|
||||
self._write_session(sessions_dir, session_id)
|
||||
|
||||
handler = DummyControlHandler(
|
||||
{
|
||||
"text": "testing",
|
||||
"freeform": True,
|
||||
"optionCount": 3,
|
||||
}
|
||||
)
|
||||
|
||||
with patch.object(control, "SESSIONS_DIR", sessions_dir), patch(
|
||||
"amc_server.mixins.control.time.sleep"
|
||||
) as sleep_mock:
|
||||
handler._inject_to_pane = MagicMock(return_value={"ok": True})
|
||||
handler._inject_text_then_enter = MagicMock(return_value={"ok": True})
|
||||
handler._respond_to_session(session_id)
|
||||
|
||||
handler._inject_to_pane.assert_called_once_with("infra", 21, "4", send_enter=False)
|
||||
handler._inject_text_then_enter.assert_called_once_with("infra", 21, "testing")
|
||||
sleep_mock.assert_called_once_with(handler._FREEFORM_MODE_SWITCH_DELAY_SEC)
|
||||
self.assertEqual(handler.errors, [])
|
||||
self.assertEqual(handler.sent, [(200, {"ok": True})])
|
||||
|
||||
def test_respond_to_session_non_freeform_uses_text_then_enter_helper(self):
|
||||
session_id = "abc456"
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
sessions_dir = Path(tmpdir)
|
||||
self._write_session(sessions_dir, session_id, zellij_pane="terminal_5")
|
||||
|
||||
handler = DummyControlHandler({"text": "hello"})
|
||||
|
||||
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||
handler._inject_to_pane = MagicMock(return_value={"ok": True})
|
||||
handler._inject_text_then_enter = MagicMock(return_value={"ok": True})
|
||||
handler._respond_to_session(session_id)
|
||||
|
||||
handler._inject_to_pane.assert_not_called()
|
||||
handler._inject_text_then_enter.assert_called_once_with("infra", 5, "hello")
|
||||
self.assertEqual(handler.sent, [(200, {"ok": True})])
|
||||
|
||||
def test_respond_to_session_missing_session_returns_404(self):
|
||||
handler = DummyControlHandler({"text": "hello"})
|
||||
sessions_dir = Path(self.id())
|
||||
|
||||
with patch.object(control, "SESSIONS_DIR", sessions_dir):
|
||||
handler._respond_to_session("does-not-exist")
|
||||
|
||||
self.assertEqual(handler.sent, [])
|
||||
self.assertEqual(handler.errors, [(404, "Session not found")])
|
||||
|
||||
def test_try_plugin_inject_uses_explicit_session_and_payload(self):
|
||||
handler = DummyControlHandler()
|
||||
env = {}
|
||||
completed = subprocess.CompletedProcess(args=[], returncode=0, stdout="", stderr="")
|
||||
|
||||
with patch.object(control, "ZELLIJ_BIN", "/opt/homebrew/bin/zellij"), patch(
|
||||
"amc_server.mixins.control.subprocess.run", return_value=completed
|
||||
) as run_mock:
|
||||
result = handler._try_plugin_inject(env, "infra", 24, "testing", send_enter=True)
|
||||
|
||||
self.assertEqual(result, {"ok": True})
|
||||
args = run_mock.call_args.args[0]
|
||||
self.assertEqual(args[0], "/opt/homebrew/bin/zellij")
|
||||
self.assertEqual(args[1:3], ["--session", "infra"])
|
||||
payload = json.loads(args[-1])
|
||||
self.assertEqual(payload["pane_id"], 24)
|
||||
self.assertEqual(payload["text"], "testing")
|
||||
self.assertIs(payload["send_enter"], True)
|
||||
|
||||
def test_inject_to_pane_without_plugin_or_unsafe_fallback_returns_error(self):
|
||||
handler = DummyControlHandler()
|
||||
|
||||
with patch.object(control, "ZELLIJ_PLUGIN", Path("/definitely/missing/plugin.wasm")), patch.dict(
|
||||
os.environ, {}, clear=True
|
||||
):
|
||||
result = handler._inject_to_pane("infra", 24, "testing", send_enter=True)
|
||||
|
||||
self.assertFalse(result["ok"])
|
||||
self.assertIn("Pane-targeted injection requires zellij-send-keys plugin", result["error"])
|
||||
|
||||
def test_inject_to_pane_allows_unsafe_fallback_when_enabled(self):
|
||||
handler = DummyControlHandler()
|
||||
|
||||
with patch.object(control, "ZELLIJ_PLUGIN", Path("/definitely/missing/plugin.wasm")), patch.dict(
|
||||
os.environ, {"AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK": "1"}, clear=True
|
||||
):
|
||||
handler._try_write_chars_inject = MagicMock(return_value={"ok": True})
|
||||
result = handler._inject_to_pane("infra", 24, "testing", send_enter=True)
|
||||
|
||||
self.assertEqual(result, {"ok": True})
|
||||
handler._try_write_chars_inject.assert_called_once()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
37
tests/test_state.py
Normal file
37
tests/test_state.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import subprocess
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
import amc_server.mixins.state as state_mod
|
||||
from amc_server.mixins.state import StateMixin
|
||||
|
||||
|
||||
class DummyStateHandler(StateMixin):
|
||||
pass
|
||||
|
||||
|
||||
class StateMixinTests(unittest.TestCase):
|
||||
def test_get_active_zellij_sessions_uses_resolved_binary_and_parses_output(self):
|
||||
handler = DummyStateHandler()
|
||||
state_mod._zellij_cache["sessions"] = None
|
||||
state_mod._zellij_cache["expires"] = 0
|
||||
|
||||
completed = subprocess.CompletedProcess(
|
||||
args=[],
|
||||
returncode=0,
|
||||
stdout="infra [created 1h ago]\nwork\n",
|
||||
stderr="",
|
||||
)
|
||||
|
||||
with patch.object(state_mod, "ZELLIJ_BIN", "/opt/homebrew/bin/zellij"), patch(
|
||||
"amc_server.mixins.state.subprocess.run", return_value=completed
|
||||
) as run_mock:
|
||||
sessions = handler._get_active_zellij_sessions()
|
||||
|
||||
self.assertEqual(sessions, {"infra", "work"})
|
||||
args = run_mock.call_args.args[0]
|
||||
self.assertEqual(args, ["/opt/homebrew/bin/zellij", "list-sessions", "--no-formatting"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user