Files
amc/dashboard-preact.html
teernisse 4d2fcc9a3b feat(dashboard): rewrite UI with Preact for reactive session monitoring
Replace vanilla JS dashboard with a modern Preact-based implementation:

- Component architecture: Header, SessionCard, SessionGroup, Modal,
  ChatMessages, QuestionBlock, SimpleInput, OptionButton, EmptyState
- Status-grouped display: sessions organized by needs_attention, active,
  starting, and done with color-coded visual indicators
- Real-time conversation view: fetch and display chat history from
  Claude Code JSONL logs with auto-scroll on new messages
- Inline response input: send messages directly from cards without
  opening modal, supports both structured options and freeform text
- Modal detail view: full conversation with markdown rendering,
  timestamps, and keyboard navigation (Enter to send, Escape to close)
- Smart polling: 3-second state refresh with silent conversation
  updates for active sessions to minimize UI flicker
- Flexoki color scheme: custom dark theme with attention (amber),
  active (green), starting (blue), and done (purple) status colors
- Berkeley Mono font stack with graceful fallbacks
- Tailwind CSS via CDN with custom color configuration
- Responsive card layout with fixed 600px height and overflow handling

The dashboard provides a unified view for monitoring multiple Claude
Code sessions, detecting when agents need human input, and responding
directly via Zellij pane injection.
2026-02-25 11:21:15 -05:00

