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}
|
||||
/>
|
||||
`;
|
||||
}
|
||||
70
dashboard/components/ChatMessages.js
Normal file
70
dashboard/components/ChatMessages.js
Normal file
@@ -0,0 +1,70 @@
|
||||
import { html, useRef, useEffect } from '../lib/preact.js';
|
||||
import { getUserMessageBg } from '../utils/status.js';
|
||||
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
|
||||
|
||||
export function ChatMessages({ messages, status }) {
|
||||
const containerRef = useRef(null);
|
||||
const userBgClass = getUserMessageBg(status);
|
||||
const wasAtBottomRef = useRef(true);
|
||||
const prevMessagesLenRef = useRef(0);
|
||||
|
||||
// Scroll to bottom on initial mount
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
// Always scroll to bottom on first render
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}, []);
|
||||
|
||||
// Check if scrolled to bottom before render
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const checkScroll = () => {
|
||||
const threshold = 50;
|
||||
const isAtBottom = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
||||
wasAtBottomRef.current = isAtBottom;
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', checkScroll);
|
||||
return () => container.removeEventListener('scroll', checkScroll);
|
||||
}, []);
|
||||
|
||||
// Scroll to bottom on new messages if user was at bottom
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container || !messages) return;
|
||||
|
||||
const hasNewMessages = messages.length > prevMessagesLenRef.current;
|
||||
prevMessagesLenRef.current = messages.length;
|
||||
|
||||
if (hasNewMessages && wasAtBottomRef.current) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
if (!messages || messages.length === 0) {
|
||||
return html`
|
||||
<div class="flex h-full items-center justify-center rounded-xl border border-dashed border-selection/70 bg-bg/30 px-4 text-center text-sm text-dim">
|
||||
No messages yet
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
const displayMessages = filterDisplayMessages(messages).slice(-20);
|
||||
|
||||
return html`
|
||||
<div ref=${containerRef} class="h-full space-y-2.5 overflow-y-auto overflow-x-hidden pr-0.5">
|
||||
${displayMessages.map((msg, i) => html`
|
||||
<${MessageBubble}
|
||||
key=${i}
|
||||
msg=${msg}
|
||||
userBg=${userBgClass}
|
||||
compact=${true}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
18
dashboard/components/EmptyState.js
Normal file
18
dashboard/components/EmptyState.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { html } from '../lib/preact.js';
|
||||
|
||||
export function EmptyState() {
|
||||
return html`
|
||||
<div class="glass-panel mx-auto flex max-w-2xl flex-col items-center justify-center rounded-3xl px-8 py-20 text-center">
|
||||
<div class="mb-6 flex h-20 w-20 items-center justify-center rounded-2xl border border-selection/80 bg-bg/40 shadow-halo">
|
||||
<svg class="h-9 w-9 text-dim" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
|
||||
d="M9.75 17L9 20l-1 1h8l-1-1-.75-3M3 13h18M5 17h14a2 2 0 002-2V5a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h2 class="mb-2 font-display text-2xl font-semibold text-bright">No Active Sessions</h2>
|
||||
<p class="max-w-lg text-dim">
|
||||
Agent sessions will appear here when they connect. Start a Claude Code session to see it in the dashboard.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
58
dashboard/components/Header.js
Normal file
58
dashboard/components/Header.js
Normal file
@@ -0,0 +1,58 @@
|
||||
import { html, useState, useEffect } from '../lib/preact.js';
|
||||
|
||||
export function Header({ sessions }) {
|
||||
const [clock, setClock] = useState(() => new Date());
|
||||
|
||||
useEffect(() => {
|
||||
const timer = setInterval(() => setClock(new Date()), 30000);
|
||||
return () => clearInterval(timer);
|
||||
}, []);
|
||||
|
||||
const counts = {
|
||||
attention: sessions.filter(s => s.status === 'needs_attention').length,
|
||||
active: sessions.filter(s => s.status === 'active').length,
|
||||
starting: sessions.filter(s => s.status === 'starting').length,
|
||||
done: sessions.filter(s => s.status === 'done').length,
|
||||
};
|
||||
const total = sessions.length;
|
||||
|
||||
return html`
|
||||
<header class="sticky top-0 z-50 px-4 pt-4 sm:px-6 sm:pt-6">
|
||||
<div class="glass-panel rounded-2xl px-4 py-4 sm:px-6">
|
||||
<div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div class="min-w-0">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-starting/40 bg-starting/10 px-3 py-1 text-micro font-medium uppercase tracking-[0.24em] text-starting">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-starting animate-float"></span>
|
||||
Control Plane
|
||||
</div>
|
||||
<h1 class="mt-3 truncate font-display text-xl font-semibold text-bright sm:text-2xl">
|
||||
Agent Mission Control
|
||||
</h1>
|
||||
<p class="mt-1 text-sm text-dim">
|
||||
${total} live session${total === 1 ? '' : 's'} • Updated ${clock.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2 text-xs sm:grid-cols-4 sm:gap-3">
|
||||
<div class="rounded-xl border border-attention/40 bg-attention/12 px-3 py-2 text-attention">
|
||||
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.attention}</div>
|
||||
<div class="text-micro uppercase tracking-[0.16em]">Attention</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-active/40 bg-active/12 px-3 py-2 text-active">
|
||||
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.active}</div>
|
||||
<div class="text-micro uppercase tracking-[0.16em]">Active</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-starting/40 bg-starting/12 px-3 py-2 text-starting">
|
||||
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.starting}</div>
|
||||
<div class="text-micro uppercase tracking-[0.16em]">Starting</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-done/40 bg-done/12 px-3 py-2 text-done">
|
||||
<div class="font-mono text-lg font-medium tabular-nums text-bright">${counts.done}</div>
|
||||
<div class="text-micro uppercase tracking-[0.16em]">Done</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
`;
|
||||
}
|
||||
54
dashboard/components/MessageBubble.js
Normal file
54
dashboard/components/MessageBubble.js
Normal file
@@ -0,0 +1,54 @@
|
||||
import { html } from '../lib/preact.js';
|
||||
import { renderContent, renderToolCalls, renderThinking } from '../lib/markdown.js';
|
||||
|
||||
/**
|
||||
* Single message bubble used by both the card chat view and modal view.
|
||||
* All message rendering logic lives here — card and modal only differ in
|
||||
* container layout, not in how individual messages are rendered.
|
||||
*
|
||||
* @param {object} msg - Message object: { role, content, thinking, tool_calls, timestamp }
|
||||
* @param {string} userBg - Tailwind classes for user message background
|
||||
* @param {boolean} compact - true = card view (smaller), false = modal view (larger)
|
||||
* @param {function} formatTime - Optional timestamp formatter (modal only)
|
||||
*/
|
||||
export function MessageBubble({ msg, userBg, compact = false, formatTime }) {
|
||||
const isUser = msg.role === 'user';
|
||||
const pad = compact ? 'px-3 py-2.5' : 'px-4 py-3';
|
||||
const maxW = compact ? 'max-w-[92%]' : 'max-w-[86%]';
|
||||
|
||||
return html`
|
||||
<div class="flex ${isUser ? 'justify-end' : 'justify-start'} animate-fade-in-up">
|
||||
<div
|
||||
class="${maxW} rounded-2xl ${pad} ${
|
||||
isUser
|
||||
? `${userBg} rounded-br-md shadow-[0_3px_8px_rgba(16,24,36,0.22)]`
|
||||
: 'border border-selection/75 bg-surface2/75 text-fg rounded-bl-md'
|
||||
}"
|
||||
>
|
||||
<div class="mb-1 font-mono text-micro uppercase tracking-[0.14em] text-dim">
|
||||
${isUser ? 'Operator' : 'Agent'}
|
||||
</div>
|
||||
${msg.thinking && renderThinking(msg.thinking)}
|
||||
<div class="whitespace-pre-wrap break-words text-ui font-chat">
|
||||
${renderContent(msg.content)}
|
||||
</div>
|
||||
${renderToolCalls(msg.tool_calls)}
|
||||
${formatTime && msg.timestamp && html`
|
||||
<div class="mt-2 font-mono text-label text-dim">
|
||||
${formatTime(msg.timestamp)}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter messages for display — removes tool-call-only messages
|
||||
* that have no text or thinking (would render as empty bubbles).
|
||||
*/
|
||||
export function filterDisplayMessages(messages) {
|
||||
return messages.filter(msg =>
|
||||
msg.content || msg.thinking || msg.role === 'user'
|
||||
);
|
||||
}
|
||||
223
dashboard/components/Modal.js
Normal file
223
dashboard/components/Modal.js
Normal file
@@ -0,0 +1,223 @@
|
||||
import { html, useState, useEffect, useRef } from '../lib/preact.js';
|
||||
import { getStatusMeta, getUserMessageBg } from '../utils/status.js';
|
||||
import { formatDuration, formatTime } from '../utils/formatting.js';
|
||||
import { MessageBubble, filterDisplayMessages } from './MessageBubble.js';
|
||||
|
||||
export function Modal({ session, conversations, conversationLoading, onClose, onSendMessage, onRefreshConversation }) {
|
||||
const [inputValue, setInputValue] = useState('');
|
||||
const [sending, setSending] = useState(false);
|
||||
const [inputFocused, setInputFocused] = useState(false);
|
||||
const inputRef = useRef(null);
|
||||
|
||||
if (!session) return null;
|
||||
|
||||
const conversation = conversations[session.session_id] || [];
|
||||
const hasPendingQuestions = session.pending_questions && session.pending_questions.length > 0;
|
||||
const optionCount = hasPendingQuestions ? session.pending_questions[0]?.options?.length || 0 : 0;
|
||||
const status = getStatusMeta(session.status);
|
||||
const agent = session.agent === 'codex' ? 'codex' : 'claude';
|
||||
const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude';
|
||||
|
||||
// Track if user has scrolled away from bottom
|
||||
const wasAtBottomRef = useRef(true);
|
||||
const prevConversationLenRef = useRef(0);
|
||||
const chatContainerRef = useRef(null);
|
||||
|
||||
// Initialize scroll position to bottom on mount (no animation)
|
||||
useEffect(() => {
|
||||
if (chatContainerRef.current) {
|
||||
chatContainerRef.current.scrollTop = chatContainerRef.current.scrollHeight;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Track scroll position
|
||||
useEffect(() => {
|
||||
const container = chatContainerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const threshold = 50;
|
||||
wasAtBottomRef.current = container.scrollHeight - container.scrollTop - container.clientHeight < threshold;
|
||||
};
|
||||
|
||||
container.addEventListener('scroll', handleScroll);
|
||||
return () => container.removeEventListener('scroll', handleScroll);
|
||||
}, []);
|
||||
|
||||
// Only scroll to bottom on NEW messages, and only if user was already at bottom
|
||||
useEffect(() => {
|
||||
const container = chatContainerRef.current;
|
||||
if (!container || !conversation) return;
|
||||
|
||||
const hasNewMessages = conversation.length > prevConversationLenRef.current;
|
||||
prevConversationLenRef.current = conversation.length;
|
||||
|
||||
if (hasNewMessages && wasAtBottomRef.current) {
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}
|
||||
}, [conversation]);
|
||||
|
||||
// Focus input when modal opens
|
||||
useEffect(() => {
|
||||
if (inputRef.current) {
|
||||
inputRef.current.focus();
|
||||
}
|
||||
}, [session]);
|
||||
|
||||
// Lock body scroll when modal is open
|
||||
useEffect(() => {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Handle keyboard events
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e) => {
|
||||
// Escape closes modal
|
||||
if (e.key === 'Escape') {
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [onClose]);
|
||||
|
||||
// Handle input key events
|
||||
const handleInputKeyDown = (e) => {
|
||||
// Enter sends message (unless Shift+Enter for newline)
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
// Send message
|
||||
const handleSend = async () => {
|
||||
const text = inputValue.trim();
|
||||
if (!text || sending) return;
|
||||
|
||||
setSending(true);
|
||||
try {
|
||||
if (onSendMessage) {
|
||||
await onSendMessage(session.session_id, text, true, optionCount);
|
||||
}
|
||||
setInputValue('');
|
||||
// Refresh conversation after sending
|
||||
if (onRefreshConversation) {
|
||||
await onRefreshConversation(session.session_id, session.project_dir, agent);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err);
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-[#02050d]/84 p-4 backdrop-blur-sm"
|
||||
onClick=${(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
class="glass-panel flex max-h-[90vh] w-full max-w-5xl flex-col overflow-hidden rounded-2xl border border-selection/80"
|
||||
style=${{ borderLeftWidth: '3px', borderLeftColor: status.borderColor }}
|
||||
onClick=${(e) => e.stopPropagation()}
|
||||
>
|
||||
<!-- Modal Header -->
|
||||
<div class="flex items-center justify-between border-b px-5 py-4 ${agentHeaderClass}">
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="mb-1 flex items-center gap-3">
|
||||
<h2 class="truncate font-display text-xl font-semibold text-bright">${session.project || session.name || session.session_id}</h2>
|
||||
<div class="flex items-center gap-2 rounded-full border px-2.5 py-1 text-xs ${status.badge}">
|
||||
<span class="h-2 w-2 rounded-full ${status.dot}"></span>
|
||||
<span class="font-mono uppercase tracking-[0.14em]">${status.label}</span>
|
||||
</div>
|
||||
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${agent === 'codex' ? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300' : 'border-violet-400/45 bg-violet-500/14 text-violet-300'}">
|
||||
${agent}
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-4 text-sm text-dim">
|
||||
<span class="truncate font-mono">${session.cwd || 'No working directory'}</span>
|
||||
${session.started_at && html`
|
||||
<span class="flex-shrink-0 font-mono">Running ${formatDuration(session.started_at)}</span>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="ml-4 flex h-9 w-9 flex-shrink-0 items-center justify-center rounded-xl border border-selection/70 text-dim transition-colors hover:border-done/35 hover:bg-done/10 hover:text-bright"
|
||||
onClick=${onClose}
|
||||
>
|
||||
<svg class="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Modal Content -->
|
||||
<div ref=${chatContainerRef} class="flex-1 overflow-y-auto overflow-x-hidden bg-surface p-5">
|
||||
${conversationLoading ? html`
|
||||
<div class="flex items-center justify-center py-12">
|
||||
<div class="font-mono text-dim">Loading conversation...</div>
|
||||
</div>
|
||||
` : conversation.length > 0 ? html`
|
||||
<div class="space-y-4">
|
||||
${filterDisplayMessages(conversation).map((msg, i) => html`
|
||||
<${MessageBubble}
|
||||
key=${i}
|
||||
msg=${msg}
|
||||
userBg=${getUserMessageBg(session.status)}
|
||||
compact=${false}
|
||||
formatTime=${formatTime}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
` : html`
|
||||
<p class="text-dim text-center py-12">No conversation messages</p>
|
||||
`}
|
||||
</div>
|
||||
|
||||
<!-- Modal Footer -->
|
||||
<div class="border-t border-selection/70 bg-bg/55 p-4">
|
||||
${hasPendingQuestions && html`
|
||||
<div class="mb-3 rounded-xl border border-attention/40 bg-attention/12 px-3 py-2 text-sm text-attention">
|
||||
Agent is waiting for a response
|
||||
</div>
|
||||
`}
|
||||
<div class="flex items-end gap-2.5">
|
||||
<textarea
|
||||
ref=${inputRef}
|
||||
value=${inputValue}
|
||||
onInput=${(e) => {
|
||||
setInputValue(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = Math.min(e.target.scrollHeight, 150) + 'px';
|
||||
}}
|
||||
onKeyDown=${handleInputKeyDown}
|
||||
onFocus=${() => setInputFocused(true)}
|
||||
onBlur=${() => setInputFocused(false)}
|
||||
placeholder=${hasPendingQuestions ? "Type your response..." : "Send a message..."}
|
||||
rows="1"
|
||||
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-4 py-2 text-sm text-fg placeholder:text-dim focus:outline-none"
|
||||
style=${{ minHeight: '42px', maxHeight: '150px', borderColor: inputFocused ? status.borderColor : undefined }}
|
||||
disabled=${sending}
|
||||
/>
|
||||
<button
|
||||
class="rounded-xl px-4 py-2 font-medium transition-all hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
style=${{ backgroundColor: status.borderColor, color: '#0a0f18' }}
|
||||
onClick=${handleSend}
|
||||
disabled=${sending || !inputValue.trim()}
|
||||
>
|
||||
${sending ? 'Sending...' : 'Send'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2 font-mono text-label text-dim">
|
||||
Press Enter to send, Shift+Enter for new line, Escape to close
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
18
dashboard/components/OptionButton.js
Normal file
18
dashboard/components/OptionButton.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import { html } from '../lib/preact.js';
|
||||
|
||||
export function OptionButton({ number, label, description, onClick }) {
|
||||
return html`
|
||||
<button
|
||||
onClick=${onClick}
|
||||
class="group w-full rounded-xl border border-selection/70 bg-surface2/55 p-3.5 text-left transition-all duration-200 hover:-translate-y-0.5 hover:border-starting/55 hover:bg-surface2/90 hover:shadow-halo"
|
||||
>
|
||||
<div class="flex items-baseline gap-2.5">
|
||||
<span class="font-mono text-starting">${number}.</span>
|
||||
<span class="font-medium text-bright">${label}</span>
|
||||
</div>
|
||||
${description && html`
|
||||
<p class="mt-1 pl-5 text-sm text-dim">${description}</p>
|
||||
`}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
94
dashboard/components/QuestionBlock.js
Normal file
94
dashboard/components/QuestionBlock.js
Normal file
@@ -0,0 +1,94 @@
|
||||
import { html, useState } from '../lib/preact.js';
|
||||
import { getStatusMeta } from '../utils/status.js';
|
||||
import { OptionButton } from './OptionButton.js';
|
||||
|
||||
export function QuestionBlock({ questions, sessionId, status, onRespond }) {
|
||||
const [freeformText, setFreeformText] = useState('');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const meta = getStatusMeta(status);
|
||||
|
||||
if (!questions || questions.length === 0) return null;
|
||||
|
||||
// Only show the first question (sequential, not parallel)
|
||||
const question = questions[0];
|
||||
const remainingCount = questions.length - 1;
|
||||
const options = question.options || [];
|
||||
|
||||
const handleOptionClick = (optionLabel) => {
|
||||
onRespond(sessionId, optionLabel, false, options.length);
|
||||
};
|
||||
|
||||
const handleFreeformSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (freeformText.trim()) {
|
||||
onRespond(sessionId, freeformText.trim(), true, options.length);
|
||||
setFreeformText('');
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<div class="space-y-3" onClick=${(e) => e.stopPropagation()}>
|
||||
<!-- Question Header Badge -->
|
||||
${question.header && html`
|
||||
<span class="inline-flex rounded-full border px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] ${meta.badge}">
|
||||
${question.header}
|
||||
</span>
|
||||
`}
|
||||
|
||||
<!-- Question Text -->
|
||||
<p class="font-medium text-bright">${question.question || question.text}</p>
|
||||
|
||||
<!-- Options -->
|
||||
${options.length > 0 && html`
|
||||
<div class="space-y-2">
|
||||
${options.map((opt, i) => html`
|
||||
<${OptionButton}
|
||||
key=${i}
|
||||
number=${i + 1}
|
||||
label=${opt.label || opt}
|
||||
description=${opt.description}
|
||||
onClick=${() => handleOptionClick(opt.label || opt)}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
`}
|
||||
|
||||
<!-- Freeform Input -->
|
||||
<form onSubmit=${handleFreeformSubmit} class="flex items-end gap-2.5">
|
||||
<textarea
|
||||
value=${freeformText}
|
||||
onInput=${(e) => {
|
||||
setFreeformText(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
}}
|
||||
onKeyDown=${(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleFreeformSubmit(e);
|
||||
}
|
||||
}}
|
||||
onFocus=${() => setFocused(true)}
|
||||
onBlur=${() => setFocused(false)}
|
||||
placeholder="Type a response..."
|
||||
rows="1"
|
||||
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg placeholder:text-dim focus:outline-none"
|
||||
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-all hover:-translate-y-0.5 hover:brightness-110"
|
||||
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- More Questions Indicator -->
|
||||
${remainingCount > 0 && html`
|
||||
<p class="font-mono text-label text-dim">+ ${remainingCount} more question${remainingCount > 1 ? 's' : ''} after this</p>
|
||||
`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
103
dashboard/components/SessionCard.js
Normal file
103
dashboard/components/SessionCard.js
Normal file
@@ -0,0 +1,103 @@
|
||||
import { html, useEffect } from '../lib/preact.js';
|
||||
import { getStatusMeta } from '../utils/status.js';
|
||||
import { formatDuration, getContextUsageSummary } from '../utils/formatting.js';
|
||||
import { ChatMessages } from './ChatMessages.js';
|
||||
import { QuestionBlock } from './QuestionBlock.js';
|
||||
import { SimpleInput } from './SimpleInput.js';
|
||||
|
||||
export function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss }) {
|
||||
const hasQuestions = session.pending_questions && session.pending_questions.length > 0;
|
||||
const statusMeta = getStatusMeta(session.status);
|
||||
const agent = session.agent === 'codex' ? 'codex' : 'claude';
|
||||
const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude';
|
||||
const contextUsage = getContextUsageSummary(session.context_usage);
|
||||
|
||||
// Fetch conversation when card mounts
|
||||
useEffect(() => {
|
||||
if (!conversation && onFetchConversation) {
|
||||
onFetchConversation(session.session_id, session.project_dir, agent);
|
||||
}
|
||||
}, [session.session_id, session.project_dir, agent, conversation, onFetchConversation]);
|
||||
|
||||
const handleDismissClick = (e) => {
|
||||
e.stopPropagation();
|
||||
onDismiss(session.session_id);
|
||||
};
|
||||
|
||||
return html`
|
||||
<div
|
||||
class="glass-panel flex h-[850px] max-h-[850px] w-[600px] cursor-pointer flex-col overflow-hidden rounded-xl border border-selection/70 transition-all duration-200 hover:border-starting/35 hover:shadow-panel"
|
||||
style=${{ borderLeftWidth: '3px', borderLeftColor: statusMeta.borderColor }}
|
||||
onClick=${() => onClick(session)}
|
||||
>
|
||||
<!-- Card Header -->
|
||||
<div class="shrink-0 border-b px-4 py-3 ${agentHeaderClass}">
|
||||
<div class="flex items-start justify-between gap-2.5">
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2.5">
|
||||
<span class="h-2 w-2 shrink-0 rounded-full ${statusMeta.dot}"></span>
|
||||
<span class="truncate font-display text-base font-medium text-bright">${session.project || session.name || 'Session'}</span>
|
||||
</div>
|
||||
<div class="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${statusMeta.badge}">
|
||||
${statusMeta.label}
|
||||
</span>
|
||||
<span class="rounded-full border px-2.5 py-1 font-mono text-micro uppercase tracking-[0.14em] ${agent === 'codex' ? 'border-emerald-400/45 bg-emerald-500/14 text-emerald-300' : 'border-violet-400/45 bg-violet-500/14 text-violet-300'}">
|
||||
${agent}
|
||||
</span>
|
||||
${session.cwd && html`
|
||||
<span class="truncate rounded-full border border-selection bg-bg/40 px-2.5 py-1 font-mono text-micro text-dim">
|
||||
${session.cwd.split('/').slice(-2).join('/')}
|
||||
</span>
|
||||
`}
|
||||
</div>
|
||||
${contextUsage && html`
|
||||
<div class="mt-2 inline-flex max-w-full items-center gap-2 rounded-lg border border-selection/80 bg-bg/45 px-2.5 py-1.5 font-mono text-label text-dim" title=${contextUsage.title}>
|
||||
<span class="text-bright">${contextUsage.headline}</span>
|
||||
<span class="truncate">${contextUsage.detail}</span>
|
||||
${contextUsage.trail && html`<span class="text-dim/80">${contextUsage.trail}</span>`}
|
||||
</div>
|
||||
`}
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0 pt-0.5">
|
||||
<span class="font-mono text-xs tabular-nums text-dim">${formatDuration(session.started_at)}</span>
|
||||
${session.status === 'done' && html`
|
||||
<button
|
||||
onClick=${handleDismissClick}
|
||||
class="flex h-7 w-7 items-center justify-center rounded-lg border border-selection/80 text-dim transition-colors hover:border-done/40 hover:bg-done/10 hover:text-bright"
|
||||
title="Dismiss"
|
||||
>
|
||||
<svg class="h-3.5 w-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/>
|
||||
</svg>
|
||||
</button>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Card Content Area (Chat) -->
|
||||
<div class="flex-1 min-h-0 overflow-y-auto bg-surface px-4 py-4">
|
||||
<${ChatMessages} messages=${conversation || []} status=${session.status} />
|
||||
</div>
|
||||
|
||||
<!-- Card Footer (Input or Questions) -->
|
||||
<div class="shrink-0 border-t border-selection/70 bg-bg/55 p-4 ${hasQuestions ? 'max-h-[300px] overflow-y-auto' : ''}">
|
||||
${hasQuestions ? html`
|
||||
<${QuestionBlock}
|
||||
questions=${session.pending_questions}
|
||||
sessionId=${session.session_id}
|
||||
status=${session.status}
|
||||
onRespond=${onRespond}
|
||||
/>
|
||||
` : html`
|
||||
<${SimpleInput}
|
||||
sessionId=${session.session_id}
|
||||
status=${session.status}
|
||||
onRespond=${onRespond}
|
||||
/>
|
||||
`}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
56
dashboard/components/SessionGroup.js
Normal file
56
dashboard/components/SessionGroup.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import { html } from '../lib/preact.js';
|
||||
import { getStatusMeta, STATUS_PRIORITY } from '../utils/status.js';
|
||||
import { SessionCard } from './SessionCard.js';
|
||||
|
||||
export function SessionGroup({ projectName, projectDir, sessions, onCardClick, conversations, onFetchConversation, onRespond, onDismiss }) {
|
||||
if (sessions.length === 0) return null;
|
||||
|
||||
// Status summary for chips
|
||||
const statusCounts = {};
|
||||
for (const s of sessions) {
|
||||
statusCounts[s.status] = (statusCounts[s.status] || 0) + 1;
|
||||
}
|
||||
|
||||
// Group header dot uses the most urgent status
|
||||
const worstStatus = sessions.reduce((worst, s) => {
|
||||
return (STATUS_PRIORITY[s.status] ?? 99) < (STATUS_PRIORITY[worst] ?? 99) ? s.status : worst;
|
||||
}, 'done');
|
||||
const worstMeta = getStatusMeta(worstStatus);
|
||||
|
||||
return html`
|
||||
<section class="mb-12">
|
||||
<div class="mb-4 flex flex-wrap items-center gap-2.5 border-b border-selection/50 pb-3">
|
||||
<span class="h-2.5 w-2.5 rounded-full ${worstMeta.dot}"></span>
|
||||
<h2 class="font-display text-body font-semibold text-bright">${projectName}</h2>
|
||||
<span class="rounded-full border border-selection/80 bg-bg/55 px-2 py-0.5 font-mono text-micro text-dim">
|
||||
${sessions.length} agent${sessions.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
${Object.entries(statusCounts).map(([status, count]) => {
|
||||
const meta = getStatusMeta(status);
|
||||
return html`
|
||||
<span key=${status} class="rounded-full border px-2 py-0.5 font-mono text-micro ${meta.badge}">
|
||||
${count} ${meta.label.toLowerCase()}
|
||||
</span>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
${projectDir && projectDir !== 'unknown' && html`
|
||||
<div class="-mt-2 mb-3 truncate font-mono text-micro text-dim/60">${projectDir}</div>
|
||||
`}
|
||||
|
||||
<div class="flex flex-wrap gap-4">
|
||||
${sessions.map(session => html`
|
||||
<${SessionCard}
|
||||
key=${session.session_id}
|
||||
session=${session}
|
||||
onClick=${onCardClick}
|
||||
conversation=${conversations[session.session_id]}
|
||||
onFetchConversation=${onFetchConversation}
|
||||
onRespond=${onRespond}
|
||||
onDismiss=${onDismiss}
|
||||
/>
|
||||
`)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
102
dashboard/components/Sidebar.js
Normal file
102
dashboard/components/Sidebar.js
Normal file
@@ -0,0 +1,102 @@
|
||||
import { html } from '../lib/preact.js';
|
||||
import { getStatusMeta, STATUS_PRIORITY } from '../utils/status.js';
|
||||
|
||||
export function Sidebar({ projectGroups, selectedProject, onSelectProject, totalSessions }) {
|
||||
// Calculate totals for "All Projects"
|
||||
const allStatusCounts = { needs_attention: 0, active: 0, starting: 0, done: 0 };
|
||||
for (const group of projectGroups) {
|
||||
for (const s of group.sessions) {
|
||||
allStatusCounts[s.status] = (allStatusCounts[s.status] || 0) + 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Worst status across all projects
|
||||
const allWorstStatus = totalSessions > 0
|
||||
? Object.keys(allStatusCounts).reduce((worst, status) =>
|
||||
allStatusCounts[status] > 0 && (STATUS_PRIORITY[status] ?? 99) < (STATUS_PRIORITY[worst] ?? 99) ? status : worst
|
||||
, 'done')
|
||||
: 'done';
|
||||
const allWorstMeta = getStatusMeta(allWorstStatus);
|
||||
|
||||
// Tiny inline status indicator
|
||||
const StatusPips = ({ counts }) => html`
|
||||
<div class="flex items-center gap-1 shrink-0">
|
||||
${counts.needs_attention > 0 && html`<span class="rounded-full bg-attention/20 px-1.5 py-0.5 font-mono text-micro tabular-nums text-attention">${counts.needs_attention}</span>`}
|
||||
${counts.active > 0 && html`<span class="rounded-full bg-active/20 px-1.5 py-0.5 font-mono text-micro tabular-nums text-active">${counts.active}</span>`}
|
||||
${counts.starting > 0 && html`<span class="rounded-full bg-starting/20 px-1.5 py-0.5 font-mono text-micro tabular-nums text-starting">${counts.starting}</span>`}
|
||||
${counts.done > 0 && html`<span class="rounded-full bg-done/15 px-1.5 py-0.5 font-mono text-micro tabular-nums text-done/70">${counts.done}</span>`}
|
||||
</div>
|
||||
`;
|
||||
|
||||
return html`
|
||||
<aside class="fixed left-0 top-0 z-40 flex h-screen w-80 flex-col border-r border-selection/50 bg-surface/95 backdrop-blur-sm">
|
||||
<!-- Sidebar Header -->
|
||||
<div class="shrink-0 border-b border-selection/50 px-5 py-4">
|
||||
<div class="inline-flex items-center gap-2 rounded-full border border-starting/40 bg-starting/10 px-2.5 py-0.5 text-micro font-medium uppercase tracking-[0.2em] text-starting">
|
||||
<span class="h-1.5 w-1.5 rounded-full bg-starting animate-float"></span>
|
||||
Control Plane
|
||||
</div>
|
||||
<h1 class="mt-2 font-display text-lg font-semibold text-bright">
|
||||
Agent Mission Control
|
||||
</h1>
|
||||
</div>
|
||||
|
||||
<!-- Project List -->
|
||||
<nav class="flex-1 overflow-y-auto px-3 py-3">
|
||||
<!-- All Projects -->
|
||||
<button
|
||||
onClick=${() => onSelectProject(null)}
|
||||
class="group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left transition-all ${
|
||||
selectedProject === null
|
||||
? 'bg-selection/50'
|
||||
: 'hover:bg-selection/25'
|
||||
}"
|
||||
>
|
||||
<span class="h-2 w-2 shrink-0 rounded-full ${allWorstMeta.dot}"></span>
|
||||
<span class="flex-1 truncate font-medium text-bright">All Projects</span>
|
||||
<${StatusPips} counts=${allStatusCounts} />
|
||||
</button>
|
||||
|
||||
<!-- Divider -->
|
||||
<div class="my-2 border-t border-selection/30"></div>
|
||||
|
||||
<!-- Individual Projects -->
|
||||
${projectGroups.map(group => {
|
||||
const statusCounts = { needs_attention: 0, active: 0, starting: 0, done: 0 };
|
||||
for (const s of group.sessions) {
|
||||
statusCounts[s.status] = (statusCounts[s.status] || 0) + 1;
|
||||
}
|
||||
|
||||
const worstStatus = group.sessions.reduce((worst, s) =>
|
||||
(STATUS_PRIORITY[s.status] ?? 99) < (STATUS_PRIORITY[worst] ?? 99) ? s.status : worst
|
||||
, 'done');
|
||||
const worstMeta = getStatusMeta(worstStatus);
|
||||
const isSelected = selectedProject === group.projectDir;
|
||||
|
||||
return html`
|
||||
<button
|
||||
key=${group.projectDir}
|
||||
onClick=${() => onSelectProject(group.projectDir)}
|
||||
class="group flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-left transition-all ${
|
||||
isSelected
|
||||
? 'bg-selection/50'
|
||||
: 'hover:bg-selection/25'
|
||||
}"
|
||||
>
|
||||
<span class="h-2 w-2 shrink-0 rounded-full ${worstMeta.dot}"></span>
|
||||
<span class="flex-1 truncate text-fg">${group.projectName}</span>
|
||||
<${StatusPips} counts=${statusCounts} />
|
||||
</button>
|
||||
`;
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<!-- Sidebar Footer -->
|
||||
<div class="shrink-0 border-t border-selection/50 px-5 py-3">
|
||||
<div class="font-mono text-micro text-dim">
|
||||
${totalSessions} session${totalSessions === 1 ? '' : 's'} total
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
`;
|
||||
}
|
||||
49
dashboard/components/SimpleInput.js
Normal file
49
dashboard/components/SimpleInput.js
Normal file
@@ -0,0 +1,49 @@
|
||||
import { html, useState } from '../lib/preact.js';
|
||||
import { getStatusMeta } from '../utils/status.js';
|
||||
|
||||
export function SimpleInput({ sessionId, status, onRespond }) {
|
||||
const [text, setText] = useState('');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const meta = getStatusMeta(status);
|
||||
|
||||
const handleSubmit = (e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (text.trim()) {
|
||||
onRespond(sessionId, text.trim(), true, 0);
|
||||
setText('');
|
||||
}
|
||||
};
|
||||
|
||||
return html`
|
||||
<form onSubmit=${handleSubmit} class="flex items-end gap-2.5" onClick=${(e) => e.stopPropagation()}>
|
||||
<textarea
|
||||
value=${text}
|
||||
onInput=${(e) => {
|
||||
setText(e.target.value);
|
||||
e.target.style.height = 'auto';
|
||||
e.target.style.height = e.target.scrollHeight + 'px';
|
||||
}}
|
||||
onKeyDown=${(e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
}}
|
||||
onFocus=${() => setFocused(true)}
|
||||
onBlur=${() => setFocused(false)}
|
||||
placeholder="Send a message..."
|
||||
rows="1"
|
||||
class="flex-1 resize-none overflow-hidden rounded-xl border border-selection/75 bg-bg/70 px-3 py-2 text-sm text-fg placeholder:text-dim focus:outline-none"
|
||||
style=${{ minHeight: '38px', maxHeight: '150px', borderColor: focused ? meta.borderColor : undefined }}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="shrink-0 rounded-xl px-3 py-2 text-sm font-medium transition-all hover:-translate-y-0.5 hover:brightness-110"
|
||||
style=${{ backgroundColor: meta.borderColor, color: '#0a0f18' }}
|
||||
>
|
||||
Send
|
||||
</button>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user