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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user