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:
20
dashboard/utils/api.js
Normal file
20
dashboard/utils/api.js
Normal file
@@ -0,0 +1,20 @@
|
||||
// API Constants
|
||||
export const API_STATE = '/api/state';
|
||||
export const API_STREAM = '/api/stream';
|
||||
export const API_DISMISS = '/api/dismiss/';
|
||||
export const API_RESPOND = '/api/respond/';
|
||||
export const API_CONVERSATION = '/api/conversation/';
|
||||
export const POLL_MS = 3000;
|
||||
export const API_TIMEOUT_MS = 10000;
|
||||
|
||||
// Fetch with timeout to prevent hanging requests
|
||||
export async function fetchWithTimeout(url, options = {}, timeoutMs = API_TIMEOUT_MS) {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, { ...options, signal: controller.signal });
|
||||
return response;
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
66
dashboard/utils/formatting.js
Normal file
66
dashboard/utils/formatting.js
Normal file
@@ -0,0 +1,66 @@
|
||||
// Formatting utilities
|
||||
|
||||
export function formatDuration(isoStart) {
|
||||
if (!isoStart) return '';
|
||||
const start = new Date(isoStart);
|
||||
const now = new Date();
|
||||
const mins = Math.max(0, Math.floor((now - start) / 60000));
|
||||
if (mins < 60) return mins + 'm';
|
||||
const hrs = Math.floor(mins / 60);
|
||||
const remainMins = mins % 60;
|
||||
return hrs + 'h ' + remainMins + 'm';
|
||||
}
|
||||
|
||||
export function formatTime(isoTime) {
|
||||
if (!isoTime) return '';
|
||||
const date = new Date(isoTime);
|
||||
return date.toLocaleTimeString('en-US', {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
hour12: true
|
||||
});
|
||||
}
|
||||
|
||||
export function formatTokenCount(value) {
|
||||
if (!Number.isFinite(value)) return '';
|
||||
return Math.round(value).toLocaleString('en-US');
|
||||
}
|
||||
|
||||
export function getContextUsageSummary(usage) {
|
||||
if (!usage || typeof usage !== 'object') return null;
|
||||
|
||||
const current = Number(usage.current_tokens);
|
||||
const windowTokens = Number(usage.window_tokens);
|
||||
const sessionTotal = usage.session_total_tokens != null ? Number(usage.session_total_tokens) : null;
|
||||
const hasCurrent = Number.isFinite(current) && current > 0;
|
||||
const hasWindow = Number.isFinite(windowTokens) && windowTokens > 0;
|
||||
const hasSessionTotal = sessionTotal != null && Number.isFinite(sessionTotal) && sessionTotal > 0;
|
||||
|
||||
if (hasCurrent && hasWindow) {
|
||||
const percent = (current / windowTokens) * 100;
|
||||
return {
|
||||
headline: `${percent >= 10 ? percent.toFixed(0) : percent.toFixed(1)}% ctx`,
|
||||
detail: `${formatTokenCount(current)} / ${formatTokenCount(windowTokens)}`,
|
||||
trail: hasSessionTotal ? `Σ ${formatTokenCount(sessionTotal)}` : '',
|
||||
title: `Context window usage: ${formatTokenCount(current)} / ${formatTokenCount(windowTokens)} tokens`,
|
||||
};
|
||||
}
|
||||
|
||||
if (hasCurrent) {
|
||||
const inputTokens = Number(usage.input_tokens);
|
||||
const outputTokens = Number(usage.output_tokens);
|
||||
const hasInput = Number.isFinite(inputTokens);
|
||||
const hasOutput = Number.isFinite(outputTokens);
|
||||
const ioDetail = hasInput || hasOutput
|
||||
? ` • in ${formatTokenCount(hasInput ? inputTokens : 0)} out ${formatTokenCount(hasOutput ? outputTokens : 0)}`
|
||||
: '';
|
||||
return {
|
||||
headline: 'Ctx usage',
|
||||
detail: `${formatTokenCount(current)} tok${ioDetail}`,
|
||||
trail: '',
|
||||
title: `Token usage: ${formatTokenCount(current)} tokens`,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
98
dashboard/utils/status.js
Normal file
98
dashboard/utils/status.js
Normal file
@@ -0,0 +1,98 @@
|
||||
// Status-related utilities
|
||||
|
||||
export const STATUS_PRIORITY = {
|
||||
needs_attention: 0,
|
||||
active: 1,
|
||||
starting: 2,
|
||||
done: 3
|
||||
};
|
||||
|
||||
export function getStatusMeta(status) {
|
||||
switch (status) {
|
||||
case 'needs_attention':
|
||||
return {
|
||||
label: 'Needs attention',
|
||||
dot: 'bg-attention pulse-attention',
|
||||
badge: 'bg-attention/18 text-attention border-attention/40',
|
||||
borderColor: '#e0b45e',
|
||||
};
|
||||
case 'active':
|
||||
return {
|
||||
label: 'Active',
|
||||
dot: 'bg-active',
|
||||
badge: 'bg-active/18 text-active border-active/40',
|
||||
borderColor: '#5fd0a4',
|
||||
};
|
||||
case 'starting':
|
||||
return {
|
||||
label: 'Starting',
|
||||
dot: 'bg-starting',
|
||||
badge: 'bg-starting/18 text-starting border-starting/40',
|
||||
borderColor: '#7cb2ff',
|
||||
};
|
||||
case 'done':
|
||||
return {
|
||||
label: 'Done',
|
||||
dot: 'bg-done',
|
||||
badge: 'bg-done/18 text-done border-done/40',
|
||||
borderColor: '#e39a8c',
|
||||
};
|
||||
default:
|
||||
return {
|
||||
label: status || 'Unknown',
|
||||
dot: 'bg-dim',
|
||||
badge: 'bg-selection text-dim border-selection',
|
||||
borderColor: '#223454',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function getUserMessageBg(status) {
|
||||
switch (status) {
|
||||
case 'needs_attention': return 'bg-attention/20 border border-attention/35 text-bright';
|
||||
case 'active': return 'bg-active/20 border border-active/30 text-bright';
|
||||
case 'starting': return 'bg-starting/20 border border-starting/30 text-bright';
|
||||
case 'done': return 'bg-done/20 border border-done/30 text-bright';
|
||||
default: return 'bg-selection/80 border border-selection text-bright';
|
||||
}
|
||||
}
|
||||
|
||||
export function groupSessionsByProject(sessions) {
|
||||
const groups = new Map();
|
||||
|
||||
for (const session of sessions) {
|
||||
const key = session.project_dir || session.cwd || 'unknown';
|
||||
if (!groups.has(key)) {
|
||||
groups.set(key, {
|
||||
projectDir: key,
|
||||
projectName: session.project || key.split('/').pop() || 'Unknown',
|
||||
sessions: [],
|
||||
});
|
||||
}
|
||||
groups.get(key).sessions.push(session);
|
||||
}
|
||||
|
||||
const result = Array.from(groups.values());
|
||||
|
||||
// Sort groups: most urgent status first, then most recent activity
|
||||
result.sort((a, b) => {
|
||||
const aWorst = Math.min(...a.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
|
||||
const bWorst = Math.min(...b.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
|
||||
if (aWorst !== bWorst) return aWorst - bWorst;
|
||||
const aRecent = Math.max(...a.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
|
||||
const bRecent = Math.max(...b.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
|
||||
return bRecent - aRecent;
|
||||
});
|
||||
|
||||
// Sort sessions within each group: urgent first, then most recent
|
||||
for (const group of result) {
|
||||
group.sessions.sort((a, b) => {
|
||||
const aPri = STATUS_PRIORITY[a.status] ?? 99;
|
||||
const bPri = STATUS_PRIORITY[b.status] ?? 99;
|
||||
if (aPri !== bPri) return aPri - bPri;
|
||||
return (b.last_event_at || '').localeCompare(a.last_event_at || '');
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
Reference in New Issue
Block a user