refactor(dashboard): extract modular Preact component structure
Replace the monolithic single-file dashboards (dashboard.html,
dashboard-preact.html) with a proper modular directory structure:
dashboard/
index.html - Entry point, loads main.js
main.js - App bootstrap, mounts <App> to #root
styles.css - Global styles (dark theme, typography)
components/
App.js - Root component, state management, polling
Header.js - Top bar with refresh/timing info
Sidebar.js - Project tree navigation
SessionCard.js - Individual session card with status/actions
SessionGroup.js - Group sessions by project path
Modal.js - Full conversation viewer overlay
ChatMessages.js - Message list with role styling
MessageBubble.js - Individual message with markdown
QuestionBlock.js - User question input with quick options
EmptyState.js - "No sessions" placeholder
OptionButton.js - Quick response button component
SimpleInput.js - Text input with send button
lib/
preact.js - Preact + htm ESM bundle (CDN shim)
markdown.js - Lightweight markdown-to-HTML renderer
utils/
api.js - fetch wrappers for /api/* endpoints
formatting.js - Time formatting, truncation helpers
status.js - Session status logic, action availability
This structure enables:
- Browser-native ES modules (no build step required)
- Component reuse and isolation
- Easier styling and theming
- IDE support for component navigation
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
113
dashboard/lib/markdown.js
Normal file
113
dashboard/lib/markdown.js
Normal file
@@ -0,0 +1,113 @@
|
||||
// Markdown rendering with syntax highlighting
|
||||
import { h } from 'https://esm.sh/preact@10.19.3';
|
||||
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';
|
||||
import htm from 'https://esm.sh/htm@3.1.1';
|
||||
|
||||
const html = htm.bind(h);
|
||||
|
||||
// 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 using custom renderer (v15 API)
|
||||
const renderer = {
|
||||
code(token) {
|
||||
const code = token.text;
|
||||
const lang = token.lang || '';
|
||||
let highlighted;
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
highlighted = hljs.highlight(code, { language: lang }).value;
|
||||
} else {
|
||||
highlighted = hljs.highlightAuto(code).value;
|
||||
}
|
||||
return `<pre><code class="hljs language-${lang}">${highlighted}</code></pre>`;
|
||||
}
|
||||
};
|
||||
marked.use({ renderer, breaks: false, gfm: true });
|
||||
|
||||
// Render markdown content with syntax highlighting
|
||||
// All HTML is sanitized with DOMPurify before rendering to prevent XSS
|
||||
export 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 }} />`;
|
||||
}
|
||||
|
||||
// Generate a short summary for a tool call based on name + input
|
||||
function getToolSummary(name, input) {
|
||||
if (!input) return name;
|
||||
switch (name) {
|
||||
case 'Bash': return input.description || input.command?.slice(0, 60) || 'Bash';
|
||||
case 'Read': return input.file_path?.split('/').slice(-2).join('/') || 'Read';
|
||||
case 'Write': return input.file_path?.split('/').slice(-2).join('/') || 'Write';
|
||||
case 'Edit': return input.file_path?.split('/').slice(-2).join('/') || 'Edit';
|
||||
case 'Grep': return `/${input.pattern?.slice(0, 40) || ''}/ ${input.glob || ''}`.trim();
|
||||
case 'Glob': return input.pattern?.slice(0, 50) || 'Glob';
|
||||
case 'Task': return input.description || 'Task';
|
||||
default: return name;
|
||||
}
|
||||
}
|
||||
|
||||
// Render tool call pills (summary mode)
|
||||
export function renderToolCalls(toolCalls) {
|
||||
if (!toolCalls || toolCalls.length === 0) return '';
|
||||
return html`
|
||||
<div class="flex flex-wrap gap-1.5 mt-1.5">
|
||||
${toolCalls.map(tc => {
|
||||
const summary = getToolSummary(tc.name, tc.input);
|
||||
return html`
|
||||
<span class="inline-flex items-center gap-1 rounded-md border border-starting/30 bg-starting/10 px-2 py-0.5 font-mono text-label text-starting">
|
||||
<span class="font-medium">${tc.name}</span>
|
||||
${summary !== tc.name && html`<span class="text-starting/65 truncate max-w-[200px]">${summary}</span>`}
|
||||
</span>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Render thinking block (full content, open by default)
|
||||
// Content is sanitized with DOMPurify before rendering
|
||||
export function renderThinking(thinking) {
|
||||
if (!thinking) return '';
|
||||
const rawHtml = marked.parse(thinking);
|
||||
const safeHtml = DOMPurify.sanitize(rawHtml);
|
||||
return html`
|
||||
<details class="mt-2 rounded-lg border border-violet-400/25 bg-violet-500/8" open>
|
||||
<summary class="cursor-pointer select-none px-3 py-1.5 font-mono text-label uppercase tracking-[0.14em] text-violet-300/80 hover:text-violet-200">
|
||||
Thinking
|
||||
</summary>
|
||||
<div class="border-t border-violet-400/15 px-3 py-2 text-label text-dim/90 font-chat leading-relaxed">
|
||||
<div class="md-content" dangerouslySetInnerHTML=${{ __html: safeHtml }} />
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user