Markdown & syntax highlighting: - Replace homegrown backtick parser with marked + DOMPurify + highlight.js - Register 10 language grammars (JS, TS, Bash, JSON, Python, Rust, CSS, HTML/XML, SQL, YAML) for fenced code block highlighting - Sanitize rendered HTML via DOMPurify to prevent XSS from message content Project-based session grouping: - Replace four status-based groups (attention/active/starting/done) with project-directory grouping via groupSessionsByProject() - Groups sort by most urgent status first, then most recent activity - Sessions within each group sort by urgency then recency - Group headers show project name, agent count, and per-status chips - Project directory path shown below group header Visual refinements: - Darken color palette across the board (bg, surface, scrollbar, glass panels) for deeper contrast - Add agent-header-codex and agent-header-claude CSS classes for color-coded card and modal headers (teal vs purple tints) - Increase agent badge visibility with higher opacity borders/backgrounds - Cards now fixed-width 600px with taller height (850px) in flex-wrap layout instead of responsive grid - Input focus border color now matches the session's status color instead of hardcoded active/attention colors - Send button background also matches status color for visual coherence - Lock body scroll when session modal is open - Add agent badge to modal header for consistent identification
1531 lines
55 KiB
HTML
1531 lines
55 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: '#01040b',
|
|
surface: '#070d18',
|
|
surface2: '#0d1830',
|
|
selection: '#223454',
|
|
fg: '#e0ebff',
|
|
bright: '#fbfdff',
|
|
dim: '#8ba3cc',
|
|
active: '#5fd0a4',
|
|
attention: '#e0b45e',
|
|
starting: '#7cb2ff',
|
|
done: '#e39a8c',
|
|
},
|
|
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-emerald-400/45',
|
|
'bg-emerald-500/14',
|
|
'text-emerald-300',
|
|
'border-violet-500/30',
|
|
'bg-violet-500/10',
|
|
'text-violet-400',
|
|
'border-violet-400/45',
|
|
'bg-violet-500/14',
|
|
'text-violet-300',
|
|
]
|
|
}
|
|
</script>
|
|
|
|
<style>
|
|
html {
|
|
font-size: 16px;
|
|
}
|
|
|
|
:root {
|
|
--bg-flat: #01040b;
|
|
--glass-border: rgba(116, 154, 214, 0.22);
|
|
}
|
|
|
|
* {
|
|
scrollbar-width: thin;
|
|
scrollbar-color: #445f8e #0a1222;
|
|
}
|
|
|
|
body {
|
|
margin: 0;
|
|
font-family: 'IBM Plex Sans', system-ui, sans-serif;
|
|
background: var(--bg-flat);
|
|
min-height: 100vh;
|
|
color: #e0ebff;
|
|
letter-spacing: 0.01em;
|
|
}
|
|
|
|
#app {
|
|
position: relative;
|
|
min-height: 100vh;
|
|
}
|
|
|
|
#app > *:not(.fixed) {
|
|
position: relative;
|
|
z-index: 1;
|
|
}
|
|
|
|
::-webkit-scrollbar {
|
|
width: 8px;
|
|
height: 8px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: #0a1222;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: #445f8e;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb:hover {
|
|
background: #5574aa;
|
|
}
|
|
|
|
@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(7, 13, 24, 0.95);
|
|
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.36), inset 0 1px 0 rgba(151, 185, 245, 0.05);
|
|
}
|
|
|
|
.agent-header-codex {
|
|
background: rgba(20, 60, 54, 0.4);
|
|
border-bottom-color: rgba(116, 227, 196, 0.34);
|
|
}
|
|
|
|
.agent-header-claude {
|
|
background: rgba(45, 36, 78, 0.42);
|
|
border-bottom-color: rgba(179, 154, 255, 0.36);
|
|
}
|
|
|
|
/* Markdown content styling */
|
|
.md-content {
|
|
line-height: 1.6;
|
|
}
|
|
|
|
.md-content > *:first-child {
|
|
margin-top: 0;
|
|
}
|
|
|
|
.md-content > *:last-child {
|
|
margin-bottom: 0;
|
|
}
|
|
|
|
.md-content h1, .md-content h2, .md-content h3,
|
|
.md-content h4, .md-content h5, .md-content h6 {
|
|
font-family: 'Space Grotesk', 'IBM Plex Sans', sans-serif;
|
|
font-weight: 600;
|
|
color: #fbfdff;
|
|
margin: 1.25em 0 0.5em;
|
|
line-height: 1.3;
|
|
}
|
|
|
|
.md-content h1 { font-size: 1.4em; }
|
|
.md-content h2 { font-size: 1.25em; }
|
|
.md-content h3 { font-size: 1.1em; }
|
|
.md-content h4, .md-content h5, .md-content h6 { font-size: 1em; }
|
|
|
|
.md-content p {
|
|
margin: 0.75em 0;
|
|
}
|
|
|
|
.md-content strong {
|
|
color: #fbfdff;
|
|
font-weight: 600;
|
|
}
|
|
|
|
.md-content em {
|
|
color: #c8d8f0;
|
|
}
|
|
|
|
.md-content a {
|
|
color: #7cb2ff;
|
|
text-decoration: underline;
|
|
text-underline-offset: 2px;
|
|
}
|
|
|
|
.md-content a:hover {
|
|
color: #a8ccff;
|
|
}
|
|
|
|
.md-content code {
|
|
font-family: 'IBM Plex Mono', monospace;
|
|
font-size: 0.9em;
|
|
background: rgba(1, 4, 11, 0.55);
|
|
border: 1px solid rgba(34, 52, 84, 0.8);
|
|
border-radius: 4px;
|
|
padding: 0.15em 0.4em;
|
|
}
|
|
|
|
.md-content pre {
|
|
margin: 0.75em 0;
|
|
padding: 0.875rem 1rem;
|
|
background: rgba(1, 4, 11, 0.65);
|
|
border: 1px solid rgba(34, 52, 84, 0.75);
|
|
border-radius: 0.75rem;
|
|
overflow-x: auto;
|
|
font-size: 0.85em;
|
|
line-height: 1.5;
|
|
}
|
|
|
|
.md-content pre code {
|
|
background: none;
|
|
border: none;
|
|
padding: 0;
|
|
font-size: inherit;
|
|
}
|
|
|
|
.md-content ul, .md-content ol {
|
|
margin: 0.75em 0;
|
|
padding-left: 1.5em;
|
|
}
|
|
|
|
.md-content li {
|
|
margin: 0.25em 0;
|
|
}
|
|
|
|
.md-content li > ul, .md-content li > ol {
|
|
margin: 0.25em 0;
|
|
}
|
|
|
|
.md-content blockquote {
|
|
margin: 0.75em 0;
|
|
padding: 0.5em 1em;
|
|
border-left: 3px solid rgba(124, 178, 255, 0.5);
|
|
background: rgba(34, 52, 84, 0.25);
|
|
border-radius: 0 0.5rem 0.5rem 0;
|
|
color: #c8d8f0;
|
|
}
|
|
|
|
.md-content hr {
|
|
border: none;
|
|
border-top: 1px solid rgba(34, 52, 84, 0.6);
|
|
margin: 1.25em 0;
|
|
}
|
|
|
|
.md-content table {
|
|
width: 100%;
|
|
border-collapse: collapse;
|
|
margin: 0.75em 0;
|
|
font-size: 0.9em;
|
|
}
|
|
|
|
.md-content th, .md-content td {
|
|
border: 1px solid rgba(34, 52, 84, 0.6);
|
|
padding: 0.5em 0.75em;
|
|
text-align: left;
|
|
}
|
|
|
|
.md-content th {
|
|
background: rgba(34, 52, 84, 0.35);
|
|
font-weight: 600;
|
|
color: #fbfdff;
|
|
}
|
|
|
|
.md-content tr:nth-child(even) {
|
|
background: rgba(34, 52, 84, 0.15);
|
|
}
|
|
|
|
/* Highlight.js syntax theme (dark) */
|
|
.hljs {
|
|
color: #e0ebff;
|
|
}
|
|
|
|
.hljs-keyword,
|
|
.hljs-selector-tag,
|
|
.hljs-built_in,
|
|
.hljs-name,
|
|
.hljs-tag {
|
|
color: #c792ea;
|
|
}
|
|
|
|
.hljs-string,
|
|
.hljs-title,
|
|
.hljs-section,
|
|
.hljs-attribute,
|
|
.hljs-literal,
|
|
.hljs-template-tag,
|
|
.hljs-template-variable,
|
|
.hljs-type,
|
|
.hljs-addition {
|
|
color: #c3e88d;
|
|
}
|
|
|
|
.hljs-comment,
|
|
.hljs-quote,
|
|
.hljs-deletion,
|
|
.hljs-meta {
|
|
color: #697098;
|
|
}
|
|
|
|
.hljs-keyword,
|
|
.hljs-selector-tag,
|
|
.hljs-literal,
|
|
.hljs-title,
|
|
.hljs-section,
|
|
.hljs-doctag,
|
|
.hljs-type,
|
|
.hljs-name,
|
|
.hljs-strong {
|
|
font-weight: 500;
|
|
}
|
|
|
|
.hljs-number,
|
|
.hljs-selector-id,
|
|
.hljs-selector-class,
|
|
.hljs-quote,
|
|
.hljs-template-tag,
|
|
.hljs-deletion {
|
|
color: #f78c6c;
|
|
}
|
|
|
|
.hljs-title.function_,
|
|
.hljs-subst,
|
|
.hljs-symbol,
|
|
.hljs-bullet,
|
|
.hljs-link {
|
|
color: #82aaff;
|
|
}
|
|
|
|
.hljs-selector-attr,
|
|
.hljs-selector-pseudo,
|
|
.hljs-variable,
|
|
.hljs-template-variable {
|
|
color: #ffcb6b;
|
|
}
|
|
|
|
.hljs-attr {
|
|
color: #89ddff;
|
|
}
|
|
|
|
.hljs-regexp,
|
|
.hljs-link {
|
|
color: #89ddff;
|
|
}
|
|
|
|
.hljs-emphasis {
|
|
font-style: italic;
|
|
}
|
|
</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, useMemo } from 'https://esm.sh/preact@10.19.3/hooks';
|
|
import htm from 'https://esm.sh/htm@3.1.1';
|
|
import { marked } from 'https://esm.sh/marked@15.0.7';
|
|
import DOMPurify from 'https://esm.sh/dompurify@3.2.4';
|
|
import hljs from 'https://esm.sh/highlight.js@11.11.1/lib/core';
|
|
import langJavascript from 'https://esm.sh/highlight.js@11.11.1/lib/languages/javascript';
|
|
import langTypescript from 'https://esm.sh/highlight.js@11.11.1/lib/languages/typescript';
|
|
import langBash from 'https://esm.sh/highlight.js@11.11.1/lib/languages/bash';
|
|
import langJson from 'https://esm.sh/highlight.js@11.11.1/lib/languages/json';
|
|
import langPython from 'https://esm.sh/highlight.js@11.11.1/lib/languages/python';
|
|
import langRust from 'https://esm.sh/highlight.js@11.11.1/lib/languages/rust';
|
|
import langCss from 'https://esm.sh/highlight.js@11.11.1/lib/languages/css';
|
|
import langXml from 'https://esm.sh/highlight.js@11.11.1/lib/languages/xml';
|
|
import langSql from 'https://esm.sh/highlight.js@11.11.1/lib/languages/sql';
|
|
import langYaml from 'https://esm.sh/highlight.js@11.11.1/lib/languages/yaml';
|
|
|
|
// Register highlight.js languages
|
|
hljs.registerLanguage('javascript', langJavascript);
|
|
hljs.registerLanguage('js', langJavascript);
|
|
hljs.registerLanguage('typescript', langTypescript);
|
|
hljs.registerLanguage('ts', langTypescript);
|
|
hljs.registerLanguage('bash', langBash);
|
|
hljs.registerLanguage('sh', langBash);
|
|
hljs.registerLanguage('shell', langBash);
|
|
hljs.registerLanguage('json', langJson);
|
|
hljs.registerLanguage('python', langPython);
|
|
hljs.registerLanguage('py', langPython);
|
|
hljs.registerLanguage('rust', langRust);
|
|
hljs.registerLanguage('css', langCss);
|
|
hljs.registerLanguage('html', langXml);
|
|
hljs.registerLanguage('xml', langXml);
|
|
hljs.registerLanguage('sql', langSql);
|
|
hljs.registerLanguage('yaml', langYaml);
|
|
hljs.registerLanguage('yml', langYaml);
|
|
|
|
// Configure marked with highlight.js
|
|
marked.setOptions({
|
|
highlight(code, lang) {
|
|
if (lang && hljs.getLanguage(lang)) {
|
|
return hljs.highlight(code, { language: lang }).value;
|
|
}
|
|
return hljs.highlightAuto(code).value;
|
|
},
|
|
breaks: true,
|
|
gfm: true,
|
|
});
|
|
|
|
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 content with syntax highlighting
|
|
function renderContent(content) {
|
|
if (!content) return '';
|
|
const rawHtml = marked.parse(content);
|
|
const safeHtml = DOMPurify.sanitize(rawHtml);
|
|
return html`<div class="md-content" dangerouslySetInnerHTML=${{ __html: safeHtml }} />`;
|
|
}
|
|
|
|
// -----------------------------
|
|
// 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: '#e0b45e',
|
|
};
|
|
case 'active':
|
|
return {
|
|
label: 'Active',
|
|
dot: 'bg-active',
|
|
badge: 'bg-active/18 text-active border-active/40',
|
|
borderColor: '#5fd0a4',
|
|
};
|
|
case 'starting':
|
|
return {
|
|
label: 'Starting',
|
|
dot: 'bg-starting',
|
|
badge: 'bg-starting/18 text-starting border-starting/40',
|
|
borderColor: '#7cb2ff',
|
|
};
|
|
case 'done':
|
|
return {
|
|
label: 'Done',
|
|
dot: 'bg-done',
|
|
badge: 'bg-done/18 text-done border-done/40',
|
|
borderColor: '#e39a8c',
|
|
};
|
|
default:
|
|
return {
|
|
label: status || 'Unknown',
|
|
dot: 'bg-dim',
|
|
badge: 'bg-selection text-dim border-selection',
|
|
borderColor: '#223454',
|
|
};
|
|
}
|
|
}
|
|
|
|
// -----------------------------
|
|
// Grouping: project-based layout
|
|
// -----------------------------
|
|
const STATUS_PRIORITY = { needs_attention: 0, active: 1, starting: 2, done: 3 };
|
|
|
|
function groupSessionsByProject(sessions) {
|
|
const groups = new Map();
|
|
|
|
for (const session of sessions) {
|
|
const key = session.project_dir || session.cwd || 'unknown';
|
|
if (!groups.has(key)) {
|
|
groups.set(key, {
|
|
projectDir: key,
|
|
projectName: session.project || key.split('/').pop() || 'Unknown',
|
|
sessions: [],
|
|
});
|
|
}
|
|
groups.get(key).sessions.push(session);
|
|
}
|
|
|
|
const result = Array.from(groups.values());
|
|
|
|
// Sort groups: most urgent status first, then most recent activity
|
|
result.sort((a, b) => {
|
|
const aWorst = Math.min(...a.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
|
|
const bWorst = Math.min(...b.sessions.map(s => STATUS_PRIORITY[s.status] ?? 99));
|
|
if (aWorst !== bWorst) return aWorst - bWorst;
|
|
const aRecent = Math.max(...a.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
|
|
const bRecent = Math.max(...b.sessions.map(s => new Date(s.last_event_at || 0).getTime()));
|
|
return bRecent - aRecent;
|
|
});
|
|
|
|
// Sort sessions within each group: urgent first, then most recent
|
|
for (const group of result) {
|
|
group.sessions.sort((a, b) => {
|
|
const aPri = STATUS_PRIORITY[a.status] ?? 99;
|
|
const bPri = STATUS_PRIORITY[b.status] ?? 99;
|
|
if (aPri !== bPri) return aPri - bPri;
|
|
return (b.last_event_at || '').localeCompare(a.last_event_at || '');
|
|
});
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
// -----------------------------
|
|
// 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>
|
|
${renderContent(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, 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>
|
|
`;
|
|
}
|
|
|
|
// -----------------------------
|
|
// SimpleInput Component (for cards without pending questions)
|
|
// -----------------------------
|
|
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>
|
|
`;
|
|
}
|
|
|
|
// -----------------------------
|
|
// 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);
|
|
const agent = session.agent === 'codex' ? 'codex' : 'claude';
|
|
const agentHeaderClass = agent === 'codex' ? 'agent-header-codex' : 'agent-header-claude';
|
|
|
|
// 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]);
|
|
|
|
// 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-[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>
|
|
</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}
|
|
status=${session.status}
|
|
onRespond=${onRespond}
|
|
/>
|
|
` : html`
|
|
<${SimpleInput}
|
|
sessionId=${session.session_id}
|
|
status=${session.status}
|
|
onRespond=${onRespond}
|
|
/>
|
|
`}
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// -----------------------------
|
|
// SessionGroup Component (project-based)
|
|
// -----------------------------
|
|
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>
|
|
`;
|
|
}
|
|
|
|
// -----------------------------
|
|
// 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);
|
|
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">
|
|
${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 projectGroups = groupSessionsByProject(sessions);
|
|
|
|
// 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`
|
|
${projectGroups.map(group => html`
|
|
<${SessionGroup}
|
|
key=${group.projectDir}
|
|
projectName=${group.projectName}
|
|
projectDir=${group.projectDir}
|
|
sessions=${group.sessions}
|
|
onCardClick=${handleCardClick}
|
|
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>
|