Compare commits

...

10 Commits

Author SHA1 Message Date
teernisse
31862f3a40 perf(dashboard): optimize CSS transitions and add entrance animations
Performance and polish improvements across dashboard components:

Transition optimizations (reduces reflow/repaint overhead):
- OptionButton: transition-all → transition-[transform,border-color,
  background-color,box-shadow]
- QuestionBlock: Add transition-colors to textarea, transition-all →
  transition-[transform,filter] on send button
- SimpleInput: Same pattern as QuestionBlock
- Sidebar: transition-all → transition-colors for project buttons

Animation additions:
- App: Add animate-fade-in-up to loading and error state containers
- MessageBubble: Make fade-in-up animation conditional on non-compact
  mode to avoid animation spam in card preview

Using specific transition properties instead of transition-all tells
the browser exactly which properties to watch, avoiding unnecessary
style recalculation on unrelated property changes.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 16:32:28 -05:00
teernisse
4740922b8d refactor(dashboard): simplify chat rendering and add scroll behavior
Removes unnecessary complexity from ChatMessages while adding proper
scroll management to SessionCard:

ChatMessages.js:
- Remove scroll position tracking refs and effects (wasAtBottomRef,
  prevMessagesLenRef, containerRef)
- Remove spinner display logic (moved to parent components)
- Simplify to pure message filtering and rendering
- Add display limit (last 20 messages) with offset tracking for keys

SessionCard.js:
- Add chatPaneRef for scroll container
- Add useEffect to scroll to bottom when conversation updates
- Provides natural "follow" behavior for new messages

The refactor moves scroll responsibility to the component that owns
the scroll container, reducing prop drilling and effect complexity.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 16:32:08 -05:00
teernisse
8578a19330 feat(dashboard): add smooth modal entrance/exit animations
Implements animated modal open/close with accessibility support:

- Add closing state with 200ms exit animation before unmount
- Refactor to React hooks-compliant structure (guards after hooks)
- Add CSS keyframes for backdrop fade and panel scale+translate
- Include prefers-reduced-motion media query to disable animations
  for users with vestibular sensitivities
- Use handleClose callback wrapper for consistent animation behavior
  across Escape key, backdrop click, and close button

The animations provide visual continuity without being distracting,
and gracefully degrade to instant transitions when reduced motion
is preferred.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 16:32:00 -05:00
teernisse
754e85445a test(server): add unit tests for context, control, and state mixins
Adds comprehensive test coverage for the amc_server package:

- test_context.py: Tests _resolve_zellij_bin preference order (which
  first, then fallback to bare name)
- test_control.py: Tests SessionControlMixin including two-step Enter
  injection with configurable delay, freeform response handling,
  plugin inject with explicit session/pane targeting, and unsafe
  fallback behavior
- test_state.py: Tests StateMixin zellij session parsing with the
  resolved binary path

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 16:31:51 -05:00
teernisse
183942fbaa fix(dashboard): spinner animation polish and accessibility
Polish the working indicator animations:

- Use cubic-bezier easing for smoother spinner rotation
- Add will-change hints for GPU acceleration
- Add display: inline-block to bounce-dot spans (required for transform)
- Add prefers-reduced-motion media query to disable animations for
  motion-sensitive users (accessibility)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 15:36:36 -05:00
teernisse
2f80995f8d fix(dashboard): robust tool call display and filter logic
Two fixes for tool call display in the dashboard:

1. **filterDisplayMessages includes tool_calls** (MessageBubble.js)
   Previously filtered out messages with only tool_calls (no content/thinking).
   Now correctly keeps messages that have tool_calls.

2. **Type-safe getToolSummary** (markdown.js)
   The heuristic tool summary extractor was calling .slice() without
   type checks. If a tool input had a non-string value (e.g., number),
   it would throw TypeError. Now uses a helper function to safely
   check types before calling string methods.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 15:36:26 -05:00
teernisse
8224acbba7 feat(dashboard): add working indicator for active sessions
Visual feedback when an agent is actively processing:

1. **Spinner on status dots** (SessionCard.js, Modal.js)
   - Status dot gets a spinning ring animation when session is active/starting
   - Uses CSS border trick with transparent borders except top

2. **Working indicator in chat** (ChatMessages.js, Modal.js)
   - Shows at bottom of conversation when agent is working
   - Bouncing dots animation ("...") next to "Agent is working" text
   - Only visible for active/starting statuses

3. **CSS animations** (styles.css)
   - spin-ring: 0.8s rotation for the status dot border
   - bounce-dot: staggered vertical bounce for the working dots

