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>
This commit is contained in:
teernisse
2026-02-25 15:01:35 -05:00
parent 9cd91f6b4e
commit da08d7a588
22 changed files with 1961 additions and 3000 deletions

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 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 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,70 @@
import { html, useRef, useEffect } from '../lib/preact.js';
import { getUserMessageBg } from '../utils/status.js';
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
export function ChatMessages({ messages, status }) {
const containerRef = useRef(null);
const userBgClass = getUserMessageBg(status);
const wasAtBottomRef = useRef(true);
const prevMessagesLenRef = useRef(0);
// Scroll to bottom on initial mount
useEffect(() => {
const container = containerRef.current;
if (!container) return;
// Always scroll to bottom on first render
container.scrollTop = container.scrollHeight;
}, []);
// Check if scrolled to bottom before render
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const checkScroll = () => {
const threshold = 50;
const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
wasAtBottomRef.current = isAtBottom;
};
container.addEventListener('scroll', checkScroll);
return () => container.removeEventListener('scroll', checkScroll);
}, []);
// Scroll to bottom on new messages if user was at bottom
useEffect(() => {
const container = containerRef.current;
if (!container || !messages) return;
const hasNewMessages = messages.length > prevMessagesLenRef.current;
prevMessagesLenRef.current = messages.length;
if (hasNewMessages && wasAtBottomRef.current) {
container.scrollTop = container.scrollHeight;
}
}, [messages]);
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 displayMessages = filterDisplayMessages(messages).slice(-20);
return html`
<div ref=${containerRef} class="h-full space-y-2.5 overflow-y-auto overflow-x-hidden pr-0.5">
${displayMessages.map((msg, i) => html`
<${MessageBubble}
key=${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'} 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 tool-call-only messages
* that have no text or thinking (would render as empty bubbles).
*/
export function filterDisplayMessages(messages) {
return messages.filter(msg =>
msg.content || msg.thinking || msg.role === 'user'
);
}

View File

@@ -0,0 +1,223 @@
import { html, useState, useEffect, useRef } 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 inputRef = useRef(null);
if (!session) return null;
const conversation = conversations[session.session_id] || [];
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';
// Track if user has scrolled away from bottom
const wasAtBottomRef = useRef(true);
const prevConversationLenRef = useRef(0);
const chatContainerRef = useRef(null);
// Initialize scroll position to bottom on mount (no animation)
useEffect(() => {
if (chatContainerRef.current) {
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
}
}, []);
// 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]);
// Lock body scroll when modal is open
useEffect(() => {
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = '';
};
}, []);
// Handle keyboard events
useEffect(() => {
const handleKeyDown = (e) => {
// Escape closes modal
if (e.key === 'Escape') {
onClose();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose]);
// Handle input key events
const handleInputKeyDown = (e) => {
// Enter sends message (unless Shift+Enter for newline)
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
// Send message
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('');
// Refresh conversation after sending
if (onRefreshConversation) {
await onRefreshConversation(session.session_id, session.project_dir, agent);
}
} catch (err) {
console.error('Failed to send message:', err);
} finally {
setSending(false);
}
};
return html`
<div
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm"
onClick=${(e) => e.target === e.currentTarget && onClose()}
>
<div
class="glass-panel flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-selection/80"
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}"></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 hover:border-done/35 hover:bg-done/10 hover:text-bright"
onClick=${onClose}
>
<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">
<div class="font-mono text-dim">Loading conversation...</div>
</div>
` : conversation.length > 0 ? html`
<div class="space-y-4">
${filterDisplayMessages(conversation).map((msg, i) => html`
<${MessageBubble}
key=${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 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-all 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-all 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 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-all 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,103 @@
import { html, useEffect } 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 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-all 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}"></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 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-all ${
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-all ${
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 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-all 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>

113
dashboard/lib/markdown.js Normal file
View File

@@ -0,0 +1,113 @@
// 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
function getToolSummary(name, input) {
if (!input) return name;
switch (name) {
case 'Bash': return input.description || input.command?.slice(0, 60) || 'Bash';
case 'Read': return input.file_path?.split('/').slice(-2).join('/') || 'Read';
case 'Write': return input.file_path?.split('/').slice(-2).join('/') || 'Write';
case 'Edit': return input.file_path?.split('/').slice(-2).join('/') || 'Edit';
case 'Grep': return `/${input.pattern?.slice(0, 40) || ''}/ ${input.glob || ''}`.trim();
case 'Glob': return input.pattern?.slice(0, 50) || 'Glob';
case 'Task': return input.description || 'Task';
default: 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'));

306
dashboard/styles.css Normal file
View File

@@ -0,0 +1,306 @@
/* 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.12) translateY(-1px);
}
}
.pulse-attention {
animation: pulse-attention 2s ease-in-out infinite;
}
/* 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;
}

98
dashboard/utils/status.js Normal file
View File

@@ -0,0 +1,98 @@
// 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',
};
case 'starting':
return {
label: 'Starting',
dot: 'bg-starting',
badge: 'bg-starting/18 text-starting border-starting/40',
borderColor: '#7cb2ff',
};
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;
}