feat(electrobun): full UI — terminal tabs, agent pane, settings, palette

Extracted into 6 components:
- ProjectCard.svelte: header with badges, tab bar, content area
- AgentPane.svelte: collapsible tool calls, status strip, prompt input
- TerminalTabs.svelte: add/close shell tabs, active highlighting
- SettingsDrawer.svelte: theme, fonts, providers
- CommandPalette.svelte: Ctrl+K search overlay
- Terminal.svelte: xterm.js with Canvas + Image addons

Status bar: running/idle/stalled counts, attention queue, session
duration, notification bell, Ctrl+K hint. All ARIA labeled.
This commit is contained in:
Hibryda 2026-03-20 01:55:24 +01:00
parent 931bc1b94c
commit b11a856b72
11 changed files with 2001 additions and 220 deletions

View file

@ -1,10 +1,11 @@
<script lang="ts">
import Terminal from './Terminal.svelte';
import ProjectCard from './ProjectCard.svelte';
import SettingsDrawer from './SettingsDrawer.svelte';
import CommandPalette from './CommandPalette.svelte';
// ── Types ────────────────────────────────────────────────────
// ── Types ────────────────────────────────────────────────────
type AgentStatus = 'running' | 'idle' | 'stalled';
type MsgRole = 'user' | 'assistant' | 'tool-call' | 'tool-result';
type TabId = 'model' | 'docs' | 'files';
interface AgentMessage {
id: number;
@ -21,9 +22,14 @@
costUsd: number;
tokens: number;
messages: AgentMessage[];
provider?: string;
profile?: string;
model?: string;
contextPct?: number;
burnRate?: number;
}
// ── Demo data ────────────────────────────────────────────────
// ── Demo data ──────────────────────────────────────────────────
const PROJECTS: Project[] = [
{
id: 'p1',
@ -33,6 +39,11 @@
status: 'running',
costUsd: 0.034,
tokens: 18420,
provider: 'claude',
profile: 'dev',
model: 'claude-opus-4-5',
contextPct: 78,
burnRate: 0.12,
messages: [
{ id: 1, role: 'user', content: 'Add a wake scheduler for Manager agents that wakes them when review queue depth > 3.' },
{ id: 2, role: 'assistant', content: 'Reading existing wake-scheduler.svelte.ts to understand the 3-strategy pattern...' },
@ -51,6 +62,9 @@
status: 'idle',
costUsd: 0.011,
tokens: 6830,
provider: 'claude',
model: 'claude-sonnet-4-5',
contextPct: 32,
messages: [
{ id: 1, role: 'user', content: 'Why is the QRAG MCP server returning 504s on large semantic search queries?' },
{ id: 2, role: 'assistant', content: 'Checking the MCP HTTP handler timeout config and Qdrant query path...' },
@ -63,27 +77,58 @@
},
];
// ── Reactive state ───────────────────────────────────────────
let activeTab = $state<Record<string, TabId>>({ p1: 'model', p2: 'model' });
let settingsOpen = $state(false);
// ── Reactive state ─────────────────────────────────────────────
let settingsOpen = $state(false);
let paletteOpen = $state(false);
let notifCount = $state(2); // demo unread
let sessionStart = $state(Date.now());
// ── Derived status bar aggregates ───────────────────────────
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
let idleCount = $derived(PROJECTS.filter(p => p.status === 'idle').length);
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
function setTab(projectId: string, tab: TabId) {
activeTab = { ...activeTab, [projectId]: tab };
}
// Blink state — JS timer toggles class, NO CSS animation (0% CPU)
// Blink state — JS timer, no CSS animation (0% CPU overhead)
let blinkVisible = $state(true);
$effect(() => {
const id = setInterval(() => { blinkVisible = !blinkVisible; }, 500);
return () => clearInterval(id);
});
// Session duration string (updates every 10s)
let sessionDuration = $state('0m');
$effect(() => {
function update() {
const mins = Math.floor((Date.now() - sessionStart) / 60000);
sessionDuration = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`;
}
update();
const id = setInterval(update, 10000);
return () => clearInterval(id);
});
// ── Global keyboard shortcuts ──────────────────────────────────
$effect(() => {
function onKeydown(e: KeyboardEvent) {
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
e.preventDefault();
paletteOpen = !paletteOpen;
}
if ((e.ctrlKey || e.metaKey) && e.key === ',') {
e.preventDefault();
settingsOpen = !settingsOpen;
}
}
window.addEventListener('keydown', onKeydown);
return () => window.removeEventListener('keydown', onKeydown);
});
// ── Status bar aggregates ──────────────────────────────────────
let runningCount = $derived(PROJECTS.filter(p => p.status === 'running').length);
let idleCount = $derived(PROJECTS.filter(p => p.status === 'idle').length);
let stalledCount = $derived(PROJECTS.filter(p => p.status === 'stalled').length);
let totalCost = $derived(PROJECTS.reduce((s, p) => s + p.costUsd, 0));
let totalTokens = $derived(PROJECTS.reduce((s, p) => s + p.tokens, 0));
// Attention queue: projects with high context or stalled
let attentionItems = $derived(
PROJECTS.filter(p => p.status === 'stalled' || (p.contextPct ?? 0) >= 75)
);
function fmtTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
@ -93,18 +138,20 @@
}
</script>
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
<div class="app-shell">
<!-- ── Sidebar icon rail ──────────────────────────────────── -->
<!-- Sidebar icon rail -->
<aside class="sidebar" role="navigation" aria-label="Primary navigation">
<div class="sidebar-spacer"></div>
<button
class="sidebar-icon"
class:active={settingsOpen}
onclick={() => settingsOpen = !settingsOpen}
aria-label="Settings"
title="Settings"
aria-label="Settings (Ctrl+,)"
title="Settings (Ctrl+,)"
>
<!-- gear icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="3"/>
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
@ -112,111 +159,34 @@
</button>
</aside>
<!-- ── Main workspace ───────────────────────────────────────── -->
<!-- Main workspace -->
<main class="workspace">
<div class="project-grid">
{#each PROJECTS as project (project.id)}
<article
class="project-card"
style="--accent: {project.accent}"
aria-label="Project: {project.name}"
>
<!-- Project header -->
<header class="project-header">
<!-- Status dot — wgpu surface placeholder -->
<div class="status-dot-wrap" aria-label="Status: {project.status}">
<!--
Future: replace inner div with <electrobun-wgpu id="wgpu-surface-{project.id}">
for GPU-rendered animation. CSS pulse is the WebView fallback.
-->
<div
class="status-dot {project.status}"
class:blink-off={project.status === 'running' && !blinkVisible}
role="img"
aria-label="{project.status}"
></div>
</div>
<span class="project-name">{project.name}</span>
<span class="project-cwd" title={project.cwd}>{project.cwd}</span>
</header>
<!-- Tab bar -->
<div class="tab-bar" role="tablist" aria-label="{project.name} tabs">
{#each (['model', 'docs', 'files'] as TabId[]) as tab}
<button
class="tab-btn"
class:active={activeTab[project.id] === tab}
role="tab"
aria-selected={activeTab[project.id] === tab}
aria-controls="tabpanel-{project.id}-{tab}"
onclick={() => setTab(project.id, tab)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
{/each}
</div>
<!-- Tab content -->
<div class="tab-content">
<!-- Model tab: agent messages -->
<div
id="tabpanel-{project.id}-model"
class="tab-pane"
class:active={activeTab[project.id] === 'model'}
role="tabpanel"
aria-label="Model"
>
<div class="agent-messages">
{#each project.messages as msg (msg.id)}
<div class="msg">
<span class="msg-role {msg.role.split('-')[0]}">{msg.role}</span>
<div
class="msg-body"
class:tool-call={msg.role === 'tool-call'}
class:tool-result={msg.role === 'tool-result'}
>{msg.content}</div>
</div>
{/each}
</div>
<!-- Terminal section — stress test: 6 terminals to verify Canvas limit -->
{#each [1,2,3,4,5,6] as termIdx (termIdx)}
<div class="terminal-section">
<Terminal />
</div>
{/each}
</div>
<!-- Docs tab placeholder -->
<div
id="tabpanel-{project.id}-docs"
class="tab-pane"
class:active={activeTab[project.id] === 'docs'}
role="tabpanel"
aria-label="Docs"
>
<div class="placeholder-pane">No markdown files open</div>
</div>
<!-- Files tab placeholder -->
<div
id="tabpanel-{project.id}-files"
class="tab-pane"
class:active={activeTab[project.id] === 'files'}
role="tabpanel"
aria-label="Files"
>
<div class="placeholder-pane">File browser — coming soon</div>
</div>
</div>
</article>
<ProjectCard
id={project.id}
name={project.name}
cwd={project.cwd}
accent={project.accent}
status={project.status}
costUsd={project.costUsd}
tokens={project.tokens}
messages={project.messages}
provider={project.provider}
profile={project.profile}
model={project.model}
contextPct={project.contextPct}
burnRate={project.burnRate}
{blinkVisible}
/>
{/each}
</div>
</main>
</div>
<!-- ── Status bar ─────────────────────────────────────────────── -->
<!-- Status bar -->
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
<!-- Agent states -->
{#if runningCount > 0}
<span class="status-segment">
<span class="status-dot-sm green" aria-hidden="true"></span>
@ -231,7 +201,34 @@
<span>idle</span>
</span>
{/if}
{#if stalledCount > 0}
<span class="status-segment">
<span class="status-dot-sm orange" aria-hidden="true"></span>
<span class="status-value">{stalledCount}</span>
<span>stalled</span>
</span>
{/if}
<!-- Attention queue -->
{#if attentionItems.length > 0}
<span class="status-segment attn-badge" title="Needs attention: {attentionItems.map(p=>p.name).join(', ')}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true" class="attn-icon">
<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span class="status-value">{attentionItems.length}</span>
<span>attention</span>
</span>
{/if}
<span class="status-bar-spacer"></span>
<!-- Session duration -->
<span class="status-segment" title="Session duration">
<span>session</span>
<span class="status-value">{sessionDuration}</span>
</span>
<!-- Tokens + cost -->
<span class="status-segment" title="Total tokens used">
<span>tokens</span>
<span class="status-value">{fmtTokens(totalTokens)}</span>
@ -240,22 +237,180 @@
<span>cost</span>
<span class="status-value">{fmtCost(totalCost)}</span>
</span>
<!-- Notification bell -->
<button
class="notif-btn"
onclick={() => notifCount = 0}
aria-label="{notifCount > 0 ? `${notifCount} unread notifications` : 'Notifications'}"
title="Notifications"
>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
{#if notifCount > 0}
<span class="notif-badge" aria-hidden="true">{notifCount}</span>
{/if}
</button>
<!-- Palette hint -->
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
</footer>
<style>
/* Component-scoped overrides only — base styles live in app.css */
:global(body) {
overflow: hidden;
}
:global(#app) {
display: flex;
flex-direction: column;
height: 100vh;
}
:global(body) { overflow: hidden; }
:global(#app) { display: flex; flex-direction: column; height: 100vh; }
.app-shell {
flex: 1;
min-height: 0;
display: flex;
overflow: hidden;
}
/* Sidebar icon rail */
.sidebar {
width: var(--sidebar-width);
flex-shrink: 0;
background: var(--ctp-mantle);
border-right: 1px solid var(--ctp-surface0);
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0;
gap: 0.25rem;
}
.sidebar-spacer { flex: 1; }
.sidebar-icon {
width: 2rem;
height: 2rem;
border-radius: 0.375rem;
border: none;
background: transparent;
color: var(--ctp-overlay1);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
padding: 0;
}
.sidebar-icon:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.sidebar-icon.active { background: var(--ctp-surface1); color: var(--ctp-mauve); }
.sidebar-icon svg { width: 1rem; height: 1rem; }
/* Workspace */
.workspace {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.project-grid {
flex: 1;
min-height: 0;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.5rem;
padding: 0.5rem;
background: var(--ctp-crust);
}
/* Status bar */
.status-bar {
height: var(--status-bar-height);
background: var(--ctp-crust);
border-top: 1px solid var(--ctp-surface0);
display: flex;
align-items: center;
gap: 0.875rem;
padding: 0 0.625rem;
flex-shrink: 0;
font-size: 0.6875rem;
color: var(--ctp-subtext0);
}
.status-segment {
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.status-dot-sm {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
flex-shrink: 0;
}
.status-dot-sm.green { background: var(--ctp-green); }
.status-dot-sm.gray { background: var(--ctp-overlay0); }
.status-dot-sm.orange { background: var(--ctp-peach); }
.status-value { color: var(--ctp-text); font-weight: 500; }
.status-bar-spacer { flex: 1; }
/* Attention badge */
.attn-badge { color: var(--ctp-yellow); }
.attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); }
/* Notification bell */
.notif-btn {
position: relative;
width: 1.5rem;
height: 1.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
border-radius: 0.25rem;
transition: color 0.12s;
padding: 0;
flex-shrink: 0;
}
.notif-btn:hover { color: var(--ctp-text); }
.notif-btn svg { width: 0.875rem; height: 0.875rem; }
.notif-badge {
position: absolute;
top: 0.125rem;
right: 0.125rem;
min-width: 0.875rem;
height: 0.875rem;
background: var(--ctp-red);
color: var(--ctp-base);
border-radius: 0.4375rem;
font-size: 0.5rem;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
padding: 0 0.2rem;
line-height: 1;
}
/* Palette shortcut hint */
.palette-hint {
padding: 0.1rem 0.3rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
font-size: 0.6rem;
color: var(--ctp-overlay0);
font-family: var(--ui-font-family);
cursor: pointer;
transition: color 0.1s;
}
.palette-hint:hover { color: var(--ctp-subtext0); }
</style>