diff --git a/dashboard-preact.html b/dashboard-preact.html
deleted file mode 100644
index 0de03f2..0000000
--- a/dashboard-preact.html
+++ /dev/null
@@ -1,1530 +0,0 @@
-
-
-
-
-
-
No active sessions
-
Start a Claude Code session to see it here
-
-
-
-
-
+ ${displayMessages.map((msg, i) => html`
+ <${MessageBubble}
+ key=${i}
+ msg=${msg}
+ userBg=${userBgClass}
+ compact=${true}
+ />
+ `)}
+
+ `;
+}
diff --git a/dashboard/components/EmptyState.js b/dashboard/components/EmptyState.js
new file mode 100644
index 0000000..61c1df7
--- /dev/null
+++ b/dashboard/components/EmptyState.js
@@ -0,0 +1,18 @@
+import { html } from '../lib/preact.js';
+
+export function EmptyState() {
+ return html`
+
+
+
No Active Sessions
+
+ Agent sessions will appear here when they connect. Start a Claude Code session to see it in the dashboard.
+
+
+ `;
+}
diff --git a/dashboard/components/Header.js b/dashboard/components/Header.js
new file mode 100644
index 0000000..49f7961
--- /dev/null
+++ b/dashboard/components/Header.js
@@ -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`
+
+ `;
+}
diff --git a/dashboard/components/QuestionBlock.js b/dashboard/components/QuestionBlock.js
new file mode 100644
index 0000000..3bd9142
--- /dev/null
+++ b/dashboard/components/QuestionBlock.js
@@ -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`
+ e.stopPropagation()}>
+
+ ${question.header && html`
+
+ ${question.header}
+
+ `}
+
+
+
${question.question || question.text}
+
+
+ ${options.length > 0 && html`
+
+ ${options.map((opt, i) => html`
+ <${OptionButton}
+ key=${i}
+ number=${i + 1}
+ label=${opt.label || opt}
+ description=${opt.description}
+ onClick=${() => handleOptionClick(opt.label || opt)}
+ />
+ `)}
+
+ `}
+
+
+
+
+
+ ${remainingCount > 0 && html`
+
+ ${remainingCount} more question${remainingCount > 1 ? 's' : ''} after this
+ `}
+
+ `;
+}
diff --git a/dashboard/components/SessionCard.js b/dashboard/components/SessionCard.js
new file mode 100644
index 0000000..40a09d5
--- /dev/null
+++ b/dashboard/components/SessionCard.js
@@ -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`
+ onClick(session)}
+ >
+
+
+
+
+
+ <${ChatMessages} messages=${conversation || []} status=${session.status} />
+
+
+
+
+ ${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}
+ />
+ `}
+
+
+ `;
+}
diff --git a/dashboard/components/SessionGroup.js b/dashboard/components/SessionGroup.js
new file mode 100644
index 0000000..166ff22
--- /dev/null
+++ b/dashboard/components/SessionGroup.js
@@ -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`
+
+ ${toolCalls.map(tc => {
+ const summary = getToolSummary(tc.name, tc.input);
+ return html`
+
+ ${tc.name}
+ ${summary !== tc.name && html`${summary}`}
+
+ `;
+ })}
+
+ `;
+}
+
+// Render thinking block (full content, open by default)
+// Content is sanitized with DOMPurify before rendering
+export function renderThinking(thinking) {
+ if (!thinking) return '';
+ const rawHtml = marked.parse(thinking);
+ const safeHtml = DOMPurify.sanitize(rawHtml);
+ return html`
+