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.
1213 lines
47 KiB
HTML
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>
|