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 [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({}); // Refs for stable callback access (avoids recreation on state changes) const sessionsRef = useRef(sessions); const conversationsRef = useRef(conversations); sessionsRef.current = sessions; conversationsRef.current = conversations; // 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', 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) { 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); } }, []); // 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 state refresh fetchState(); // Refresh conversation for immediate feedback const session = sessionsRef.current.find(s => s.session_id === sessionId); if (session) { await refreshConversationSilent(sessionId, session.project_dir, session.agent || 'claude'); } } } catch (err) { console.error('Error responding to session:', err); throw err; // Re-throw so SimpleInput/QuestionBlock can catch and show error } }, [fetchState, refreshConversationSilent]); // 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 (!conversationsRef.current[session.session_id]) { await fetchConversation(session.session_id, session.project_dir, session.agent || 'claude'); } }, [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} onClose=${handleCloseModal} onFetchConversation=${fetchConversation} onRespond=${respondToSession} onDismiss=${dismissSession} /> `; }