4. **Status metadata** (status.js)
   - Added `spinning: true` flag for active and starting statuses
   - Used by components to conditionally render spinner elements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 15:20:26 -05:00
teernisse
7cf51427b7 fix(codex): parse tool calls and reasoning in conversation API
The Codex conversation parser was only handling "message" payload types,
missing tool calls entirely. Codex uses separate response_items:

  - function_call: tool invocations with name, arguments, call_id
  - reasoning: thinking summaries (encrypted content, visible summary)
  - message: user/assistant text (previously the only type handled)

Changes:
- Parse function_call payloads and accumulate as tool_calls array
- Attach tool_calls to the next assistant message, or flush standalone
- Parse reasoning payloads and extract summary text as thinking
- Add _parse_codex_arguments() helper to handle JSON string arguments

This fixes the dashboard not showing Codex tool calls like exec_command,
read_file, etc.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 15:20:18 -05:00
teernisse
be2dd6a4fb fix(zellij): robust binary resolution and two-step Enter injection
Two reliability fixes for response injection:

1. **Zellij binary resolution** (context.py, state.py, control.py)
   
   When AMC is started via macOS launchctl, PATH is minimal and may not
   include Homebrew's bin directory. The new `_resolve_zellij_bin()`
   function tries `shutil.which("zellij")` first, then falls back to
   common installation paths:
   - /opt/homebrew/bin/zellij (Apple Silicon Homebrew)
   - /usr/local/bin/zellij (Intel Homebrew)
   - /usr/bin/zellij
   
   All subprocess calls now use ZELLIJ_BIN instead of hardcoded "zellij".

2. **Two-step Enter injection** (control.py)
   
   Previously, text and Enter were sent together, causing race conditions
   where Claude Code would receive only the Enter key (blank submit).
   Now uses `_inject_text_then_enter()`:
   - Send text (without Enter)
   - Wait for configurable delay (default 200ms)
   - Send Enter separately
   
   Delay is configurable via AMC_SUBMIT_ENTER_DELAY_MS env var (0-2000ms).

3. **Documentation updates** (README.md)
   
   - Update file table: dashboard-preact.html → dashboard/
   - Clarify plugin is required (not optional) for pane-targeted injection
   - Document AMC_ALLOW_UNSAFE_WRITE_CHARS_FALLBACK env var
   - Note about Zellij resolution for launchctl compatibility

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 15:20:08 -05:00
teernisse
da08d7a588 refactor(dashboard): extract modular Preact component structure
Replace the monolithic single-file dashboards (dashboard.html,
dashboard-preact.html) with a proper modular directory structure:

  dashboard/
    index.html              - Entry point, loads main.js
    main.js                 - App bootstrap, mounts <App> to #root
    styles.css              - Global styles (dark theme, typography)
    components/
      App.js                - Root component, state management, polling
      Header.js             - Top bar with refresh/timing info
      Sidebar.js            - Project tree navigation
      SessionCard.js        - Individual session card with status/actions
      SessionGroup.js       - Group sessions by project path
      Modal.js              - Full conversation viewer overlay
      ChatMessages.js       - Message list with role styling
      MessageBubble.js      - Individual message with markdown
      QuestionBlock.js      - User question input with quick options
      EmptyState.js         - "No sessions" placeholder
      OptionButton.js       - Quick response button component
      SimpleInput.js        - Text input with send button
    lib/
      preact.js             - Preact + htm ESM bundle (CDN shim)
      markdown.js           - Lightweight markdown-to-HTML renderer
    utils/
      api.js                - fetch wrappers for /api/* endpoints
      formatting.js         - Time formatting, truncation helpers
      status.js             - Session status logic, action availability

This structure enables:
- Browser-native ES modules (no build step required)
- Component reuse and isolation
- Easier styling and theming
- IDE support for component navigation

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-25 15:01:47 -05:00
30 changed files with 2504 additions and 3050 deletions

View File

@@ -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

View File

@@ -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"

View File

@@ -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)}

View File

@@ -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 {}

View File

@@ -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

File diff suppressed because it is too large Load Diff

392
dashboard/components/App.js Normal file
View 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}
/>
`;
}

View 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>
`;
}

View 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>
`;
}

View 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>
`;
}

View 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'
);
}

View 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>
`;
}

View 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>
`;
}

View 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>
`;
}

View 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>
`;
}

View 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>
`;
}

View 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>
`;
}

View 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
View 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
View 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
View 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
View 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
View 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
View 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);
}
}

View 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
View 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
View 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
View 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
View 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()