Failed to connect to API
${error}
No active sessions
All sessions have completed
import { html, useState, useEffect, useCallback, useMemo, useRef } from '../lib/preact.js'; import { API_STATE, API_STREAM, API_DISMISS, API_DISMISS_DEAD, 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'; import { ToastContainer, showToast, trackError, clearErrorCount } from './Toast.js'; import { SpawnModal } from './SpawnModal.js'; let optimisticMsgId = 0; export function App() { const [sessions, setSessions] = useState([]); const [modalSession, setModalSession] = useState(null); const [conversations, setConversations] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selectedProject, setSelectedProject] = useState(null); const [sseConnected, setSseConnected] = useState(false); const [deadSessionsCollapsed, setDeadSessionsCollapsed] = useState(true); const [spawnModalOpen, setSpawnModalOpen] = useState(false); // Background conversation refresh with error tracking 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) { trackError(`conversation-${sessionId}`, `Failed to fetch conversation (HTTP ${response.status})`); return; } const data = await response.json(); setConversations(prev => ({ ...prev, [sessionId]: data.messages || [] })); clearErrorCount(`conversation-${sessionId}`); // Clear on success } catch (err) { trackError(`conversation-${sessionId}`, `Failed to fetch conversation: ${err.message}`); } }, []); // Track last_event_at for each session to detect actual changes const lastEventAtRef = useRef({}); // Refs for stable callback access (avoids recreation on state changes) const sessionsRef = useRef(sessions); const conversationsRef = useRef(conversations); const modalSessionRef = useRef(null); sessionsRef.current = sessions; conversationsRef.current = conversations; // Apply state payload from polling or SSE stream const applyStateData = useCallback((data) => { const newSessions = data.sessions || []; const newSessionIds = new Set(newSessions.map(s => s.session_id)); setSessions(newSessions); setError(null); // Update modalSession if it's still open (to get latest pending_questions, etc.) const modalId = modalSessionRef.current; if (modalId) { const updatedSession = newSessions.find(s => s.session_id === modalId); if (updatedSession) { setModalSession(updatedSession); } } // Clean up conversation cache for sessions that no longer exist setConversations(prev => { const activeIds = Object.keys(prev).filter(id => newSessionIds.has(id)); if (activeIds.length === Object.keys(prev).length) return prev; // No cleanup needed const cleaned = {}; for (const id of activeIds) { cleaned[id] = prev[id]; } return cleaned; }); // Refresh conversations for sessions that have actually changed // Use conversation_mtime_ns for real-time updates (changes on every file write), // falling back to last_event_at for sessions without mtime tracking const prevEventMap = lastEventAtRef.current; const nextEventMap = {}; for (const session of newSessions) { const id = session.session_id; // Prefer mtime (changes on every write) over last_event_at (only on hook events) const newKey = session.conversation_mtime_ns || session.last_event_at || ''; nextEventMap[id] = newKey; const oldKey = prevEventMap[id] || ''; if (newKey !== oldKey) { 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); clearErrorCount('state-fetch'); } catch (err) { const msg = err.name === 'AbortError' ? 'Request timed out' : err.message; trackError('state-fetch', `Failed to fetch state: ${msg}`); setError(msg); setLoading(false); } }, [applyStateData]); // Fetch conversation for a session (explicit fetch, e.g., on modal open) const fetchConversation = useCallback(async (sessionId, projectDir, agent = 'claude', force = false) => { // Skip if already fetched and not forcing refresh if (!force && conversationsRef.current[sessionId]) return; 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) { trackError(`conversation-${sessionId}`, `Failed to fetch conversation (HTTP ${response.status})`); return; } const data = await response.json(); setConversations(prev => ({ ...prev, [sessionId]: data.messages || [] })); clearErrorCount(`conversation-${sessionId}`); } catch (err) { trackError(`conversation-${sessionId}`, `Error fetching conversation: ${err.message}`); } }, []); // Respond to a session's pending question with optimistic update const respondToSession = useCallback(async (sessionId, text, isFreeform = false, optionCount = 0) => { const payload = { text }; if (isFreeform) { payload.freeform = true; payload.optionCount = optionCount; } // Optimistic update: immediately show user's message const optimisticMsg = { id: `optimistic-${++optimisticMsgId}`, role: 'user', content: text, timestamp: new Date().toISOString(), _optimistic: true, // Flag for identification }; setConversations(prev => ({ ...prev, [sessionId]: [...(prev[sessionId] || []), optimisticMsg] })); 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) { throw new Error(data.error || 'Failed to send response'); } clearErrorCount(`respond-${sessionId}`); // SSE will push state update when Claude processes the message, // which triggers conversation refresh via applyStateData } catch (err) { // Remove optimistic message on failure setConversations(prev => ({ ...prev, [sessionId]: (prev[sessionId] || []).filter(m => m !== optimisticMsg) })); trackError(`respond-${sessionId}`, `Failed to send message: ${err.message}`); throw err; // Re-throw so SimpleInput/QuestionBlock can catch and show error } }, []); // 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(); } else { trackError(`dismiss-${sessionId}`, `Failed to dismiss session: ${data.error || 'Unknown error'}`); } } catch (err) { trackError(`dismiss-${sessionId}`, `Error dismissing session: ${err.message}`); } }, [fetchState]); // Dismiss all dead sessions const dismissDeadSessions = useCallback(async () => { try { const res = await fetch(API_DISMISS_DEAD, { method: 'POST' }); const data = await res.json(); if (data.ok) { fetchState(); } else { trackError('dismiss-dead', `Failed to clear completed sessions: ${data.error || 'Unknown error'}`); } } catch (err) { trackError('dismiss-dead', `Error clearing completed sessions: ${err.message}`); } }, [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) { trackError('sse-init', `Failed to initialize EventSource: ${err.message}`); setSseConnected(false); reconnectTimer = setTimeout(connect, 2000); return; } eventSource.addEventListener('open', () => { if (stopped) return; setSseConnected(true); setError(null); // Clear event cache on reconnect to force refresh of all conversations // (handles updates missed during disconnect) lastEventAtRef.current = {}; }); eventSource.addEventListener('state', (event) => { if (stopped) return; try { const data = JSON.parse(event.data); applyStateData(data); clearErrorCount('sse-parse'); } catch (err) { trackError('sse-parse', `Failed to parse SSE state payload: ${err.message}`); } }); 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]); // Split sessions into active and dead const { activeSessions, deadSessions } = useMemo(() => { const active = []; const dead = []; for (const group of filteredGroups) { for (const session of group.sessions) { if (session.is_dead) { dead.push(session); } else { active.push(session); } } } return { activeSessions: active, deadSessions: dead }; }, [filteredGroups]); // Handle card click - open modal and fetch conversation if not cached const handleCardClick = useCallback(async (session) => { modalSessionRef.current = session.session_id; setModalSession(session); // Fetch conversation if not already cached if (!conversationsRef.current[session.session_id]) { await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude'); } }, [fetchConversation]); const handleCloseModal = useCallback(() => { modalSessionRef.current = null; setModalSession(null); }, []); const handleSelectProject = useCallback((projectDir) => { setSelectedProject(projectDir); }, []); const handleSpawnResult = useCallback((result) => { if (result.success) { showToast(`${result.agentType} agent spawned for ${result.project}`, 'success'); } else if (result.error) { showToast(result.error, 'error'); } }, []); return html` <${Sidebar} projectGroups=${projectGroups} selectedProject=${selectedProject} onSelectProject=${handleSelectProject} totalSessions=${sessions.length} />
${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}` : ''}
Failed to connect to API
${error}
No active sessions
All sessions have completed