feat(dashboard): add markdown rendering, project grouping, and visual refresh

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
This commit is contained in:
teernisse
2026-02-25 11:51:28 -05:00
parent e994c7a0e8
commit e718c44555

View File

@@ -15,17 +15,17 @@
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',
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'],
@@ -84,9 +84,15 @@
'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>
@@ -97,13 +103,13 @@
}
:root {
--bg-flat: #060d1a;
--glass-border: rgba(137, 171, 226, 0.24);
--bg-flat: #01040b;
--glass-border: rgba(116, 154, 214, 0.22);
}
* {
scrollbar-width: thin;
scrollbar-color: #4e658d #0f1a2d;
scrollbar-color: #445f8e #0a1222;
}
body {
@@ -111,7 +117,7 @@
font-family: 'IBM Plex Sans', system-ui, sans-serif;
background: var(--bg-flat);
min-height: 100vh;
color: #d9e3f7;
color: #e0ebff;
letter-spacing: 0.01em;
}
@@ -120,7 +126,7 @@
min-height: 100vh;
}
#app > * {
#app > *:not(.fixed) {
position: relative;
z-index: 1;
}
@@ -131,16 +137,16 @@
}
::-webkit-scrollbar-track {
background: #0f1a2d;
background: #0a1222;
}
::-webkit-scrollbar-thumb {
background: #4e658d;
background: #445f8e;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #617ba8;
background: #5574aa;
}
@keyframes pulse-attention {
@@ -161,8 +167,227 @@
.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);
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>
@@ -171,8 +396,52 @@
<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 { 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);
@@ -207,28 +476,12 @@
});
}
// Render markdown-ish content with basic styling
// Render markdown content with syntax highlighting
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}`
);
});
const rawHtml = marked.parse(content);
const safeHtml = DOMPurify.sanitize(rawHtml);
return html`<div class="md-content" dangerouslySetInnerHTML=${{ __html: safeHtml }} />`;
}
// -----------------------------
@@ -254,39 +507,84 @@
label: 'Needs attention',
dot: 'bg-attention pulse-attention',
badge: 'bg-attention/18 text-attention border-attention/40',
borderColor: '#d4ad5d',
borderColor: '#e0b45e',
};
case 'active':
return {
label: 'Active',
dot: 'bg-active',
badge: 'bg-active/18 text-active border-active/40',
borderColor: '#69c39a',
borderColor: '#5fd0a4',
};
case 'starting':
return {
label: 'Starting',
dot: 'bg-starting',
badge: 'bg-starting/18 text-starting border-starting/40',
borderColor: '#79a6ff',
borderColor: '#7cb2ff',
};
case 'done':
return {
label: 'Done',
dot: 'bg-done',
badge: 'bg-done/18 text-done border-done/40',
borderColor: '#d19189',
borderColor: '#e39a8c',
};
default:
return {
label: status || 'Unknown',
dot: 'bg-dim',
badge: 'bg-selection text-dim border-selection',
borderColor: '#2a3c5c',
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
// -----------------------------
@@ -361,7 +659,7 @@
<div class="mb-1 font-mono text-micro uppercase tracking-[0.14em] text-dim">
${msg.role === 'user' ? 'Operator' : 'Agent'}
</div>
${msg.content}
${renderContent(msg.content)}
</div>
</div>
`)}
@@ -452,8 +750,10 @@
// -----------------------------
// QuestionBlock Component
// -----------------------------
function QuestionBlock({ questions, sessionId, onRespond }) {
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;
@@ -479,7 +779,7 @@
<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">
<span class="inline-flex rounded-full border px-2 py-1 font-mono text-micro uppercase tracking-[0.15em] ${meta.badge}">
${question.header}
</span>
`}
@@ -517,14 +817,17 @@
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:border-attention/70 focus:outline-none"
style="min-height: 38px; max-height: 150px;"
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 bg-attention px-3 py-2 text-sm font-medium text-[#16110a] transition-all hover:-translate-y-0.5 hover:brightness-110"
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>
@@ -541,8 +844,10 @@
// -----------------------------
// SimpleInput Component (for cards without pending questions)
// -----------------------------
function SimpleInput({ sessionId, onRespond }) {
function SimpleInput({ sessionId, status, onRespond }) {
const [text, setText] = useState('');
const [focused, setFocused] = useState(false);
const meta = getStatusMeta(status);
const handleSubmit = (e) => {
e.preventDefault();
@@ -568,14 +873,17 @@
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:border-active/70 focus:outline-none"
style="min-height: 38px; max-height: 150px;"
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 bg-active px-3 py-2 text-sm font-medium text-[#08140f] transition-all hover:-translate-y-0.5 hover:brightness-110"
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>
@@ -590,13 +898,15 @@
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, session.agent || 'claude');
onFetchConversation(session.session_id, session.project_dir, agent);
}
}, [session.session_id, session.project_dir, session.agent, conversation, onFetchConversation]);
}, [session.session_id, session.project_dir, agent, conversation, onFetchConversation]);
// Scroll chat to bottom when conversation loads or updates
useEffect(() => {
@@ -612,12 +922,12 @@
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"
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 border-selection/70 px-4 py-3">
<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">
@@ -628,8 +938,8 @@
<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 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">
@@ -666,11 +976,13 @@
<${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}
/>
`}
@@ -680,22 +992,45 @@
}
// -----------------------------
// SessionGroup Component
// SessionGroup Component (project-based)
// -----------------------------
function SessionGroup({ title, sessions, onCardClick, statusColor, conversations, onFetchConversation, onRespond, onDismiss }) {
function SessionGroup({ projectName, projectDir, sessions, onCardClick, conversations, onFetchConversation, onRespond, onDismiss }) {
if (sessions.length === 0) return null;
const textColor = statusColor.replace('bg-', 'text-');
// 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-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 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="grid grid-cols-1 gap-4 lg:grid-cols-2">
<div class="flex flex-wrap gap-4">
${sessions.map(session => html`
<${SessionCard}
key=${session.session_id}
@@ -726,6 +1061,8 @@
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);
@@ -773,6 +1110,14 @@
}
}, [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) => {
@@ -808,7 +1153,7 @@
setInputValue('');
// Refresh conversation after sending
if (onRefreshConversation) {
await onRefreshConversation(session.session_id, session.project_dir, session.agent || 'claude');
await onRefreshConversation(session.session_id, session.project_dir, agent);
}
} catch (err) {
console.error('Failed to send message:', err);
@@ -828,7 +1173,7 @@
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 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>
@@ -836,6 +1181,9 @@
<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>
@@ -855,7 +1203,7 @@
</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">
<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>
@@ -1101,10 +1449,7 @@
}, [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');
const projectGroups = groupSessionsByProject(sessions);
// Handle card click - open modal and fetch conversation if not cached
const handleCardClick = useCallback(async (session) => {
@@ -1150,46 +1495,19 @@
` : 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}
/>
${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>