1213 lines
47 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Agent Mission Control</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;600;700&family=IBM+Plex+Mono:wght@400;500&family=IBM+Plex+Sans:wght@400;500;600&display=swap" rel="stylesheet">
<!-- Tailwind CDN -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
bg: '#060d1a',
surface: '#0e1728',
surface2: '#16233a',
selection: '#2a3c5c',
fg: '#d9e3f7',
bright: '#f7faff',
dim: '#92a6c9',
active: '#69c39a',
attention: '#d4ad5d',
starting: '#79a6ff',
done: '#d19189',
},
fontFamily: {
display: ['Space Grotesk', 'IBM Plex Sans', 'sans-serif'],
sans: ['IBM Plex Sans', 'system-ui', 'sans-serif'],
mono: ['IBM Plex Mono', 'SFMono-Regular', 'Menlo', 'monospace'],
},
fontSize: {
micro: ['clamp(0.68rem, 0.05vw + 0.66rem, 0.78rem)', { lineHeight: '1.35' }],
label: ['clamp(0.76rem, 0.07vw + 0.74rem, 0.86rem)', { lineHeight: '1.4' }],
ui: ['clamp(0.84rem, 0.09vw + 0.81rem, 0.94rem)', { lineHeight: '1.45' }],
body: ['clamp(0.88rem, 0.1vw + 0.85rem, 0.98rem)', { lineHeight: '1.55' }],
chat: ['clamp(0.92rem, 0.12vw + 0.89rem, 1.02rem)', { lineHeight: '1.6' }],
},
boxShadow: {
panel: '0 8px 18px rgba(10, 14, 20, 0.28)',
halo: '0 0 0 1px rgba(117, 138, 166, 0.12), 0 6px 14px rgba(10, 14, 20, 0.24)',
},
keyframes: {
float: {
'0%, 100%': { transform: 'translateY(0)' },
'50%': { transform: 'translateY(-4px)' },
},
fadeInUp: {
'0%': { opacity: '0', transform: 'translateY(10px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
},
animation: {
float: 'float 6s ease-in-out infinite',
'fade-in-up': 'fadeInUp 0.35s ease-out',
},
}
},
safelist: [
'bg-attention/30',
'bg-active/30',
'bg-starting/30',
'bg-done/30',
'bg-attention/18',
'bg-active/18',
'bg-starting/18',
'bg-done/18',
'bg-selection/80',
'border-attention/40',
'border-active/40',
'border-starting/40',
'border-done/40',
'border-l-attention',
'border-l-active',
'border-l-starting',
'border-l-done',
'text-attention',
'text-active',
'text-starting',
'text-done',
'border-emerald-500/30',
'bg-emerald-500/10',
'text-emerald-400',
'border-violet-500/30',
'bg-violet-500/10',
'text-violet-400',
]
}
</script>
<style>
html {
font-size: 16px;
}
:root {
--bg-flat: #060d1a;
--glass-border: rgba(137, 171, 226, 0.24);
}
* {
scrollbar-width: thin;
scrollbar-color: #4e658d #0f1a2d;
}
body {
margin: 0;
font-family: 'IBM Plex Sans', system-ui, sans-serif;
background: var(--bg-flat);
min-height: 100vh;
color: #d9e3f7;
letter-spacing: 0.01em;
}
#app {
position: relative;
min-height: 100vh;
}
#app > * {
position: relative;
z-index: 1;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #0f1a2d;
}
::-webkit-scrollbar-thumb {
background: #4e658d;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #617ba8;
}
@keyframes pulse-attention {
0%, 100% {
opacity: 1;
transform: scale(1) translateY(0);
}
50% {
opacity: 0.62;
transform: scale(1.12) translateY(-1px);
}
}
.pulse-attention {
animation: pulse-attention 2s ease-in-out infinite;
}
.glass-panel {
backdrop-filter: blur(10px);
border: 1px solid var(--glass-border);
background: rgba(11, 20, 35, 0.94);
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.3), inset 0 1px 0 rgba(177, 204, 252, 0.06);
}
</style>
</head>
<body class="min-h-screen text-fg antialiased">
<div id="app"></div>
<script type="module">
import { h, render } from 'https://esm.sh/preact@10.19.3';
import { useState, useEffect, useRef, useCallback } from 'https://esm.sh/preact@10.19.3/hooks';
import htm from 'https://esm.sh/htm@3.1.1';
const html = htm.bind(h);
// API Constants
const API_STATE = '/api/state';
const API_DISMISS = '/api/dismiss/';
const API_RESPOND = '/api/respond/';
const API_CONVERSATION = '/api/conversation/';
const POLL_MS = 3000;
// -----------------------------
// Helper Functions
// -----------------------------
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';
}
function formatTime(isoTime) {
if (!isoTime) return '';
const date = new Date(isoTime);
return date.toLocaleTimeString('en-US', {
hour: 'numeric',
minute: '2-digit',
hour12: true
});
}
// Render markdown-ish content with basic styling
function renderContent(content) {
if (!content) return '';
// Split by code blocks first
const parts = content.split(/(```[\s\S]*?```|`[^`]+`)/g);
return parts.map((part, i) => {
// Multi-line code block
if (part.startsWith('```') && part.endsWith('```')) {
const code = part.slice(3, -3).replace(/^\w+\n/, ''); // Remove language identifier
return html`<pre key=${i} class="my-2 overflow-x-auto whitespace-pre-wrap rounded-xl border border-selection/75 bg-bg/65 p-3 font-mono text-xs leading-relaxed">${code}</pre>`;
}
// Inline code
if (part.startsWith('`') && part.endsWith('`')) {
return html`<code key=${i} class="rounded border border-selection/80 bg-bg/55 px-1.5 py-0.5 font-mono text-[0.9em]">${part.slice(1, -1)}</code>`;
}
// Regular text - preserve line breaks
return part.split('\n').map((line, j) =>
j === 0 ? line : html`<br key="${i}-${j}" />${line}`
);
});
}
// -----------------------------
// Helper: Get user message background class based on status
// -----------------------------
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';
}
}
// -----------------------------
// Helper: Status display metadata
// -----------------------------
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: '#d4ad5d',
};
case 'active':
return {
label: 'Active',
dot: 'bg-active',
badge: 'bg-active/18 text-active border-active/40',
borderColor: '#69c39a',
};
case 'starting':
return {
label: 'Starting',
dot: 'bg-starting',
badge: 'bg-starting/18 text-starting border-starting/40',
borderColor: '#79a6ff',
};
case 'done':
return {
label: 'Done',
dot: 'bg-done',
badge: 'bg-done/18 text-done border-done/40',
borderColor: '#d19189',
};
default:
return {
label: status || 'Unknown',
dot: 'bg-dim',
badge: 'bg-selection text-dim border-selection',
borderColor: '#2a3c5c',
};
}
}
// -----------------------------
// ChatMessages Component
// -----------------------------
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>
`;
}
// Show last 20 messages only
const displayMessages = 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`
<div
key=${i}
class="flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-fade-in-up"
>
<div
class="max-w-[92%] whitespace-pre-wrap break-words rounded-2xl px-3 py-2.5 text-ui ${
msg.role === 'user'
? `${userBgClass} 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">
${msg.role === 'user' ? 'Operator' : 'Agent'}
</div>
${msg.content}
</div>
</div>
`)}
</div>
`;
}
// -----------------------------
// Header Component
// -----------------------------
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>
`;
}
// -----------------------------
// OptionButton Component
// -----------------------------
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>
`;
}
// -----------------------------
// QuestionBlock Component
// -----------------------------
function QuestionBlock({ questions, sessionId, onRespond }) {
const [freeformText, setFreeformText] = useState('');
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 border-attention/40 bg-attention/10 px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] text-attention">
${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);
}
}}
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:border-attention/70 focus:outline-none"
style="min-height: 38px; max-height: 150px;"
/>
<button
type="submit"
class="shrink-0 rounded-xl bg-attention px-3 py-2 text-sm font-medium text-[#16110a] transition-all hover:-translate-y-0.5 hover:brightness-110"
>
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>
`;
}
// -----------------------------
// SimpleInput Component (for cards without pending questions)
// -----------------------------
function SimpleInput({ sessionId, onRespond }) {
const [text, setText] = useState('');
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);
}
}}
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:border-active/70 focus:outline-none"
style="min-height: 38px; max-height: 150px;"
/>
<button
type="submit"
class="shrink-0 rounded-xl bg-active px-3 py-2 text-sm font-medium text-[#08140f] transition-all hover:-translate-y-0.5 hover:brightness-110"
>
Send
</button>
</form>
`;
}
// -----------------------------
// SessionCard Component
// -----------------------------
function SessionCard({ session, onClick, conversation, onFetchConversation, onRespond, onDismiss }) {
const hasQuestions = session.pending_questions && session.pending_questions.length > 0;
const scrollRef = useRef(null);
const statusMeta = getStatusMeta(session.status);
// Fetch conversation when card mounts
useEffect(() => {
if (!conversation && onFetchConversation) {
onFetchConversation(session.session_id, session.project_dir, session.agent || 'claude');
}
}, [session.session_id, session.project_dir, session.agent, conversation, onFetchConversation]);
// Scroll chat to bottom when conversation loads or updates
useEffect(() => {
if (scrollRef.current && conversation && conversation.length > 0) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [conversation]);
const handleDismissClick = (e) => {
e.stopPropagation();
onDismiss(session.session_id);
};
return html`
<div
class="glass-panel flex h-[460px] w-full max-w-full 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 border-selection/70 px-4 py-3">
<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] ${session.agent === 'codex' ? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400' : 'border-violet-500/30 bg-violet-500/10 text-violet-400'}">
${session.agent === 'codex' ? 'codex' : 'claude'}
</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>
</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 ref=${scrollRef} 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}
onRespond=${onRespond}
/>
` : html`
<${SimpleInput}
sessionId=${session.session_id}
onRespond=${onRespond}
/>
`}
</div>
</div>
`;
}
// -----------------------------
// SessionGroup Component
// -----------------------------
function SessionGroup({ title, sessions, onCardClick, statusColor, conversations, onFetchConversation, onRespond, onDismiss }) {
if (sessions.length === 0) return null;
const textColor = statusColor.replace('bg-', 'text-');
return html`
<section class="mb-12">
<div class="mb-5 flex items-center gap-2 border-b border-selection/50 pb-3">
<span class="h-2.5 w-2.5 rounded-full ${statusColor} ${title.toLowerCase().includes('attention') ? 'pulse-attention' : ''}"></span>
<h2 class="font-mono text-label font-medium uppercase tracking-[0.2em] ${textColor}">${title}</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}</span>
</div>
<div class="grid grid-cols-1 gap-4 lg:grid-cols-2">
${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>
`;
}
// -----------------------------
// Modal Component
// -----------------------------
function Modal({ session, conversations, conversationLoading, onClose, onSendMessage, onRefreshConversation }) {
const [inputValue, setInputValue] = useState('');
const [sending, setSending] = 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);
// 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]);
// 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, session.agent || 'claude');
}
} 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 border-selection/70 px-5 py-4">
<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>
</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-[linear-gradient(180deg,rgba(9,18,34,0.82),rgba(5,10,20,0.9))] 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">
${conversation.map((msg, i) => html`
<div
key=${i}
class="flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'} animate-fade-in-up"
>
<div
class="max-w-[86%] rounded-2xl px-4 py-3 ${
msg.role === 'user'
? `${getUserMessageBg(session.status)} rounded-br-md shadow-[0_4px_10px_rgba(16,24,36,0.24)]`
: 'border border-selection/75 bg-surface2/78 text-fg rounded-bl-md'
}"
>
<div class="mb-1 font-mono text-micro uppercase tracking-[0.16em] text-dim">
${msg.role === 'user' ? 'Operator' : 'Agent'}
</div>
<div class="whitespace-pre-wrap break-words text-micro">
${renderContent(msg.content)}
</div>
${msg.timestamp && html`
<div class="mt-2 font-mono text-label text-dim">
${formatTime(msg.timestamp)}
</div>
`}
</div>
</div>
`)}
</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}
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:border-active/70 focus:outline-none"
style="min-height: 42px; max-height: 150px;"
disabled=${sending}
/>
<button
class="rounded-xl bg-active px-4 py-2 font-medium text-[#08140f] transition-all hover:-translate-y-0.5 hover:brightness-110 disabled:cursor-not-allowed disabled:opacity-50"
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>
`;
}
// -----------------------------
// Empty State Component
// -----------------------------
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>
`;
}
// -----------------------------
// App Component
// -----------------------------
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);
// 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
}
}, []);
// Fetch state from API
const fetchState = useCallback(async () => {
try {
const response = await fetch(API_STATE);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
const newSessions = data.sessions || [];
setSessions(newSessions);
setError(null);
// Update modalSession if it's still open (to get latest pending_questions, etc.)
setModalSession(prev => {
if (!prev) return null;
const updatedSession = newSessions.find(s => s.session_id === prev.session_id);
return updatedSession || prev;
});
// Refresh conversations for active/attention sessions (they're actively changing)
for (const session of newSessions) {
if (session.status === 'active' || session.status === 'needs_attention') {
// Force refresh conversation in background (no loading indicator)
refreshConversationSilent(session.session_id, session.project_dir, session.agent || 'claude');
}
}
} catch (err) {
console.error('Failed to fetch state:', err);
setError(err.message);
} finally {
setLoading(false);
}
}, [refreshConversationSilent]);
// 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]);
// Poll for updates
useEffect(() => {
fetchState();
const interval = setInterval(fetchState, POLL_MS);
return () => clearInterval(interval);
}, [fetchState]);
// Group sessions by status
const attentionSessions = sessions.filter(s => s.status === 'needs_attention');
const activeSessions = sessions.filter(s => s.status === 'active');
const startingSessions = sessions.filter(s => s.status === 'starting');
const doneSessions = sessions.filter(s => s.status === 'done');
// 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);
}, []);
return html`
<div class="pb-10">
<${Header} sessions=${sessions} />
<main class="px-4 pb-6 pt-6 sm:px-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>
` : sessions.length === 0 ? html`
<${EmptyState} />
` : html`
<${SessionGroup}
title="Needs Attention"
sessions=${attentionSessions}
onCardClick=${handleCardClick}
statusColor="bg-attention"
conversations=${conversations}
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
/>
<${SessionGroup}
title="Active"
sessions=${activeSessions}
onCardClick=${handleCardClick}
statusColor="bg-active"
conversations=${conversations}
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
/>
<${SessionGroup}
title="Starting"
sessions=${startingSessions}
onCardClick=${handleCardClick}
statusColor="bg-starting"
conversations=${conversations}
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
/>
<${SessionGroup}
title="Done"
sessions=${doneSessions}
onCardClick=${handleCardClick}
statusColor="bg-done"
conversations=${conversations}
onFetchConversation=${fetchConversation}
onRespond=${respondToSession}
onDismiss=${dismissSession}
/>
`}
</main>
</div>
<${Modal}
session=${modalSession}
conversations=${conversations}
conversationLoading=${conversationLoading}
onClose=${handleCloseModal}
onSendMessage=${respondToSession}
onRefreshConversation=${refreshConversation}
/>
`;
}
// Mount the app
render(html`<${App} />`, document.getElementById('app'));
</script>
</body>
</html>