refactor(dashboard): extract modular Preact component structure
Replace the monolithic single-file dashboards (dashboard.html,
dashboard-preact.html) with a proper modular directory structure:
dashboard/
index.html - Entry point, loads main.js
main.js - App bootstrap, mounts <App> to #root
styles.css - Global styles (dark theme, typography)
components/
App.js - Root component, state management, polling
Header.js - Top bar with refresh/timing info
Sidebar.js - Project tree navigation
SessionCard.js - Individual session card with status/actions
SessionGroup.js - Group sessions by project path
Modal.js - Full conversation viewer overlay
ChatMessages.js - Message list with role styling
MessageBubble.js - Individual message with markdown
QuestionBlock.js - User question input with quick options
EmptyState.js - "No sessions" placeholder
OptionButton.js - Quick response button component
SimpleInput.js - Text input with send button
lib/
preact.js - Preact + htm ESM bundle (CDN shim)
markdown.js - Lightweight markdown-to-HTML renderer
utils/
api.js - fetch wrappers for /api/* endpoints
formatting.js - Time formatting, truncation helpers
status.js - Session status logic, action availability
This structure enables:
- Browser-native ES modules (no build step required)
- Component reuse and isolation
- Easier styling and theming
- IDE support for component navigation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
392
dashboard/components/App.js
Normal file
392
dashboard/components/App.js
Normal file
@@ -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 -->
|
||||
<${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 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 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`
|
||||
<!-- Sessions Grid (no project grouping header since sidebar shows selection) -->
|
||||
<div class="flex flex-wrap gap-4">
|
||||
${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}
|
||||
/>
|
||||
`)
|
||||
)}
|
||||
</div>
|
||||
`}
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<${Modal}
|
||||
session=${modalSession}
|
||||
conversations=${conversations}
|
||||
conversationLoading=${conversationLoading}
|
||||
onClose=${handleCloseModal}
|
||||
onSendMessage=${respondToSession}
|
||||
onRefreshConversation=${refreshConversation}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user