diff --git a/dashboard-preact.html b/dashboard-preact.html deleted file mode 100644 index 0de03f2..0000000 --- a/dashboard-preact.html +++ /dev/null @@ -1,1530 +0,0 @@ - - - - - - Agent Mission Control - - - - - - - - - - - -
- - - - diff --git a/dashboard.html b/dashboard.html deleted file mode 100644 index a5fa9fc..0000000 --- a/dashboard.html +++ /dev/null @@ -1,1470 +0,0 @@ - - - - - -AMC - - - - -
- AGENT MISSION CONTROL -
-
- -
-
-
No active sessions
-
Start a Claude Code session to see it here
-
-
- - - - - - - diff --git a/dashboard/components/App.js b/dashboard/components/App.js new file mode 100644 index 0000000..7438f9e --- /dev/null +++ b/dashboard/components/App.js @@ -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} + projectGroups=${projectGroups} + selectedProject=${selectedProject} + onSelectProject=${handleSelectProject} + totalSessions=${sessions.length} + /> + + +
+ +
+
+
+

+ ${selectedProject === null ? 'All Projects' : filteredGroups[0]?.projectName || 'Project'} +

+

+ ${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 ${filteredGroups[0].projectDir}` : ''} +

+
+ +
+ ${(() => { + 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` +
+ ${counts.needs_attention} + attention +
+ `} + ${counts.active > 0 && html` +
+ ${counts.active} + active +
+ `} + ${counts.starting > 0 && html` +
+ ${counts.starting} + starting +
+ `} + ${counts.done > 0 && html` +
+ ${counts.done} + done +
+ `} + `; + })()} +
+
+
+ +
+ ${loading ? html` +
+
Loading sessions...
+
+ ` : error ? html` +
+
+

Failed to connect to API

+

${error}

+
+
+ ` : filteredGroups.length === 0 ? html` + <${EmptyState} /> + ` : html` + +
+ ${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} + /> + `) + )} +
+ `} +
+
+ + <${Modal} + session=${modalSession} + conversations=${conversations} + conversationLoading=${conversationLoading} + onClose=${handleCloseModal} + onSendMessage=${respondToSession} + onRefreshConversation=${refreshConversation} + /> + `; +} diff --git a/dashboard/components/ChatMessages.js b/dashboard/components/ChatMessages.js new file mode 100644 index 0000000..2147418 --- /dev/null +++ b/dashboard/components/ChatMessages.js @@ -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` +
+ No messages yet +
+ `; + } + + const displayMessages = filterDisplayMessages(messages).slice(-20); + + return html` +
+ ${displayMessages.map((msg, i) => html` + <${MessageBubble} + key=${i} + msg=${msg} + userBg=${userBgClass} + compact=${true} + /> + `)} +
+ `; +} diff --git a/dashboard/components/EmptyState.js b/dashboard/components/EmptyState.js new file mode 100644 index 0000000..61c1df7 --- /dev/null +++ b/dashboard/components/EmptyState.js @@ -0,0 +1,18 @@ +import { html } from '../lib/preact.js'; + +export function EmptyState() { + return html` +
+
+ + + +
+

No Active Sessions

+

+ Agent sessions will appear here when they connect. Start a Claude Code session to see it in the dashboard. +

+
+ `; +} diff --git a/dashboard/components/Header.js b/dashboard/components/Header.js new file mode 100644 index 0000000..49f7961 --- /dev/null +++ b/dashboard/components/Header.js @@ -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` +
+
+
+
+
+ + Control Plane +
+

+ Agent Mission Control +

+

+ ${total} live session${total === 1 ? '' : 's'} • Updated ${clock.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} +

+
+ +
+
+
${counts.attention}
+
Attention
+
+
+
${counts.active}
+
Active
+
+
+
${counts.starting}
+
Starting
+
+
+
${counts.done}
+
Done
+
+
+
+
+
+ `; +} diff --git a/dashboard/components/MessageBubble.js b/dashboard/components/MessageBubble.js new file mode 100644 index 0000000..ef3e6d6 --- /dev/null +++ b/dashboard/components/MessageBubble.js @@ -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` +
+
+
+ ${isUser ? 'Operator' : 'Agent'} +
+ ${msg.thinking && renderThinking(msg.thinking)} +
+ ${renderContent(msg.content)} +
+ ${renderToolCalls(msg.tool_calls)} + ${formatTime && msg.timestamp && html` +
+ ${formatTime(msg.timestamp)} +
+ `} +
+
+ `; +} + +/** + * 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' + ); +} diff --git a/dashboard/components/Modal.js b/dashboard/components/Modal.js new file mode 100644 index 0000000..a8f8361 --- /dev/null +++ b/dashboard/components/Modal.js @@ -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` +
e.target === e.currentTarget && onClose()} + > +
e.stopPropagation()} + > + +
+
+
+

${session.project || session.name || session.session_id}

+
+ + ${status.label} +
+ + ${agent} + +
+
+ ${session.cwd || 'No working directory'} + ${session.started_at && html` + Running ${formatDuration(session.started_at)} + `} +
+
+ +
+ + +
+ ${conversationLoading ? html` +
+
Loading conversation...
+
+ ` : conversation.length > 0 ? html` +
+ ${filterDisplayMessages(conversation).map((msg, i) => html` + <${MessageBubble} + key=${i} + msg=${msg} + userBg=${getUserMessageBg(session.status)} + compact=${false} + formatTime=${formatTime} + /> + `)} +
+ ` : html` +

No conversation messages

+ `} +
+ + +
+ ${hasPendingQuestions && html` +
+ Agent is waiting for a response +
+ `} +
+