Files
amc/dashboard/components/App.js

539 lines
20 KiB
JavaScript

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 -->
<${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>
<button
class="rounded-lg border border-active/40 bg-active/12 px-3 py-2 text-sm font-medium text-active transition-colors hover:bg-active/20"
onClick=${() => setSpawnModalOpen(true)}
>
+ New Agent
</button>
</div>
</header>
<main class="px-6 pb-6 pt-6">
${loading ? html`
<div class="glass-panel animate-fade-in-up flex items-center justify-center rounded-2xl py-24">
<div class="font-mono text-dim">Loading sessions...</div>
</div>
` : error ? html`
<div class="glass-panel animate-fade-in-up flex items-center justify-center rounded-2xl py-24">
<div class="text-center">
<p class="mb-2 font-display text-lg text-attention">Failed to connect to API</p>
<p class="font-mono text-sm text-dim">${error}</p>
</div>
</div>
` : filteredGroups.length === 0 ? html`
<${EmptyState} />
` : html`
<!-- Active Sessions Grid -->
${activeSessions.length > 0 ? html`
<div class="flex flex-wrap gap-4">
${activeSessions.map(session => html`
<${SessionCard}
key=${session.session_id}
session=${session}
onClick=${handleCardClick}
conversation=${conversations[session.session_id]}
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
/>
`)}
</div>
` : deadSessions.length > 0 ? html`
<div class="glass-panel flex items-center justify-center rounded-xl py-12 mb-6">
<div class="text-center">
<p class="font-display text-lg text-dim">No active sessions</p>
<p class="mt-1 font-mono text-micro text-dim/70">All sessions have completed</p>
</div>
</div>
` : ''}
<!-- Completed Sessions (Dead) - Collapsible -->
${deadSessions.length > 0 && html`
<div class="mt-8">
<button
onClick=${() => setDeadSessionsCollapsed(!deadSessionsCollapsed)}
class="group flex w-full items-center gap-3 rounded-lg border border-selection/50 bg-surface/50 px-4 py-3 text-left transition-colors hover:border-selection hover:bg-surface/80"
>
<svg
class="h-4 w-4 text-dim transition-transform ${deadSessionsCollapsed ? '' : 'rotate-90'}"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/>
</svg>
<span class="font-display text-sm font-medium text-dim">
Completed Sessions
</span>
<span class="rounded-full bg-done/15 px-2 py-0.5 font-mono text-micro tabular-nums text-done/70">
${deadSessions.length}
</span>
<div class="flex-1"></div>
<button
onClick=${(e) => { e.stopPropagation(); dismissDeadSessions(); }}
class="rounded-lg border border-selection/80 bg-bg/40 px-3 py-1.5 font-mono text-micro text-dim transition-colors hover:border-done/40 hover:bg-done/10 hover:text-bright"
>
Clear All
</button>
</button>
${!deadSessionsCollapsed && html`
<div class="mt-4 flex flex-wrap gap-4">
${deadSessions.map(session => html`
<${SessionCard}
key=${session.session_id}
session=${session}
onClick=${handleCardClick}
conversation=${conversations[session.session_id]}
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
/>
`)}
</div>
`}
</div>
`}
`}
</main>
</div>
<${Modal}
session=${modalSession}
conversations=${conversations}
onClose=${handleCloseModal}
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
/>
<${ToastContainer} />
<${SpawnModal}
isOpen=${spawnModalOpen}
onClose=${() => setSpawnModalOpen(false)}
onSpawn=${handleSpawnResult}
currentProject=${selectedProject}
/>
`;
}