Closes bd-3ny. Added mousedown listener that dismisses the dropdown when clicking outside both the dropdown and textarea. Uses early return to avoid registering listeners when dropdown is already closed.
516 lines
19 KiB
JavaScript
516 lines
19 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, trackError, clearErrorCount } from './Toast.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);
|
|
|
|
// 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);
|
|
}, []);
|
|
|
|
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 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} />
|
|
`;
|
|
}
|