feat(electrobun): file management — CodeMirror editor, PDF viewer, CSV table, real file I/O

- CodeEditor: CodeMirror 6 with Catppuccin theme, 15+ languages, Ctrl+S save,
  dirty tracking, save-on-blur
- PdfViewer: pdfjs-dist canvas rendering, zoom 0.5-3x, HiDPI, lazy page load
- CsvTable: RFC 4180 parser, delimiter auto-detect, sortable columns, sticky header
- FileBrowser: real filesystem via files.list/read/write RPC, lazy dir loading,
  file type routing (code→editor, pdf→viewer, csv→table, images→display)
- 10MB size gate, binary detection, base64 encoding for non-text files
This commit is contained in:
Hibryda 2026-03-22 01:36:02 +01:00
parent 29a3370e79
commit 252fca70df
22 changed files with 8116 additions and 227 deletions

View file

@ -5,9 +5,12 @@
import CommandPalette from './CommandPalette.svelte';
import ToastContainer from './ToastContainer.svelte';
import NotifDrawer, { type Notification } from './NotifDrawer.svelte';
import StatusBar from './StatusBar.svelte';
import SearchOverlay from './SearchOverlay.svelte';
import { themeStore } from './theme-store.svelte.ts';
import { fontStore } from './font-store.svelte.ts';
import { keybindingStore } from './keybinding-store.svelte.ts';
import { trackProject } from './health-store.svelte.ts';
import { appRpc } from './rpc.ts';
// ── Types ─────────────────────────────────────────────────────
@ -137,6 +140,7 @@
let settingsOpen = $state(false);
let paletteOpen = $state(false);
let drawerOpen = $state(false);
let searchOpen = $state(false);
let sessionStart = $state(Date.now());
let notifications = $state<Notification[]>([
@ -236,15 +240,8 @@
}
// ── 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));
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); }
function fmtCost(n: number): string { return `$${n.toFixed(3)}`; }
// ── DEBUG: Visual click diagnostics overlay (gated behind DEBUG env) ────
const DEBUG_ENABLED = typeof window !== 'undefined' && new URLSearchParams(window.location.search).has('debug');
@ -299,13 +296,29 @@
keybindingStore.on('group4', () => setActiveGroup(groups[3]?.id));
keybindingStore.on('minimize', () => handleMinimize());
// Ctrl+Shift+F for search overlay
function handleSearchShortcut(e: KeyboardEvent) {
if (e.ctrlKey && e.shiftKey && e.key === 'F') {
e.preventDefault();
searchOpen = !searchOpen;
}
}
document.addEventListener('keydown', handleSearchShortcut);
// Track projects for health monitoring
for (const p of PROJECTS) trackProject(p.id);
const cleanup = keybindingStore.installListener();
return cleanup;
return () => {
cleanup();
document.removeEventListener('keydown', handleSearchShortcut);
};
});
</script>
<SettingsDrawer open={settingsOpen} onClose={() => settingsOpen = false} />
<CommandPalette open={paletteOpen} onClose={() => paletteOpen = false} />
<SearchOverlay open={searchOpen} onClose={() => searchOpen = false} />
<ToastContainer />
<NotifDrawer
open={drawerOpen}
@ -423,59 +436,14 @@
</aside>
</div>
<!-- Status bar -->
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
{#if runningCount > 0}
<span class="status-segment">
<span class="status-dot-sm green" aria-hidden="true"></span>
<span class="status-value">{runningCount}</span>
<span>running</span>
</span>
{/if}
{#if idleCount > 0}
<span class="status-segment">
<span class="status-dot-sm gray" aria-hidden="true"></span>
<span class="status-value">{idleCount}</span>
<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}
{#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>
<span class="status-segment" title="Active group">
<span class="status-value">{activeGroup?.name}</span>
</span>
<span class="status-segment" title="Session duration">
<span>session</span>
<span class="status-value">{sessionDuration}</span>
</span>
<span class="status-segment" title="Total tokens used">
<span>tokens</span>
<span class="status-value">{fmtTokens(totalTokens)}</span>
</span>
<span class="status-segment" title="Total session cost">
<span>cost</span>
<span class="status-value">{fmtCost(totalCost)}</span>
</span>
<kbd class="palette-hint" title="Open command palette">Ctrl+K</kbd>
</footer>
<!-- Status bar (health-backed) -->
<StatusBar
projectCount={filteredProjects.length}
{totalTokens}
totalCost={totalCost}
{sessionDuration}
groupName={activeGroup?.name ?? ''}
/>
{#if DEBUG_ENABLED && debugLog.length > 0}
<!-- DEBUG: visible click log (enable with ?debug URL param) -->
@ -728,55 +696,5 @@
line-height: 1;
}
/* ── 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; }
.attn-badge { color: var(--ctp-yellow); }
.attn-icon { width: 0.75rem; height: 0.75rem; stroke: var(--ctp-yellow); }
.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); }
/* Status bar styles are in StatusBar.svelte */
</style>

View file

@ -0,0 +1,248 @@
<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte';
import { EditorView, keymap, lineNumbers, highlightActiveLine, highlightActiveLineGutter, drawSelection, dropCursor } from '@codemirror/view';
import { EditorState } from '@codemirror/state';
import { defaultKeymap, history, historyKeymap, indentWithTab } from '@codemirror/commands';
import { syntaxHighlighting, defaultHighlightStyle, indentOnInput, bracketMatching, foldGutter, foldKeymap } from '@codemirror/language';
import { closeBrackets, closeBracketsKeymap, autocompletion, completionKeymap } from '@codemirror/autocomplete';
import { highlightSelectionMatches, searchKeymap } from '@codemirror/search';
interface Props {
content: string;
lang: string;
readonly?: boolean;
onchange?: (content: string) => void;
onsave?: () => void;
onblur?: () => void;
}
let { content, lang, readonly = false, onchange, onsave, onblur }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let view: EditorView | undefined = $state();
/** Map file extension hint to CodeMirror language extension (dynamic import). */
async function getLangExtension(l: string) {
switch (l) {
case 'javascript':
case 'jsx': {
const { javascript } = await import('@codemirror/lang-javascript');
return javascript({ jsx: true });
}
case 'typescript':
case 'tsx': {
const { javascript } = await import('@codemirror/lang-javascript');
return javascript({ jsx: true, typescript: true });
}
case 'html':
case 'svelte': {
const { html } = await import('@codemirror/lang-html');
return html();
}
case 'css':
case 'scss':
case 'less': {
const { css } = await import('@codemirror/lang-css');
return css();
}
case 'json': {
const { json } = await import('@codemirror/lang-json');
return json();
}
case 'markdown':
case 'md': {
const { markdown } = await import('@codemirror/lang-markdown');
return markdown();
}
case 'python':
case 'py': {
const { python } = await import('@codemirror/lang-python');
return python();
}
case 'rust':
case 'rs': {
const { rust } = await import('@codemirror/lang-rust');
return rust();
}
default:
return null;
}
}
/** Catppuccin Mocha theme via CSS custom properties. */
const catppuccinTheme = EditorView.theme({
'&': {
backgroundColor: 'var(--ctp-base)',
color: 'var(--ctp-text)',
fontFamily: 'var(--term-font-family, "JetBrains Mono", monospace)',
fontSize: '0.775rem',
},
'.cm-content': {
caretColor: 'var(--ctp-rosewater)',
lineHeight: '1.55',
},
'.cm-cursor, .cm-dropCursor': {
borderLeftColor: 'var(--ctp-rosewater)',
},
'&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection': {
backgroundColor: 'color-mix(in srgb, var(--ctp-blue) 25%, transparent)',
},
'.cm-panels': {
backgroundColor: 'var(--ctp-mantle)',
color: 'var(--ctp-text)',
},
'.cm-panels.cm-panels-top': {
borderBottom: '1px solid var(--ctp-surface0)',
},
'.cm-searchMatch': {
backgroundColor: 'color-mix(in srgb, var(--ctp-yellow) 25%, transparent)',
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: 'color-mix(in srgb, var(--ctp-peach) 30%, transparent)',
},
'.cm-activeLine': {
backgroundColor: 'color-mix(in srgb, var(--ctp-surface0) 40%, transparent)',
},
'.cm-selectionMatch': {
backgroundColor: 'color-mix(in srgb, var(--ctp-teal) 15%, transparent)',
},
'.cm-matchingBracket, .cm-nonmatchingBracket': {
backgroundColor: 'color-mix(in srgb, var(--ctp-blue) 20%, transparent)',
outline: '1px solid color-mix(in srgb, var(--ctp-blue) 40%, transparent)',
},
'.cm-gutters': {
backgroundColor: 'var(--ctp-mantle)',
color: 'var(--ctp-overlay0)',
border: 'none',
borderRight: '1px solid var(--ctp-surface0)',
},
'.cm-activeLineGutter': {
backgroundColor: 'color-mix(in srgb, var(--ctp-surface0) 40%, transparent)',
color: 'var(--ctp-text)',
},
'.cm-foldPlaceholder': {
backgroundColor: 'var(--ctp-surface0)',
border: 'none',
color: 'var(--ctp-overlay1)',
},
'.cm-tooltip': {
backgroundColor: 'var(--ctp-surface0)',
color: 'var(--ctp-text)',
border: '1px solid var(--ctp-surface1)',
borderRadius: '0.25rem',
},
}, { dark: true });
function buildExtensions(langExt: ReturnType<Awaited<ReturnType<typeof getLangExtension>>> | null) {
const exts = [
lineNumbers(),
highlightActiveLineGutter(),
history(),
foldGutter(),
drawSelection(),
dropCursor(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
bracketMatching(),
closeBrackets(),
autocompletion(),
highlightActiveLine(),
highlightSelectionMatches(),
keymap.of([
...closeBracketsKeymap,
...defaultKeymap,
...searchKeymap,
...historyKeymap,
...foldKeymap,
...completionKeymap,
indentWithTab,
{ key: 'Mod-s', run: () => { onsave?.(); return true; } },
]),
catppuccinTheme,
EditorView.updateListener.of(update => {
if (update.docChanged) {
onchange?.(update.state.doc.toString());
}
}),
EditorView.domEventHandlers({
blur: () => { onblur?.(); },
}),
EditorView.lineWrapping,
];
if (readonly) exts.push(EditorState.readOnly.of(true));
if (langExt) exts.push(langExt);
return exts;
}
async function createEditor() {
if (!container) return;
const langExt = await getLangExtension(lang);
view = new EditorView({
state: EditorState.create({ doc: content, extensions: buildExtensions(langExt) }),
parent: container,
});
}
onMount(() => { createEditor(); });
onDestroy(() => { view?.destroy(); });
// When content prop changes externally (different file loaded), replace editor content
let lastContent = $state(untrack(() => content));
$effect(() => {
const c = content;
if (view && c !== lastContent) {
const currentDoc = view.state.doc.toString();
if (c !== currentDoc) {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: c },
});
}
lastContent = c;
}
});
// When lang changes, recreate editor
let lastLang = $state(untrack(() => lang));
$effect(() => {
const l = lang;
if (l !== lastLang && view) {
lastLang = l;
const currentContent = view.state.doc.toString();
view.destroy();
queueMicrotask(async () => {
const langExt = await getLangExtension(l);
view = new EditorView({
state: EditorState.create({ doc: currentContent, extensions: buildExtensions(langExt) }),
parent: container!,
});
});
}
});
export function getContent(): string {
return view?.state.doc.toString() ?? content;
}
</script>
<div class="code-editor" bind:this={container}></div>
<style>
.code-editor {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.code-editor :global(.cm-editor) {
flex: 1;
overflow: hidden;
}
.code-editor :global(.cm-scroller) {
overflow: auto;
}
</style>

View file

@ -0,0 +1,521 @@
<script lang="ts">
import { appRpc } from './rpc.ts';
// ── Types ────────────────────────────────────────────────────────────
interface Channel {
id: string;
name: string;
groupId: string;
createdBy: string;
memberCount: number;
createdAt: string;
}
interface ChannelMessage {
id: string;
channelId: string;
fromAgent: string;
content: string;
createdAt: string;
senderName: string;
senderRole: string;
}
interface Agent {
id: string;
name: string;
role: string;
groupId: string;
tier: number;
status: string;
unreadCount: number;
}
interface DM {
id: string;
fromAgent: string;
toAgent: string;
content: string;
read: boolean;
createdAt: string;
senderName: string | null;
senderRole: string | null;
}
interface Props {
groupId: string;
/** Agent ID for this project's perspective (defaults to 'admin'). */
agentId?: string;
}
let { groupId, agentId = 'admin' }: Props = $props();
// ── State ────────────────────────────────────────────────────────────
type TabMode = 'channels' | 'dms';
let mode = $state<TabMode>('channels');
let channels = $state<Channel[]>([]);
let agents = $state<Agent[]>([]);
let activeChannelId = $state<string | null>(null);
let activeDmAgentId = $state<string | null>(null);
let channelMessages = $state<ChannelMessage[]>([]);
let dmMessages = $state<DM[]>([]);
let input = $state('');
let loading = $state(false);
// ── Data fetching ────────────────────────────────────────────────────
async function loadChannels() {
try {
const res = await appRpc.request['btmsg.listChannels']({ groupId });
channels = res.channels;
if (channels.length > 0 && !activeChannelId) {
activeChannelId = channels[0].id;
await loadChannelMessages(channels[0].id);
}
} catch (err) {
console.error('[CommsTab] loadChannels:', err);
}
}
async function loadAgents() {
try {
const res = await appRpc.request['btmsg.getAgents']({ groupId });
agents = res.agents.filter((a: Agent) => a.id !== agentId);
} catch (err) {
console.error('[CommsTab] loadAgents:', err);
}
}
async function loadChannelMessages(channelId: string) {
try {
loading = true;
const res = await appRpc.request['btmsg.getChannelMessages']({
channelId, limit: 100,
});
channelMessages = res.messages;
} catch (err) {
console.error('[CommsTab] loadChannelMessages:', err);
} finally {
loading = false;
}
}
async function loadDmMessages(otherId: string) {
try {
loading = true;
const res = await appRpc.request['btmsg.listMessages']({
agentId, otherId, limit: 50,
});
dmMessages = res.messages;
} catch (err) {
console.error('[CommsTab] loadDmMessages:', err);
} finally {
loading = false;
}
}
function selectChannel(id: string) {
activeChannelId = id;
loadChannelMessages(id);
}
function selectDm(otherId: string) {
activeDmAgentId = otherId;
loadDmMessages(otherId);
}
async function sendMessage() {
const text = input.trim();
if (!text) return;
input = '';
try {
if (mode === 'channels' && activeChannelId) {
await appRpc.request['btmsg.sendChannelMessage']({
channelId: activeChannelId, fromAgent: agentId, content: text,
});
await loadChannelMessages(activeChannelId);
} else if (mode === 'dms' && activeDmAgentId) {
await appRpc.request['btmsg.sendMessage']({
fromAgent: agentId, toAgent: activeDmAgentId, content: text,
});
await loadDmMessages(activeDmAgentId);
}
} catch (err) {
console.error('[CommsTab] sendMessage:', err);
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}
// ── Init + polling ───────────────────────────────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
$effect(() => {
loadChannels();
loadAgents();
pollTimer = setInterval(() => {
if (mode === 'channels' && activeChannelId) {
loadChannelMessages(activeChannelId);
} else if (mode === 'dms' && activeDmAgentId) {
loadDmMessages(activeDmAgentId);
}
}, 5000);
return () => { if (pollTimer) clearInterval(pollTimer); };
});
</script>
<div class="comms-tab">
<!-- Mode toggle -->
<div class="comms-mode-bar">
<button
class="mode-btn"
class:active={mode === 'channels'}
onclick={() => { mode = 'channels'; }}
>Channels</button>
<button
class="mode-btn"
class:active={mode === 'dms'}
onclick={() => { mode = 'dms'; loadAgents(); }}
>DMs</button>
</div>
<div class="comms-body">
<!-- Sidebar: channel list or agent list -->
<div class="comms-sidebar">
{#if mode === 'channels'}
{#each channels as ch}
<button
class="sidebar-item"
class:active={activeChannelId === ch.id}
onclick={() => selectChannel(ch.id)}
>
<span class="ch-hash">#</span>
<span class="ch-name">{ch.name}</span>
</button>
{/each}
{#if channels.length === 0}
<div class="sidebar-empty">No channels</div>
{/if}
{:else}
{#each agents as ag}
<button
class="sidebar-item"
class:active={activeDmAgentId === ag.id}
onclick={() => selectDm(ag.id)}
>
<span class="agent-dot {ag.status}"></span>
<span class="agent-name">{ag.name}</span>
<span class="agent-role">{ag.role}</span>
{#if ag.unreadCount > 0}
<span class="unread-badge">{ag.unreadCount}</span>
{/if}
</button>
{/each}
{#if agents.length === 0}
<div class="sidebar-empty">No agents</div>
{/if}
{/if}
</div>
<!-- Message area -->
<div class="comms-messages">
{#if loading}
<div class="msg-loading">Loading...</div>
{:else if mode === 'channels'}
<div class="msg-list">
{#each channelMessages as msg}
<div class="msg-row">
<span class="msg-sender">{msg.senderName}</span>
<span class="msg-role">{msg.senderRole}</span>
<span class="msg-time">{msg.createdAt.slice(11, 16)}</span>
<div class="msg-content">{msg.content}</div>
</div>
{/each}
{#if channelMessages.length === 0}
<div class="msg-empty">No messages in this channel</div>
{/if}
</div>
{:else}
<div class="msg-list">
{#each dmMessages as msg}
<div class="msg-row" class:msg-mine={msg.fromAgent === agentId}>
<span class="msg-sender">{msg.senderName ?? msg.fromAgent}</span>
<span class="msg-time">{msg.createdAt.slice(11, 16)}</span>
<div class="msg-content">{msg.content}</div>
</div>
{/each}
{#if dmMessages.length === 0 && activeDmAgentId}
<div class="msg-empty">No messages yet</div>
{/if}
{#if !activeDmAgentId}
<div class="msg-empty">Select an agent to message</div>
{/if}
</div>
{/if}
<!-- Input bar -->
<div class="msg-input-bar">
<input
class="msg-input"
type="text"
placeholder={mode === 'channels' ? 'Message channel...' : 'Send DM...'}
bind:value={input}
onkeydown={handleKeydown}
/>
<button class="msg-send-btn" onclick={sendMessage} disabled={!input.trim()}>
Send
</button>
</div>
</div>
</div>
</div>
<style>
.comms-tab {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.comms-mode-bar {
display: flex;
gap: 0.125rem;
padding: 0.25rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.mode-btn {
flex: 1;
padding: 0.25rem 0.5rem;
background: transparent;
border: 1px solid transparent;
border-radius: 0.25rem;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.75rem;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.mode-btn:hover { color: var(--ctp-text); }
.mode-btn.active {
background: var(--ctp-surface0);
color: var(--ctp-text);
border-color: var(--ctp-surface1);
}
.comms-body {
display: flex;
flex: 1;
min-height: 0;
overflow: hidden;
}
.comms-sidebar {
width: 10rem;
flex-shrink: 0;
border-right: 1px solid var(--ctp-surface0);
overflow-y: auto;
padding: 0.25rem 0;
background: var(--ctp-mantle);
}
.sidebar-item {
display: flex;
align-items: center;
gap: 0.375rem;
width: 100%;
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.75rem;
cursor: pointer;
text-align: left;
transition: background 0.1s;
}
.sidebar-item:hover { background: var(--ctp-surface0); color: var(--ctp-text); }
.sidebar-item.active { background: var(--ctp-surface0); color: var(--ctp-text); }
.ch-hash {
color: var(--ctp-overlay0);
font-weight: 700;
flex-shrink: 0;
}
.ch-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.agent-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--ctp-overlay0);
flex-shrink: 0;
}
.agent-dot.active { background: var(--ctp-green); }
.agent-dot.running { background: var(--ctp-green); }
.agent-dot.stopped { background: var(--ctp-overlay0); }
.agent-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.agent-role {
font-size: 0.625rem;
color: var(--ctp-overlay0);
flex-shrink: 0;
}
.unread-badge {
background: var(--ctp-red);
color: var(--ctp-base);
font-size: 0.5625rem;
font-weight: 700;
padding: 0 0.25rem;
border-radius: 0.5rem;
min-width: 0.875rem;
text-align: center;
flex-shrink: 0;
}
.sidebar-empty {
padding: 1rem 0.5rem;
color: var(--ctp-overlay0);
font-size: 0.75rem;
font-style: italic;
text-align: center;
}
.comms-messages {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
overflow: hidden;
}
.msg-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.msg-row {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
align-items: baseline;
}
.msg-mine { flex-direction: row-reverse; }
.msg-sender {
font-size: 0.6875rem;
font-weight: 600;
color: var(--ctp-blue);
flex-shrink: 0;
}
.msg-role {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
flex-shrink: 0;
}
.msg-time {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
flex-shrink: 0;
margin-left: auto;
}
.msg-content {
width: 100%;
font-size: 0.8125rem;
color: var(--ctp-text);
line-height: 1.4;
word-break: break-word;
}
.msg-empty, .msg-loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
font-style: italic;
}
.msg-input-bar {
display: flex;
gap: 0.25rem;
padding: 0.375rem;
border-top: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
flex-shrink: 0;
}
.msg-input {
flex: 1;
height: 1.75rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.8125rem;
padding: 0 0.5rem;
outline: none;
}
.msg-input:focus { border-color: var(--ctp-mauve); }
.msg-send-btn {
padding: 0 0.625rem;
height: 1.75rem;
background: color-mix(in srgb, var(--ctp-blue) 20%, transparent);
border: 1px solid var(--ctp-blue);
border-radius: 0.25rem;
color: var(--ctp-blue);
font-family: var(--ui-font-family);
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: background 0.1s;
flex-shrink: 0;
}
.msg-send-btn:hover:not(:disabled) {
background: color-mix(in srgb, var(--ctp-blue) 35%, transparent);
}
.msg-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
</style>

View file

@ -0,0 +1,243 @@
<script lang="ts">
interface Props {
content: string;
filename: string;
}
let { content, filename }: Props = $props();
/** RFC 4180 CSV parser with quoted field support. */
function parseCsv(text: string, delimiter: string): string[][] {
const rows: string[][] = [];
let i = 0;
const len = text.length;
while (i < len) {
const row: string[] = [];
while (i < len) {
let field = '';
if (text[i] === '"') {
i++; // skip opening quote
while (i < len) {
if (text[i] === '"') {
if (i + 1 < len && text[i + 1] === '"') {
field += '"';
i += 2;
} else {
i++; // skip closing quote
break;
}
} else {
field += text[i];
i++;
}
}
} else {
while (i < len && text[i] !== delimiter && text[i] !== '\n' && text[i] !== '\r') {
field += text[i];
i++;
}
}
row.push(field);
if (i < len && text[i] === delimiter) {
i++;
} else {
if (i < len && text[i] === '\r') i++;
if (i < len && text[i] === '\n') i++;
break;
}
}
if (row.length > 0 && !(row.length === 1 && row[0] === '')) {
rows.push(row);
}
}
return rows;
}
/** Detect delimiter from first line: comma, semicolon, tab, or pipe. */
function detectDelimiter(text: string): string {
const firstLine = text.split('\n')[0] ?? '';
const counts: [string, number][] = [
[',', (firstLine.match(/,/g) ?? []).length],
[';', (firstLine.match(/;/g) ?? []).length],
['\t', (firstLine.match(/\t/g) ?? []).length],
['|', (firstLine.match(/\|/g) ?? []).length],
];
counts.sort((a, b) => b[1] - a[1]);
return counts[0][1] > 0 ? counts[0][0] : ',';
}
let delimiter = $derived(detectDelimiter(content));
let parsed = $derived(parseCsv(content, delimiter));
let headers = $derived(parsed[0] ?? []);
let dataRows = $derived(parsed.slice(1));
let totalRows = $derived(dataRows.length);
let colCount = $derived(Math.max(...parsed.map(r => r.length), 0));
// Sort state
let sortCol = $state<number | null>(null);
let sortAsc = $state(true);
let sortedRows = $derived.by(() => {
if (sortCol === null) return dataRows;
const col = sortCol;
const asc = sortAsc;
return [...dataRows].sort((a, b) => {
const va = a[col] ?? '';
const vb = b[col] ?? '';
const na = Number(va);
const nb = Number(vb);
if (!isNaN(na) && !isNaN(nb)) {
return asc ? na - nb : nb - na;
}
return asc ? va.localeCompare(vb) : vb.localeCompare(va);
});
});
function toggleSort(col: number) {
if (sortCol === col) {
sortAsc = !sortAsc;
} else {
sortCol = col;
sortAsc = true;
}
}
function sortIndicator(col: number): string {
if (sortCol !== col) return '';
return sortAsc ? ' ▲' : ' ▼';
}
</script>
<div class="csv-table-wrapper">
<div class="csv-toolbar">
<span class="csv-info">
{totalRows} row{totalRows !== 1 ? 's' : ''} x {colCount} col{colCount !== 1 ? 's' : ''}
</span>
<span class="csv-filename">{filename}</span>
</div>
<div class="csv-scroll">
<table class="csv-table">
<thead>
<tr>
<th class="row-num">#</th>
{#each headers as header, i}
<th onclick={() => toggleSort(i)} class="sortable">
{header}{sortIndicator(i)}
</th>
{/each}
</tr>
</thead>
<tbody>
{#each sortedRows as row, rowIdx (rowIdx)}
<tr>
<td class="row-num">{rowIdx + 1}</td>
{#each { length: colCount } as _, colIdx}
<td>{row[colIdx] ?? ''}</td>
{/each}
</tr>
{/each}
</tbody>
</table>
</div>
</div>
<style>
.csv-table-wrapper {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--ctp-base);
}
.csv-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.csv-info {
font-size: 0.7rem;
color: var(--ctp-overlay1);
font-variant-numeric: tabular-nums;
}
.csv-filename {
font-size: 0.65rem;
color: var(--ctp-overlay0);
font-family: var(--term-font-family, monospace);
}
.csv-scroll {
flex: 1;
overflow: auto;
}
.csv-table {
width: 100%;
border-collapse: collapse;
font-size: 0.725rem;
font-family: var(--term-font-family, monospace);
white-space: nowrap;
}
.csv-table thead {
position: sticky;
top: 0;
z-index: 1;
}
.csv-table th {
background: var(--ctp-mantle);
color: var(--ctp-subtext1);
font-weight: 600;
text-align: left;
padding: 0.3125rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface1);
user-select: none;
}
.csv-table th.sortable {
cursor: pointer;
transition: background 0.12s;
}
.csv-table th.sortable:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.csv-table td {
padding: 0.25rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
color: var(--ctp-text);
max-width: 20rem;
overflow: hidden;
text-overflow: ellipsis;
}
.csv-table tbody tr:hover td {
background: color-mix(in srgb, var(--ctp-surface0) 50%, transparent);
}
.row-num {
color: var(--ctp-overlay0);
font-size: 0.625rem;
text-align: right;
width: 2.5rem;
min-width: 2.5rem;
padding-right: 0.625rem;
border-right: 1px solid var(--ctp-surface0);
}
thead .row-num {
border-bottom: 1px solid var(--ctp-surface1);
}
</style>

View file

@ -1,133 +1,352 @@
<script lang="ts">
interface FileNode {
import { appRpc } from './rpc.ts';
import CodeEditor from './CodeEditor.svelte';
import PdfViewer from './PdfViewer.svelte';
import CsvTable from './CsvTable.svelte';
interface Props {
cwd: string;
}
let { cwd }: Props = $props();
interface DirEntry {
name: string;
type: 'file' | 'dir';
children?: FileNode[];
size: number;
}
// Demo directory tree
const TREE: FileNode[] = [
{
name: 'src', type: 'dir', children: [
{
name: 'lib', type: 'dir', children: [
{
name: 'stores', type: 'dir', children: [
{ name: 'workspace.svelte.ts', type: 'file' },
{ name: 'agents.svelte.ts', type: 'file' },
{ name: 'health.svelte.ts', type: 'file' },
],
},
{
name: 'adapters', type: 'dir', children: [
{ name: 'claude-messages.ts', type: 'file' },
{ name: 'agent-bridge.ts', type: 'file' },
],
},
{ name: 'agent-dispatcher.ts', type: 'file' },
],
},
{ name: 'App.svelte', type: 'file' },
],
},
{
name: 'src-tauri', type: 'dir', children: [
{
name: 'src', type: 'dir', children: [
{ name: 'lib.rs', type: 'file' },
{ name: 'btmsg.rs', type: 'file' },
],
},
],
},
{ name: 'Cargo.toml', type: 'file' },
{ name: 'package.json', type: 'file' },
{ name: 'vite.config.ts', type: 'file' },
];
let openDirs = $state<Set<string>>(new Set(['src', 'src/lib', 'src/lib/stores']));
// Tree state: track loaded children and open/closed per path
let childrenCache = $state<Map<string, DirEntry[]>>(new Map());
let openDirs = $state<Set<string>>(new Set());
let loadingDirs = $state<Set<string>>(new Set());
let selectedFile = $state<string | null>(null);
function toggleDir(path: string) {
const s = new Set(openDirs);
if (s.has(path)) s.delete(path);
else s.add(path);
openDirs = s;
// File viewer state
let fileContent = $state<string | null>(null);
let fileEncoding = $state<'utf8' | 'base64'>('utf8');
let fileSize = $state(0);
let fileError = $state<string | null>(null);
let fileLoading = $state(false);
let isDirty = $state(false);
let editorContent = $state('');
// Extension-based type detection
const CODE_EXTS = new Set([
'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs',
'py', 'rs', 'go', 'css', 'scss', 'less',
'html', 'svelte', 'vue',
'json', 'md', 'yaml', 'yml', 'toml', 'sh', 'bash',
'xml', 'sql', 'c', 'cpp', 'h', 'java', 'php',
]);
const IMAGE_EXTS = new Set(['png', 'jpg', 'jpeg', 'gif', 'svg', 'webp', 'ico', 'bmp']);
const PDF_EXTS = new Set(['pdf']);
const CSV_EXTS = new Set(['csv', 'tsv']);
function getExt(name: string): string {
const dot = name.lastIndexOf('.');
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : '';
}
function selectFile(path: string) {
selectedFile = path;
type FileType = 'code' | 'image' | 'pdf' | 'csv' | 'text';
function detectFileType(name: string): FileType {
const ext = getExt(name);
if (PDF_EXTS.has(ext)) return 'pdf';
if (CSV_EXTS.has(ext)) return 'csv';
if (IMAGE_EXTS.has(ext)) return 'image';
if (CODE_EXTS.has(ext)) return 'code';
return 'text';
}
/** Map extension to CodeMirror language name. */
function extToLang(name: string): string {
const ext = getExt(name);
const map: Record<string, string> = {
ts: 'typescript', tsx: 'tsx', js: 'javascript', jsx: 'jsx',
mjs: 'javascript', cjs: 'javascript',
py: 'python', rs: 'rust', go: 'go',
css: 'css', scss: 'css', less: 'css',
html: 'html', svelte: 'html', vue: 'html',
json: 'json', md: 'markdown', yaml: 'yaml', yml: 'yaml',
toml: 'toml', sh: 'bash', bash: 'bash',
xml: 'xml', sql: 'sql', c: 'c', cpp: 'cpp', h: 'c',
java: 'java', php: 'php',
};
return map[ext] ?? 'text';
}
let selectedType = $derived<FileType>(selectedFile ? detectFileType(selectedFile) : 'text');
let selectedName = $derived(selectedFile ? selectedFile.split('/').pop() ?? '' : '');
/** Load directory entries via RPC. */
async function loadDir(dirPath: string) {
if (childrenCache.has(dirPath)) return;
const key = dirPath;
loadingDirs = new Set([...loadingDirs, key]);
try {
const result = await appRpc.request["files.list"]({ path: dirPath });
if (result.error) {
console.error(`[files.list] ${dirPath}: ${result.error}`);
return;
}
const next = new Map(childrenCache);
next.set(dirPath, result.entries);
childrenCache = next;
} catch (err) {
console.error('[files.list]', err);
} finally {
const s = new Set(loadingDirs);
s.delete(key);
loadingDirs = s;
}
}
/** Toggle a directory open/closed. Lazy-loads on first open. */
async function toggleDir(dirPath: string) {
const s = new Set(openDirs);
if (s.has(dirPath)) {
s.delete(dirPath);
openDirs = s;
} else {
s.add(dirPath);
openDirs = s;
await loadDir(dirPath);
}
}
/** Select and load a file. */
async function selectFile(filePath: string) {
if (selectedFile === filePath) return;
selectedFile = filePath;
isDirty = false;
fileContent = null;
fileError = null;
fileLoading = true;
const type = detectFileType(filePath);
// PDF uses its own loader via PdfViewer
if (type === 'pdf') {
fileLoading = false;
return;
}
// Images: read as base64 for display
if (type === 'image') {
try {
const result = await appRpc.request["files.read"]({ path: filePath });
if (result.error) {
fileError = result.error;
return;
}
fileContent = result.content ?? '';
fileEncoding = result.encoding;
fileSize = result.size;
} catch (err) {
fileError = err instanceof Error ? err.message : String(err);
} finally {
fileLoading = false;
}
return;
}
try {
const result = await appRpc.request["files.read"]({ path: filePath });
if (result.error) {
fileError = result.error;
return;
}
fileContent = result.content ?? '';
fileEncoding = result.encoding;
fileSize = result.size;
editorContent = fileContent;
} catch (err) {
fileError = err instanceof Error ? err.message : String(err);
} finally {
fileLoading = false;
}
}
/** Save current file. */
async function saveFile() {
if (!selectedFile || !isDirty) return;
try {
const result = await appRpc.request["files.write"]({
path: selectedFile,
content: editorContent,
});
if (result.ok) {
isDirty = false;
fileContent = editorContent;
} else if (result.error) {
console.error('[files.write]', result.error);
}
} catch (err) {
console.error('[files.write]', err);
}
}
function onEditorChange(newContent: string) {
editorContent = newContent;
isDirty = newContent !== fileContent;
}
function fileIcon(name: string): string {
if (name.endsWith('.ts') || name.endsWith('.svelte.ts')) return '⟨/⟩';
if (name.endsWith('.svelte')) return '◈';
if (name.endsWith('.rs')) return '⊕';
if (name.endsWith('.toml')) return '⚙';
if (name.endsWith('.json')) return '{}';
return '·';
const ext = getExt(name);
if (['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'].includes(ext)) return '</>';
if (ext === 'svelte' || ext === 'vue') return '~';
if (ext === 'rs') return 'Rs';
if (ext === 'py') return 'Py';
if (ext === 'go') return 'Go';
if (['json', 'toml', 'yaml', 'yml'].includes(ext)) return '{}';
if (ext === 'md') return 'M';
if (ext === 'css' || ext === 'scss') return '#';
if (ext === 'html') return '<>';
if (IMAGE_EXTS.has(ext)) return 'Im';
if (ext === 'pdf') return 'Pd';
if (CSV_EXTS.has(ext)) return 'Tb';
return '..';
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
// Load root directory on mount
$effect(() => {
if (cwd) {
loadDir(cwd);
const s = new Set(openDirs);
s.add(cwd);
openDirs = s;
}
});
</script>
<div class="file-browser">
<!-- Tree panel -->
<div class="fb-tree">
{#snippet renderNode(node: FileNode, path: string, depth: number)}
{#if node.type === 'dir'}
<button
class="fb-row fb-dir"
style:padding-left="{0.5 + depth * 0.875}rem"
onclick={() => toggleDir(path)}
aria-expanded={openDirs.has(path)}
>
<span class="fb-chevron" class:open={openDirs.has(path)}></span>
<span class="fb-icon dir-icon">📁</span>
<span class="fb-name">{node.name}</span>
</button>
{#if openDirs.has(path) && node.children}
{#each node.children as child}
{@render renderNode(child, `${path}/${child.name}`, depth + 1)}
{/each}
{/if}
{:else}
<button
class="fb-row fb-file"
class:selected={selectedFile === path}
style:padding-left="{0.5 + depth * 0.875}rem"
onclick={() => selectFile(path)}
title={path}
>
<span class="fb-icon file-type">{fileIcon(node.name)}</span>
<span class="fb-name">{node.name}</span>
</button>
{#snippet renderEntries(dirPath: string, depth: number)}
{#if childrenCache.has(dirPath)}
{#each childrenCache.get(dirPath) ?? [] as entry}
{@const fullPath = `${dirPath}/${entry.name}`}
{#if entry.type === 'dir'}
<button
class="fb-row fb-dir"
style:padding-left="{0.5 + depth * 0.875}rem"
onclick={() => toggleDir(fullPath)}
aria-expanded={openDirs.has(fullPath)}
>
<span class="fb-chevron" class:open={openDirs.has(fullPath)}>
{#if loadingDirs.has(fullPath)}...{:else}>{/if}
</span>
<span class="fb-name">{entry.name}</span>
</button>
{#if openDirs.has(fullPath)}
{@render renderEntries(fullPath, depth + 1)}
{/if}
{:else}
<button
class="fb-row fb-file"
class:selected={selectedFile === fullPath}
style:padding-left="{0.5 + depth * 0.875}rem"
onclick={() => selectFile(fullPath)}
title={`${entry.name} (${formatSize(entry.size)})`}
>
<span class="fb-icon file-type">{fileIcon(entry.name)}</span>
<span class="fb-name">{entry.name}</span>
{#if selectedFile === fullPath && isDirty}
<span class="dirty-dot" title="Unsaved changes"></span>
{/if}
</button>
{/if}
{/each}
{:else if loadingDirs.has(dirPath)}
<div class="fb-loading" style:padding-left="{0.5 + depth * 0.875}rem">Loading...</div>
{/if}
{/snippet}
{#each TREE as node}
{@render renderNode(node, node.name, 0)}
{/each}
{@render renderEntries(cwd, 0)}
</div>
{#if selectedFile}
<div class="fb-preview">
<div class="fb-preview-label">{selectedFile}</div>
<div class="fb-preview-content">(click to open in editor)</div>
</div>
{/if}
<!-- Viewer panel -->
<div class="fb-viewer">
{#if !selectedFile}
<div class="fb-empty">Select a file to view</div>
{:else if fileLoading}
<div class="fb-empty">Loading...</div>
{:else if fileError}
<div class="fb-error">{fileError}</div>
{:else if selectedType === 'pdf'}
<PdfViewer filePath={selectedFile} />
{:else if selectedType === 'csv' && fileContent != null}
<CsvTable content={fileContent} filename={selectedName} />
{:else if selectedType === 'image' && fileContent}
{@const ext = getExt(selectedName)}
{@const mime = ext === 'svg' ? 'image/svg+xml' : ext === 'png' ? 'image/png' : ext === 'gif' ? 'image/gif' : ext === 'webp' ? 'image/webp' : 'image/jpeg'}
<div class="fb-image-wrap">
<div class="fb-image-label">{selectedName} ({formatSize(fileSize)})</div>
<img
class="fb-image"
src="data:{mime};base64,{fileEncoding === 'base64' ? fileContent : btoa(fileContent)}"
alt={selectedName}
/>
</div>
{:else if selectedType === 'code' && fileContent != null}
<div class="fb-editor-header">
<span class="fb-editor-path" title={selectedFile}>
{selectedName}
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
</span>
<span class="fb-editor-size">{formatSize(fileSize)}</span>
</div>
<CodeEditor
content={fileContent}
lang={extToLang(selectedFile)}
onsave={saveFile}
onchange={onEditorChange}
onblur={saveFile}
/>
{:else if fileContent != null}
<!-- Raw text fallback -->
<div class="fb-editor-header">
<span class="fb-editor-path" title={selectedFile}>
{selectedName}
{#if isDirty}<span class="dirty-indicator"> (modified)</span>{/if}
</span>
<span class="fb-editor-size">{formatSize(fileSize)}</span>
</div>
<CodeEditor
content={fileContent}
lang="text"
onsave={saveFile}
onchange={onEditorChange}
onblur={saveFile}
/>
{/if}
</div>
</div>
<style>
.file-browser {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
font-size: 0.8125rem;
}
/* ── Tree panel ── */
.fb-tree {
flex: 1;
width: 14rem;
min-width: 10rem;
overflow-y: auto;
padding: 0.25rem 0;
border-right: 1px solid var(--ctp-surface0);
background: var(--ctp-mantle);
flex-shrink: 0;
}
.fb-tree::-webkit-scrollbar { width: 0.25rem; }
@ -163,25 +382,25 @@
.fb-chevron {
display: inline-block;
width: 0.875rem;
font-size: 0.875rem;
font-size: 0.75rem;
color: var(--ctp-overlay1);
transition: transform 0.12s;
transform: rotate(0deg);
flex-shrink: 0;
line-height: 1;
font-family: var(--term-font-family, monospace);
}
.fb-chevron.open { transform: rotate(90deg); }
.fb-icon {
flex-shrink: 0;
font-style: normal;
}
.fb-icon { flex-shrink: 0; font-style: normal; }
.file-type {
font-size: 0.6875rem;
font-size: 0.6rem;
color: var(--ctp-overlay1);
font-family: var(--term-font-family);
font-family: var(--term-font-family, monospace);
width: 1.25rem;
text-align: center;
}
.fb-name {
@ -192,26 +411,104 @@
.fb-dir .fb-name { color: var(--ctp-subtext1); font-weight: 500; }
.fb-preview {
border-top: 1px solid var(--ctp-surface0);
padding: 0.5rem 0.75rem;
.fb-loading {
font-size: 0.7rem;
color: var(--ctp-overlay0);
font-style: italic;
padding: 0.15rem 0;
}
.dirty-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--ctp-peach);
flex-shrink: 0;
margin-left: auto;
}
/* ── Viewer panel ── */
.fb-viewer {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
background: var(--ctp-base);
}
.fb-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-overlay0);
font-size: 0.8rem;
font-style: italic;
}
.fb-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-red);
font-size: 0.8rem;
padding: 1rem;
}
.fb-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.fb-preview-label {
font-size: 0.75rem;
.fb-editor-path {
font-size: 0.7rem;
color: var(--ctp-subtext0);
font-family: var(--term-font-family);
margin-bottom: 0.2rem;
font-family: var(--term-font-family, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fb-preview-content {
font-size: 0.75rem;
color: var(--ctp-overlay0);
.dirty-indicator {
color: var(--ctp-peach);
font-style: italic;
}
.fb-editor-size {
font-size: 0.65rem;
color: var(--ctp-overlay0);
font-variant-numeric: tabular-nums;
flex-shrink: 0;
margin-left: 0.5rem;
}
.fb-image-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 0.5rem;
}
.fb-image-label {
font-size: 0.75rem;
color: var(--ctp-subtext0);
font-family: var(--term-font-family, monospace);
flex-shrink: 0;
padding: 0.5rem;
}
.fb-image {
max-width: 100%;
max-height: calc(100% - 2rem);
object-fit: contain;
border-radius: 0.25rem;
}
</style>

View file

@ -0,0 +1,298 @@
<script lang="ts">
import { onMount, onDestroy, untrack } from 'svelte';
import * as pdfjsLib from 'pdfjs-dist';
import pdfjsWorkerUrl from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
import { appRpc } from './rpc.ts';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorkerUrl;
interface Props {
filePath: string;
}
let { filePath }: Props = $props();
let container: HTMLDivElement | undefined = $state();
let pageCount = $state(0);
let currentScale = $state(1.0);
let loading = $state(true);
let error = $state<string | null>(null);
let pdfDoc: pdfjsLib.PDFDocumentProxy | null = null;
let observer: IntersectionObserver | null = null;
let renderedPages = new Set<number>();
let renderingPages = new Set<number>();
const SCALE_STEP = 0.25;
const MIN_SCALE = 0.5;
const MAX_SCALE = 3.0;
async function loadPdf(fp: string) {
loading = true;
error = null;
cleanup();
try {
// Read file as base64 via RPC, then convert to Uint8Array
const result = await appRpc.request["files.read"]({ path: fp });
if (result.error) {
error = result.error;
return;
}
if (!result.content) {
error = 'Empty file';
return;
}
let data: Uint8Array;
if (result.encoding === 'base64') {
const binary = atob(result.content);
data = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) data[i] = binary.charCodeAt(i);
} else {
// Shouldn't happen for PDF but handle gracefully
const encoder = new TextEncoder();
data = encoder.encode(result.content);
}
const loadingTask = pdfjsLib.getDocument({ data });
pdfDoc = await loadingTask.promise;
pageCount = pdfDoc.numPages;
createPlaceholders();
} catch (e) {
error = `Failed to load PDF: ${e}`;
console.warn('PDF load error:', e);
} finally {
loading = false;
}
}
function createPlaceholders() {
if (!pdfDoc || !container) return;
container.innerHTML = '';
renderedPages.clear();
renderingPages.clear();
observer?.disconnect();
observer = new IntersectionObserver(onIntersect, {
root: container,
rootMargin: '200px 0px',
});
for (let i = 1; i <= pdfDoc.numPages; i++) {
const placeholder = document.createElement('div');
placeholder.className = 'pdf-page-slot';
placeholder.dataset.page = String(i);
placeholder.style.width = '100%';
placeholder.style.minHeight = '20rem';
container.appendChild(placeholder);
observer.observe(placeholder);
}
}
function onIntersect(entries: IntersectionObserverEntry[]) {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
const pageNum = Number((entry.target as HTMLElement).dataset.page);
if (!pageNum || renderedPages.has(pageNum) || renderingPages.has(pageNum)) continue;
renderPage(pageNum, entry.target as HTMLElement);
}
}
async function renderPage(pageNum: number, slot: HTMLElement) {
if (!pdfDoc) return;
renderingPages.add(pageNum);
try {
const page = await pdfDoc.getPage(pageNum);
const viewport = page.getViewport({ scale: currentScale * window.devicePixelRatio });
const displayViewport = page.getViewport({ scale: currentScale });
const canvas = document.createElement('canvas');
canvas.className = 'pdf-page-canvas';
canvas.width = viewport.width;
canvas.height = viewport.height;
canvas.style.width = `${displayViewport.width}px`;
canvas.style.height = `${displayViewport.height}px`;
slot.innerHTML = '';
slot.style.minHeight = '';
slot.appendChild(canvas);
const ctx = canvas.getContext('2d');
if (!ctx) return;
const task = page.render({ canvasContext: ctx, viewport });
await task.promise;
renderedPages.add(pageNum);
observer?.unobserve(slot);
} catch (e: unknown) {
if (e && typeof e === 'object' && 'name' in e && (e as { name: string }).name !== 'RenderingCancelledException') {
console.warn(`Failed to render page ${pageNum}:`, e);
}
} finally {
renderingPages.delete(pageNum);
}
}
function rerender() {
renderedPages.clear();
renderingPages.clear();
createPlaceholders();
}
function zoomIn() {
if (currentScale >= MAX_SCALE) return;
currentScale = Math.min(MAX_SCALE, currentScale + SCALE_STEP);
rerender();
}
function zoomOut() {
if (currentScale <= MIN_SCALE) return;
currentScale = Math.max(MIN_SCALE, currentScale - SCALE_STEP);
rerender();
}
function resetZoom() {
currentScale = 1.0;
rerender();
}
function cleanup() {
observer?.disconnect();
observer = null;
renderedPages.clear();
renderingPages.clear();
if (container) container.innerHTML = '';
if (pdfDoc) {
pdfDoc.destroy();
pdfDoc = null;
}
}
onMount(() => { loadPdf(filePath); });
let lastPath = $state(untrack(() => filePath));
$effect(() => {
const p = filePath;
if (p !== lastPath) {
lastPath = p;
loadPdf(p);
}
});
onDestroy(() => { cleanup(); });
</script>
<div class="pdf-viewer">
<div class="pdf-toolbar">
<span class="pdf-info">
{#if loading}
Loading...
{:else if error}
Error
{:else}
{pageCount} page{pageCount !== 1 ? 's' : ''}
{/if}
</span>
<div class="pdf-zoom-controls">
<button class="zoom-btn" onclick={zoomOut} disabled={currentScale <= MIN_SCALE} title="Zoom out">-</button>
<button class="zoom-label" onclick={resetZoom} title="Reset zoom">{Math.round(currentScale * 100)}%</button>
<button class="zoom-btn" onclick={zoomIn} disabled={currentScale >= MAX_SCALE} title="Zoom in">+</button>
</div>
</div>
{#if error}
<div class="pdf-error">{error}</div>
{:else}
<div class="pdf-pages" bind:this={container}></div>
{/if}
</div>
<style>
.pdf-viewer {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
background: var(--ctp-crust);
}
.pdf-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.pdf-info {
font-size: 0.7rem;
color: var(--ctp-overlay1);
}
.pdf-zoom-controls {
display: flex;
align-items: center;
gap: 0.125rem;
}
.zoom-btn, .zoom-label {
background: transparent;
border: 1px solid var(--ctp-surface1);
color: var(--ctp-subtext0);
font-size: 0.7rem;
padding: 0.125rem 0.375rem;
border-radius: 0.1875rem;
cursor: pointer;
transition: background 0.12s, color 0.12s;
}
.zoom-btn:hover:not(:disabled), .zoom-label:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.zoom-btn:disabled {
opacity: 0.4;
cursor: default;
}
.zoom-label {
min-width: 3rem;
text-align: center;
font-variant-numeric: tabular-nums;
}
.pdf-pages {
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 0.75rem;
}
.pdf-pages :global(.pdf-page-slot) {
display: flex;
justify-content: center;
}
.pdf-pages :global(.pdf-page-canvas) {
box-shadow: 0 1px 4px color-mix(in srgb, var(--ctp-crust) 60%, transparent);
border-radius: 2px;
}
.pdf-error {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--ctp-red);
font-size: 0.8rem;
padding: 1rem;
}
</style>

View file

@ -3,12 +3,15 @@
import TerminalTabs from './TerminalTabs.svelte';
import FileBrowser from './FileBrowser.svelte';
import MemoryTab from './MemoryTab.svelte';
import CommsTab from './CommsTab.svelte';
import TaskBoardTab from './TaskBoardTab.svelte';
import {
startAgent, stopAgent, sendPrompt, getSession, hasSession,
loadLastSession,
type AgentStatus, type AgentMessage,
} from './agent-store.svelte.ts';
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory';
type ProjectTab = 'model' | 'docs' | 'context' | 'files' | 'ssh' | 'memory' | 'comms' | 'tasks';
interface Props {
id: string;
@ -29,6 +32,8 @@
clonesAtMax?: boolean;
/** Callback when user requests cloning (receives projectId and branch name). */
onClone?: (projectId: string, branch: string) => void;
/** Group ID for btmsg/bttask context. */
groupId?: string;
}
let {
@ -315,7 +320,7 @@
role="tabpanel"
aria-label="Files"
>
<FileBrowser />
<FileBrowser {cwd} />
</div>
{/if}

View file

@ -0,0 +1,301 @@
<script lang="ts">
import { appRpc } from './rpc.ts';
interface Props {
open: boolean;
onClose: () => void;
onNavigate?: (resultType: string, id: string) => void;
}
let { open, onClose, onNavigate }: Props = $props();
interface SearchResult {
resultType: string;
id: string;
title: string;
snippet: string;
score: number;
}
let query = $state('');
let results = $state<SearchResult[]>([]);
let loading = $state(false);
let selectedIndex = $state(0);
let inputEl: HTMLInputElement | undefined = $state();
// Debounce timer
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
// Group results by type
let grouped = $derived(() => {
const groups: Record<string, SearchResult[]> = {};
for (const r of results) {
const key = r.resultType;
if (!groups[key]) groups[key] = [];
groups[key].push(r);
}
return groups;
});
let groupLabels: Record<string, string> = {
message: 'Messages',
task: 'Tasks',
btmsg: 'Communications',
};
function handleInput() {
if (debounceTimer) clearTimeout(debounceTimer);
debounceTimer = setTimeout(() => doSearch(), 300);
}
async function doSearch() {
const q = query.trim();
if (!q) {
results = [];
return;
}
loading = true;
try {
const res = await appRpc.request['search.query']({ query: q, limit: 20 });
results = res.results ?? [];
selectedIndex = 0;
} catch (err) {
console.error('[search]', err);
results = [];
} finally {
loading = false;
}
}
function handleKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault();
onClose();
} else if (e.key === 'ArrowDown') {
e.preventDefault();
if (selectedIndex < results.length - 1) selectedIndex++;
} else if (e.key === 'ArrowUp') {
e.preventDefault();
if (selectedIndex > 0) selectedIndex--;
} else if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
const item = results[selectedIndex];
if (item) {
onNavigate?.(item.resultType, item.id);
onClose();
}
}
}
function selectResult(idx: number) {
const item = results[idx];
if (item) {
onNavigate?.(item.resultType, item.id);
onClose();
}
}
// Focus input when opened
$effect(() => {
if (open && inputEl) {
requestAnimationFrame(() => inputEl?.focus());
}
if (!open) {
query = '';
results = [];
selectedIndex = 0;
}
});
</script>
{#if open}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay-backdrop" onclick={onClose} onkeydown={handleKeydown}>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay-panel" onclick={(e) => e.stopPropagation()} onkeydown={handleKeydown}>
<div class="search-input-wrap">
<svg class="search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" aria-hidden="true">
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
</svg>
<input
bind:this={inputEl}
class="search-input"
type="text"
placeholder="Search messages, tasks, communications..."
bind:value={query}
oninput={handleInput}
/>
{#if loading}
<span class="loading-dot" aria-label="Searching"></span>
{/if}
<kbd class="esc-hint">Esc</kbd>
</div>
{#if results.length > 0}
<div class="results-list">
{#each Object.entries(grouped()) as [type, items]}
<div class="result-group">
<div class="group-label">{groupLabels[type] ?? type}</div>
{#each items as item, i}
{@const flatIdx = results.indexOf(item)}
<button
class="result-item"
class:selected={flatIdx === selectedIndex}
onclick={() => selectResult(flatIdx)}
onmouseenter={() => selectedIndex = flatIdx}
>
<span class="result-title">{item.title}</span>
<span class="result-snippet">{@html item.snippet}</span>
</button>
{/each}
</div>
{/each}
</div>
{:else if query.trim() && !loading}
<div class="no-results">No results for "{query}"</div>
{/if}
</div>
</div>
{/if}
<style>
.overlay-backdrop {
position: fixed;
inset: 0;
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
z-index: 200;
display: flex;
justify-content: center;
padding-top: 15vh;
}
.overlay-panel {
width: min(36rem, 90vw);
max-height: 60vh;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.5rem;
box-shadow: 0 1rem 3rem color-mix(in srgb, var(--ctp-crust) 50%, transparent);
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-input-wrap {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1rem;
border-bottom: 1px solid var(--ctp-surface0);
}
.search-icon {
width: 1rem;
height: 1rem;
color: var(--ctp-overlay1);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: transparent;
border: none;
outline: none;
color: var(--ctp-text);
font-size: 0.875rem;
font-family: var(--ui-font-family, system-ui, sans-serif);
}
.search-input::placeholder {
color: var(--ctp-overlay0);
}
.loading-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
background: var(--ctp-blue);
animation: pulse 0.8s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.esc-hint {
padding: 0.1rem 0.3rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.2rem;
font-size: 0.625rem;
color: var(--ctp-overlay0);
font-family: var(--ui-font-family, system-ui, sans-serif);
}
.results-list {
overflow-y: auto;
padding: 0.25rem 0;
}
.result-group {
padding: 0.25rem 0;
}
.group-label {
font-size: 0.625rem;
font-weight: 600;
color: var(--ctp-overlay0);
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.25rem 1rem;
}
.result-item {
width: 100%;
display: flex;
flex-direction: column;
gap: 0.125rem;
padding: 0.375rem 1rem;
background: transparent;
border: none;
text-align: left;
cursor: pointer;
color: var(--ctp-text);
font: inherit;
font-size: 0.8125rem;
}
.result-item:hover, .result-item.selected {
background: var(--ctp-surface0);
}
.result-title {
font-weight: 500;
color: var(--ctp-text);
}
.result-snippet {
font-size: 0.75rem;
color: var(--ctp-subtext0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.result-snippet :global(b) {
color: var(--ctp-yellow);
font-weight: 600;
}
.no-results {
padding: 1.5rem 1rem;
text-align: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
}
.results-list::-webkit-scrollbar { width: 0.375rem; }
.results-list::-webkit-scrollbar-track { background: transparent; }
.results-list::-webkit-scrollbar-thumb { background: var(--ctp-surface1); border-radius: 0.25rem; }
</style>

View file

@ -0,0 +1,275 @@
<script lang="ts">
import {
getHealthAggregates,
getAttentionQueue,
type ProjectHealth,
} from './health-store.svelte.ts';
interface Props {
projectCount: number;
totalTokens: number;
totalCost: number;
sessionDuration: string;
groupName: string;
onFocusProject?: (projectId: string) => void;
}
let {
projectCount,
totalTokens,
totalCost,
sessionDuration,
groupName,
onFocusProject,
}: Props = $props();
let health = $derived(getHealthAggregates());
let attentionQueue = $derived(getAttentionQueue(5));
let showAttention = $state(false);
function formatRate(rate: number): string {
if (rate < 0.01) return '$0/hr';
if (rate < 1) return `$${rate.toFixed(2)}/hr`;
return `$${rate.toFixed(1)}/hr`;
}
function fmtTokens(n: number): string {
return n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
}
function fmtCost(n: number): string {
return `$${n.toFixed(3)}`;
}
function attentionColor(item: ProjectHealth): string {
if (item.attentionScore >= 90) return 'var(--ctp-red)';
if (item.attentionScore >= 70) return 'var(--ctp-peach)';
if (item.attentionScore >= 40) return 'var(--ctp-yellow)';
return 'var(--ctp-overlay1)';
}
function focusProject(projectId: string) {
onFocusProject?.(projectId);
showAttention = false;
}
</script>
<footer class="status-bar" role="status" aria-live="polite" aria-label="System status">
<!-- Left: agent state counts -->
{#if health.running > 0}
<span class="status-segment">
<span class="dot green pulse-dot" aria-hidden="true"></span>
<span class="val">{health.running}</span>
<span>running</span>
</span>
{/if}
{#if health.idle > 0}
<span class="status-segment">
<span class="dot gray" aria-hidden="true"></span>
<span class="val">{health.idle}</span>
<span>idle</span>
</span>
{/if}
{#if health.stalled > 0}
<span class="status-segment stalled">
<span class="dot orange" aria-hidden="true"></span>
<span class="val">{health.stalled}</span>
<span>stalled</span>
</span>
{/if}
<!-- Attention queue -->
{#if attentionQueue.length > 0}
<button
class="status-segment attn-btn"
class:attn-open={showAttention}
onclick={() => showAttention = !showAttention}
title="Needs attention"
>
<span class="dot orange pulse-dot" aria-hidden="true"></span>
<span class="val">{attentionQueue.length}</span>
<span>attention</span>
</button>
{/if}
<span class="spacer"></span>
<!-- Right: aggregates -->
{#if health.totalBurnRatePerHour > 0}
<span class="status-segment burn" title="Burn rate">
<span class="val">{formatRate(health.totalBurnRatePerHour)}</span>
</span>
{/if}
<span class="status-segment" title="Active group">
<span class="val">{groupName}</span>
</span>
<span class="status-segment" title="Projects">
<span class="val">{projectCount}</span>
<span>projects</span>
</span>
<span class="status-segment" title="Session duration">
<span>session</span>
<span class="val">{sessionDuration}</span>
</span>
<span class="status-segment" title="Total tokens">
<span>tokens</span>
<span class="val">{fmtTokens(totalTokens)}</span>
</span>
<span class="status-segment" title="Total cost">
<span>cost</span>
<span class="val cost">{fmtCost(totalCost)}</span>
</span>
<kbd class="palette-hint" title="Search (Ctrl+Shift+F)">Ctrl+Shift+F</kbd>
</footer>
<!-- Attention dropdown -->
{#if showAttention && attentionQueue.length > 0}
<div class="attention-panel">
{#each attentionQueue as item (item.projectId)}
<button
class="attention-card"
onclick={() => focusProject(item.projectId)}
>
<span class="card-id">{item.projectId.slice(0, 12)}</span>
<span class="card-reason" style="color: {attentionColor(item)}">{item.attentionReason}</span>
{#if item.contextPressure !== null && item.contextPressure > 0.5}
<span class="card-ctx">ctx {Math.round(item.contextPressure * 100)}%</span>
{/if}
</button>
{/each}
</div>
{/if}
<style>
.status-bar {
height: var(--status-bar-height, 1.5rem);
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);
font-family: var(--ui-font-family, system-ui, sans-serif);
user-select: none;
position: relative;
}
.status-segment {
display: flex;
align-items: center;
gap: 0.25rem;
white-space: nowrap;
}
.dot {
width: 0.4375rem;
height: 0.4375rem;
border-radius: 50%;
flex-shrink: 0;
}
.dot.green { background: var(--ctp-green); }
.dot.gray { background: var(--ctp-overlay0); }
.dot.orange { background: var(--ctp-peach); }
.pulse-dot {
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.val { color: var(--ctp-text); font-weight: 500; }
.cost { color: var(--ctp-yellow); }
.burn { color: var(--ctp-mauve); font-weight: 600; }
.stalled { color: var(--ctp-peach); font-weight: 600; }
.spacer { flex: 1; }
/* Attention button */
.attn-btn {
background: none;
border: none;
color: var(--ctp-peach);
font: inherit;
font-size: inherit;
cursor: pointer;
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0;
font-weight: 600;
}
.attn-btn:hover, .attn-btn.attn-open {
color: var(--ctp-red);
}
/* Attention panel */
.attention-panel {
position: absolute;
bottom: var(--status-bar-height, 1.5rem);
left: 0;
right: 0;
background: var(--ctp-surface0);
border-top: 1px solid var(--ctp-surface1);
display: flex;
gap: 1px;
padding: 0.25rem 0.5rem;
z-index: 100;
overflow-x: auto;
}
.attention-card {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.25rem 0.5rem;
background: var(--ctp-base);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font: inherit;
font-size: 0.6875rem;
cursor: pointer;
white-space: nowrap;
flex-shrink: 0;
}
.attention-card:hover {
background: var(--ctp-surface0);
border-color: var(--ctp-surface2);
}
.card-id { font-weight: 600; }
.card-reason { font-size: 0.625rem; }
.card-ctx {
font-size: 0.5625rem;
color: var(--ctp-overlay0);
background: color-mix(in srgb, var(--ctp-yellow) 10%, transparent);
padding: 0 0.25rem;
border-radius: 0.125rem;
}
.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, system-ui, sans-serif);
cursor: pointer;
transition: color 0.1s;
}
.palette-hint:hover { color: var(--ctp-subtext0); }
</style>

View file

@ -0,0 +1,515 @@
<script lang="ts">
import { appRpc } from './rpc.ts';
// ── Types ────────────────────────────────────────────────────────────
interface Task {
id: string;
title: string;
description: string;
status: string;
priority: string;
assignedTo: string | null;
createdBy: string;
groupId: string;
version: number;
}
interface Props {
groupId: string;
/** Agent ID perspective for creating tasks (defaults to 'admin'). */
agentId?: string;
}
let { groupId, agentId = 'admin' }: Props = $props();
// ── State ────────────────────────────────────────────────────────────
const COLUMNS = ['todo', 'progress', 'review', 'done', 'blocked'] as const;
const COL_LABELS: Record<string, string> = {
todo: 'To Do', progress: 'In Progress', review: 'Review',
done: 'Done', blocked: 'Blocked',
};
const PRIORITY_COLORS: Record<string, string> = {
high: 'var(--ctp-red)', medium: 'var(--ctp-peach)',
low: 'var(--ctp-teal)',
};
let tasks = $state<Task[]>([]);
let showCreateForm = $state(false);
let newTitle = $state('');
let newDesc = $state('');
let newPriority = $state('medium');
let error = $state('');
// Drag state
let draggedTaskId = $state<string | null>(null);
let dragOverCol = $state<string | null>(null);
// ── Derived ──────────────────────────────────────────────────────────
let tasksByCol = $derived(
COLUMNS.reduce((acc, col) => {
acc[col] = tasks.filter(t => t.status === col);
return acc;
}, {} as Record<string, Task[]>)
);
// ── Data fetching ────────────────────────────────────────────────────
async function loadTasks() {
try {
const res = await appRpc.request['bttask.listTasks']({ groupId });
tasks = res.tasks;
} catch (err) {
console.error('[TaskBoard] loadTasks:', err);
}
}
async function createTask() {
const title = newTitle.trim();
if (!title) { error = 'Title required'; return; }
error = '';
try {
const res = await appRpc.request['bttask.createTask']({
title,
description: newDesc.trim(),
priority: newPriority,
groupId,
createdBy: agentId,
});
if (res.ok) {
newTitle = '';
newDesc = '';
newPriority = 'medium';
showCreateForm = false;
await loadTasks();
} else {
error = res.error ?? 'Failed to create task';
}
} catch (err) {
console.error('[TaskBoard] createTask:', err);
error = 'Failed to create task';
}
}
async function moveTask(taskId: string, newStatus: string) {
const task = tasks.find(t => t.id === taskId);
if (!task || task.status === newStatus) return;
try {
const res = await appRpc.request['bttask.updateTaskStatus']({
taskId,
status: newStatus,
expectedVersion: task.version,
});
if (res.ok) {
// Optimistic update
task.status = newStatus;
task.version = res.newVersion ?? task.version + 1;
tasks = [...tasks]; // trigger reactivity
} else {
error = res.error ?? 'Version conflict';
await loadTasks(); // reload on conflict
}
} catch (err) {
console.error('[TaskBoard] moveTask:', err);
await loadTasks();
}
}
async function deleteTask(taskId: string) {
try {
await appRpc.request['bttask.deleteTask']({ taskId });
tasks = tasks.filter(t => t.id !== taskId);
} catch (err) {
console.error('[TaskBoard] deleteTask:', err);
}
}
// ── Drag handlers ────────────────────────────────────────────────────
function onDragStart(e: DragEvent, taskId: string) {
draggedTaskId = taskId;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', taskId);
}
}
function onDragOver(e: DragEvent, col: string) {
e.preventDefault();
dragOverCol = col;
}
function onDragLeave() {
dragOverCol = null;
}
function onDrop(e: DragEvent, col: string) {
e.preventDefault();
dragOverCol = null;
if (draggedTaskId) {
moveTask(draggedTaskId, col);
draggedTaskId = null;
}
}
function onDragEnd() {
draggedTaskId = null;
dragOverCol = null;
}
// ── Init + polling ───────────────────────────────────────────────────
let pollTimer: ReturnType<typeof setInterval> | null = null;
$effect(() => {
loadTasks();
pollTimer = setInterval(loadTasks, 5000);
return () => { if (pollTimer) clearInterval(pollTimer); };
});
</script>
<div class="task-board">
<!-- Toolbar -->
<div class="tb-toolbar">
<span class="tb-title">Task Board</span>
<span class="tb-count">{tasks.length} tasks</span>
<button class="tb-add-btn" onclick={() => { showCreateForm = !showCreateForm; }}>
{showCreateForm ? 'Cancel' : '+ Task'}
</button>
</div>
<!-- Create form -->
{#if showCreateForm}
<div class="tb-create-form">
<input
class="tb-input"
type="text"
placeholder="Task title"
bind:value={newTitle}
onkeydown={(e) => { if (e.key === 'Enter') createTask(); }}
/>
<input
class="tb-input tb-desc"
type="text"
placeholder="Description (optional)"
bind:value={newDesc}
/>
<div class="tb-form-row">
<select class="tb-select" bind:value={newPriority}>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<button class="tb-submit" onclick={createTask}>Create</button>
</div>
{#if error}
<span class="tb-error">{error}</span>
{/if}
</div>
{/if}
<!-- Kanban columns -->
<div class="tb-columns">
{#each COLUMNS as col}
<div
class="tb-column"
class:drag-over={dragOverCol === col}
ondragover={(e) => onDragOver(e, col)}
ondragleave={onDragLeave}
ondrop={(e) => onDrop(e, col)}
role="list"
aria-label="{COL_LABELS[col]} column"
>
<div class="tb-col-header">
<span class="tb-col-label">{COL_LABELS[col]}</span>
<span class="tb-col-count">{tasksByCol[col]?.length ?? 0}</span>
</div>
<div class="tb-col-body">
{#each tasksByCol[col] ?? [] as task (task.id)}
<div
class="tb-card"
class:dragging={draggedTaskId === task.id}
draggable="true"
ondragstart={(e) => onDragStart(e, task.id)}
ondragend={onDragEnd}
role="listitem"
>
<div class="card-header">
<span
class="priority-dot"
style:background={PRIORITY_COLORS[task.priority] ?? 'var(--ctp-overlay0)'}
title="Priority: {task.priority}"
></span>
<span class="card-title">{task.title}</span>
<button
class="card-delete"
onclick={() => deleteTask(task.id)}
title="Delete task"
aria-label="Delete task"
>&times;</button>
</div>
{#if task.description}
<div class="card-desc">{task.description}</div>
{/if}
{#if task.assignedTo}
<div class="card-assignee">
<span class="assignee-icon">@</span>
{task.assignedTo}
</div>
{/if}
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
<style>
.task-board {
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
}
.tb-toolbar {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.tb-title {
font-size: 0.8125rem;
font-weight: 600;
color: var(--ctp-text);
}
.tb-count {
font-size: 0.6875rem;
color: var(--ctp-overlay0);
margin-left: auto;
}
.tb-add-btn {
padding: 0.125rem 0.5rem;
background: color-mix(in srgb, var(--ctp-green) 15%, transparent);
border: 1px solid var(--ctp-green);
border-radius: 0.25rem;
color: var(--ctp-green);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
transition: background 0.1s;
}
.tb-add-btn:hover {
background: color-mix(in srgb, var(--ctp-green) 30%, transparent);
}
.tb-create-form {
display: flex;
flex-direction: column;
gap: 0.25rem;
padding: 0.5rem 0.625rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.tb-input {
height: 1.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.75rem;
padding: 0 0.375rem;
outline: none;
}
.tb-input:focus { border-color: var(--ctp-mauve); }
.tb-form-row {
display: flex;
gap: 0.25rem;
align-items: center;
}
.tb-select {
height: 1.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
padding: 0 0.25rem;
outline: none;
}
.tb-submit {
padding: 0.125rem 0.625rem;
height: 1.625rem;
background: color-mix(in srgb, var(--ctp-mauve) 20%, transparent);
border: 1px solid var(--ctp-mauve);
border-radius: 0.25rem;
color: var(--ctp-mauve);
font-family: var(--ui-font-family);
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
margin-left: auto;
}
.tb-submit:hover {
background: color-mix(in srgb, var(--ctp-mauve) 35%, transparent);
}
.tb-error {
font-size: 0.6875rem;
color: var(--ctp-red);
}
.tb-columns {
display: flex;
flex: 1;
min-height: 0;
overflow-x: auto;
gap: 1px;
background: var(--ctp-surface0);
}
.tb-column {
flex: 1;
min-width: 8rem;
display: flex;
flex-direction: column;
background: var(--ctp-base);
transition: background 0.15s;
}
.tb-column.drag-over {
background: color-mix(in srgb, var(--ctp-blue) 8%, var(--ctp-base));
}
.tb-col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.375rem 0.5rem;
border-bottom: 1px solid var(--ctp-surface0);
flex-shrink: 0;
}
.tb-col-label {
font-size: 0.6875rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--ctp-subtext0);
}
.tb-col-count {
font-size: 0.625rem;
color: var(--ctp-overlay0);
background: var(--ctp-surface0);
padding: 0 0.25rem;
border-radius: 0.25rem;
}
.tb-col-body {
flex: 1;
overflow-y: auto;
padding: 0.375rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.tb-card {
background: var(--ctp-mantle);
border: 1px solid var(--ctp-surface0);
border-radius: 0.375rem;
padding: 0.375rem 0.5rem;
cursor: grab;
transition: border-color 0.12s, opacity 0.12s;
}
.tb-card:hover { border-color: var(--ctp-surface1); }
.tb-card.dragging { opacity: 0.4; }
.card-header {
display: flex;
align-items: center;
gap: 0.375rem;
}
.priority-dot {
width: 0.5rem;
height: 0.5rem;
border-radius: 50%;
flex-shrink: 0;
}
.card-title {
flex: 1;
font-size: 0.75rem;
font-weight: 600;
color: var(--ctp-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-delete {
background: transparent;
border: none;
color: var(--ctp-overlay0);
font-size: 0.875rem;
cursor: pointer;
padding: 0;
line-height: 1;
flex-shrink: 0;
opacity: 0;
transition: opacity 0.1s, color 0.1s;
}
.tb-card:hover .card-delete { opacity: 1; }
.card-delete:hover { color: var(--ctp-red); }
.card-desc {
font-size: 0.6875rem;
color: var(--ctp-subtext0);
margin-top: 0.125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-assignee {
font-size: 0.625rem;
color: var(--ctp-overlay1);
margin-top: 0.25rem;
display: flex;
align-items: center;
gap: 0.125rem;
}
.assignee-icon {
color: var(--ctp-blue);
font-weight: 700;
}
</style>

View file

@ -74,6 +74,54 @@ let sessions = $state<Record<string, AgentSession>>({});
// Grace period timers for cleanup after done/error
const cleanupTimers = new Map<string, ReturnType<typeof setTimeout>>();
// Debounce timer for message persistence
const msgPersistTimers = new Map<string, ReturnType<typeof setTimeout>>();
// ── Session persistence helpers ─────────────────────────────────────────────
function persistSession(session: AgentSession): void {
appRpc.request['session.save']({
projectId: session.projectId,
sessionId: session.sessionId,
provider: session.provider,
status: session.status,
costUsd: session.costUsd,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
model: session.model,
error: session.error,
createdAt: session.messages[0]?.timestamp ?? Date.now(),
updatedAt: Date.now(),
}).catch((err: unknown) => {
console.error('[session.save] persist error:', err);
});
}
function persistMessages(session: AgentSession): void {
// Debounce: batch message saves every 2 seconds
const existing = msgPersistTimers.get(session.sessionId);
if (existing) clearTimeout(existing);
const timer = setTimeout(() => {
msgPersistTimers.delete(session.sessionId);
const msgs = session.messages.map((m) => ({
sessionId: session.sessionId,
msgId: m.id,
role: m.role,
content: m.content,
toolName: m.toolName,
toolInput: m.toolInput,
timestamp: m.timestamp,
}));
if (msgs.length === 0) return;
appRpc.request['session.messages.save']({ messages: msgs }).catch((err: unknown) => {
console.error('[session.messages.save] persist error:', err);
});
}, 2000);
msgPersistTimers.set(session.sessionId, timer);
}
// ── RPC event listeners (registered once) ────────────────────────────────────
let listenersRegistered = false;
@ -104,6 +152,7 @@ function ensureListeners() {
if (converted.length > 0) {
session.messages = [...session.messages, ...converted];
persistMessages(session);
}
});
@ -119,8 +168,18 @@ function ensureListeners() {
session.status = normalizeStatus(payload.status);
if (payload.error) session.error = payload.error;
// Persist on every status change
persistSession(session);
// Schedule cleanup after done/error (Fix #2)
if (session.status === 'done' || session.status === 'error') {
// Flush any pending message persistence immediately
const pendingTimer = msgPersistTimers.get(session.sessionId);
if (pendingTimer) {
clearTimeout(pendingTimer);
msgPersistTimers.delete(session.sessionId);
}
persistMessages(session);
scheduleCleanup(session.sessionId, session.projectId);
}
});
@ -427,5 +486,57 @@ export function clearSession(projectId: string): void {
}
}
/**
* Load the last session for a project from SQLite (for restart recovery).
* Restores session state + messages into the reactive store.
* Only restores done/error sessions (running sessions are gone after restart).
*/
export async function loadLastSession(projectId: string): Promise<boolean> {
ensureListeners();
try {
const { session } = await appRpc.request['session.load']({ projectId });
if (!session) return false;
// Only restore completed sessions (running sessions can't be resumed)
if (session.status !== 'done' && session.status !== 'error') return false;
// Load messages for this session
const { messages: storedMsgs } = await appRpc.request['session.messages.load']({
sessionId: session.sessionId,
});
const restoredMessages: AgentMessage[] = storedMsgs.map((m: {
msgId: string; role: string; content: string;
toolName?: string; toolInput?: string; timestamp: number;
}) => ({
id: m.msgId,
role: m.role as MsgRole,
content: m.content,
toolName: m.toolName,
toolInput: m.toolInput,
timestamp: m.timestamp,
}));
sessions[session.sessionId] = {
sessionId: session.sessionId,
projectId: session.projectId,
provider: session.provider,
status: normalizeStatus(session.status),
messages: restoredMessages,
costUsd: session.costUsd,
inputTokens: session.inputTokens,
outputTokens: session.outputTokens,
model: session.model,
error: session.error,
};
projectSessionMap.set(projectId, session.sessionId);
return true;
} catch (err) {
console.error('[loadLastSession] error:', err);
return false;
}
}
/** Initialize listeners on module load. */
ensureListeners();

View file

@ -0,0 +1,229 @@
/**
* Per-project health tracking Svelte 5 runes.
*
* Tracks activity state, burn rate (5-min EMA from cost snapshots),
* context pressure (tokens / model limit), and attention scoring.
* 5-second tick timer drives derived state updates.
*/
// ── Types ────────────────────────────────────────────────────────────────────
export type ActivityState = 'inactive' | 'running' | 'idle' | 'stalled';
export interface ProjectHealth {
projectId: string;
activityState: ActivityState;
activeTool: string | null;
idleDurationMs: number;
burnRatePerHour: number;
contextPressure: number | null;
fileConflictCount: number;
attentionScore: number;
attentionReason: string | null;
}
// ── Configuration ────────────────────────────────────────────────────────────
const DEFAULT_STALL_THRESHOLD_MS = 15 * 60 * 1000; // 15 minutes
const TICK_INTERVAL_MS = 5_000;
const BURN_RATE_WINDOW_MS = 5 * 60 * 1000; // 5-minute window
const DEFAULT_CONTEXT_LIMIT = 200_000;
// ── Internal state ───────────────────────────────────────────────────────────
interface ProjectTracker {
projectId: string;
lastActivityTs: number;
lastToolName: string | null;
toolInFlight: boolean;
costSnapshots: Array<[number, number]>; // [timestamp, costUsd]
totalTokens: number;
totalCost: number;
status: 'inactive' | 'running' | 'idle' | 'done' | 'error';
}
let trackers = $state<Map<string, ProjectTracker>>(new Map());
let tickTs = $state<number>(Date.now());
let tickInterval: ReturnType<typeof setInterval> | null = null;
// ── Attention scoring (pure) ─────────────────────────────────────────────────
function scoreAttention(
activityState: ActivityState,
contextPressure: number | null,
fileConflictCount: number,
status: string,
): { score: number; reason: string | null } {
if (status === 'error') return { score: 90, reason: 'Agent error' };
if (activityState === 'stalled') return { score: 100, reason: 'Agent stalled (>15 min)' };
if (contextPressure !== null && contextPressure > 0.9) return { score: 80, reason: 'Context >90%' };
if (fileConflictCount > 0) return { score: 70, reason: `${fileConflictCount} file conflict(s)` };
if (contextPressure !== null && contextPressure > 0.75) return { score: 40, reason: 'Context >75%' };
return { score: 0, reason: null };
}
// ── Burn rate calculation ────────────────────────────────────────────────────
function computeBurnRate(snapshots: Array<[number, number]>): number {
if (snapshots.length < 2) return 0;
const windowStart = Date.now() - BURN_RATE_WINDOW_MS;
const recent = snapshots.filter(([ts]) => ts >= windowStart);
if (recent.length < 2) return 0;
const first = recent[0];
const last = recent[recent.length - 1];
const elapsedHours = (last[0] - first[0]) / 3_600_000;
if (elapsedHours < 0.001) return 0;
const costDelta = last[1] - first[1];
return Math.max(0, costDelta / elapsedHours);
}
// ── Derived health per project ───────────────────────────────────────────────
function computeHealth(tracker: ProjectTracker, now: number): ProjectHealth {
let activityState: ActivityState;
let idleDurationMs = 0;
let activeTool: string | null = null;
if (tracker.status === 'inactive' || tracker.status === 'done' || tracker.status === 'error') {
activityState = 'inactive';
} else if (tracker.toolInFlight) {
activityState = 'running';
activeTool = tracker.lastToolName;
} else {
idleDurationMs = now - tracker.lastActivityTs;
activityState = idleDurationMs >= DEFAULT_STALL_THRESHOLD_MS ? 'stalled' : 'idle';
}
let contextPressure: number | null = null;
if (tracker.totalTokens > 0) {
contextPressure = Math.min(1, tracker.totalTokens / DEFAULT_CONTEXT_LIMIT);
}
const burnRatePerHour = computeBurnRate(tracker.costSnapshots);
const attention = scoreAttention(activityState, contextPressure, 0, tracker.status);
return {
projectId: tracker.projectId,
activityState,
activeTool,
idleDurationMs,
burnRatePerHour,
contextPressure,
fileConflictCount: 0,
attentionScore: attention.score,
attentionReason: attention.reason,
};
}
// ── Public API ───────────────────────────────────────────────────────────────
/** Register a project for health tracking. */
export function trackProject(projectId: string): void {
if (trackers.has(projectId)) return;
trackers.set(projectId, {
projectId,
lastActivityTs: Date.now(),
lastToolName: null,
toolInFlight: false,
costSnapshots: [],
totalTokens: 0,
totalCost: 0,
status: 'inactive',
});
if (!tickInterval) startHealthTick();
}
/** Record activity (call on every agent message). */
export function recordActivity(projectId: string, toolName?: string): void {
const t = trackers.get(projectId);
if (!t) return;
t.lastActivityTs = Date.now();
t.status = 'running';
if (toolName !== undefined) {
t.lastToolName = toolName;
t.toolInFlight = true;
}
if (!tickInterval) startHealthTick();
}
/** Record tool completion. */
export function recordToolDone(projectId: string): void {
const t = trackers.get(projectId);
if (!t) return;
t.lastActivityTs = Date.now();
t.toolInFlight = false;
}
/** Record a token/cost snapshot for burn rate calculation. */
export function recordTokenSnapshot(projectId: string, totalTokens: number, costUsd: number): void {
const t = trackers.get(projectId);
if (!t) return;
const now = Date.now();
t.totalTokens = totalTokens;
t.totalCost = costUsd;
t.costSnapshots.push([now, costUsd]);
const cutoff = now - BURN_RATE_WINDOW_MS * 2;
t.costSnapshots = t.costSnapshots.filter(([ts]) => ts > cutoff);
}
/** Update project status. */
export function setProjectStatus(projectId: string, status: 'running' | 'idle' | 'done' | 'error'): void {
const t = trackers.get(projectId);
if (t) t.status = status;
}
/** Get health for a single project (reactive via tickTs). */
export function getProjectHealth(projectId: string): ProjectHealth | null {
const now = tickTs;
const t = trackers.get(projectId);
if (!t) return null;
return computeHealth(t, now);
}
/** Get top N items needing attention. */
export function getAttentionQueue(limit = 5): ProjectHealth[] {
const now = tickTs;
const results: ProjectHealth[] = [];
for (const t of trackers.values()) {
const h = computeHealth(t, now);
if (h.attentionScore > 0) results.push(h);
}
results.sort((a, b) => b.attentionScore - a.attentionScore);
return results.slice(0, limit);
}
/** Get aggregate stats across all tracked projects. */
export function getHealthAggregates(): {
running: number;
idle: number;
stalled: number;
totalBurnRatePerHour: number;
} {
const now = tickTs;
let running = 0, idle = 0, stalled = 0, totalBurnRatePerHour = 0;
for (const t of trackers.values()) {
const h = computeHealth(t, now);
if (h.activityState === 'running') running++;
else if (h.activityState === 'idle') idle++;
else if (h.activityState === 'stalled') stalled++;
totalBurnRatePerHour += h.burnRatePerHour;
}
return { running, idle, stalled, totalBurnRatePerHour };
}
/** Start the health tick timer. */
function startHealthTick(): void {
if (tickInterval) return;
tickInterval = setInterval(() => {
tickTs = Date.now();
}, TICK_INTERVAL_MS);
}
/** Stop the health tick timer. */
export function stopHealthTick(): void {
if (tickInterval) {
clearInterval(tickInterval);
tickInterval = null;
}
}

View file

@ -0,0 +1,287 @@
/**
* Plugin Host Web Worker sandbox for Electrobun plugins.
*
* Each plugin runs in a dedicated Web Worker with no DOM/IPC access.
* Communication: Main <-> Worker via postMessage.
* Permission-gated API (messages, events, notifications, palette).
* On unload, Worker is terminated all plugin state destroyed.
*/
import { appRpc } from './rpc.ts';
// ── Types ────────────────────────────────────────────────────────────────────
export interface PluginMeta {
id: string;
name: string;
version: string;
description: string;
main: string;
permissions: string[];
}
interface LoadedPlugin {
meta: PluginMeta;
worker: Worker;
callbacks: Map<string, () => void>;
eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }>;
cleanup: () => void;
}
type PluginCommandCallback = () => void;
// ── State ────────────────────────────────────────────────────────────────────
const loadedPlugins = new Map<string, LoadedPlugin>();
// External command/event registries (set by plugin-store)
let commandRegistry: ((pluginId: string, label: string, callback: PluginCommandCallback) => void) | null = null;
let commandRemover: ((pluginId: string) => void) | null = null;
let eventBus: { on: (event: string, handler: (data: unknown) => void) => void; off: (event: string, handler: (data: unknown) => void) => void } | null = null;
/** Wire up external registries (called by plugin-store on init). */
export function setPluginRegistries(opts: {
addCommand: (pluginId: string, label: string, cb: PluginCommandCallback) => void;
removeCommands: (pluginId: string) => void;
eventBus: { on: (e: string, h: (d: unknown) => void) => void; off: (e: string, h: (d: unknown) => void) => void };
}): void {
commandRegistry = opts.addCommand;
commandRemover = opts.removeCommands;
eventBus = opts.eventBus;
}
// ── Worker script builder ────────────────────────────────────────────────────
function buildWorkerScript(): string {
return `
"use strict";
const _callbacks = new Map();
let _callbackId = 0;
function _nextCbId() { return '__cb_' + (++_callbackId); }
const _pending = new Map();
let _rpcId = 0;
function _rpc(method, args) {
return new Promise((resolve, reject) => {
const id = '__rpc_' + (++_rpcId);
_pending.set(id, { resolve, reject });
self.postMessage({ type: 'rpc', id, method, args });
});
}
self.onmessage = function(e) {
const msg = e.data;
if (msg.type === 'init') {
const permissions = msg.permissions || [];
const meta = msg.meta;
const api = { meta: Object.freeze(meta) };
if (permissions.includes('palette')) {
api.palette = {
registerCommand(label, callback) {
if (typeof label !== 'string' || !label.trim()) throw new Error('Command label must be non-empty string');
if (typeof callback !== 'function') throw new Error('Command callback must be a function');
const cbId = _nextCbId();
_callbacks.set(cbId, callback);
self.postMessage({ type: 'palette-register', label, callbackId: cbId });
},
};
}
if (permissions.includes('notifications')) {
api.notifications = {
show(message) {
self.postMessage({ type: 'notification', message: String(message) });
},
};
}
if (permissions.includes('messages')) {
api.messages = {
list() { return _rpc('messages.list', {}); },
};
}
if (permissions.includes('events')) {
api.events = {
on(event, callback) {
if (typeof event !== 'string' || typeof callback !== 'function') {
throw new Error('events.on requires (string, function)');
}
const cbId = _nextCbId();
_callbacks.set(cbId, callback);
self.postMessage({ type: 'event-on', event, callbackId: cbId });
},
off(event) {
self.postMessage({ type: 'event-off', event });
},
};
}
Object.freeze(api);
try {
const fn = (0, eval)('(function(agor) { "use strict"; ' + msg.code + '\\n})');
fn(api);
self.postMessage({ type: 'loaded' });
} catch (err) {
self.postMessage({ type: 'error', message: String(err) });
}
}
if (msg.type === 'invoke-callback') {
const cb = _callbacks.get(msg.callbackId);
if (cb) {
try { cb(msg.data); }
catch (err) { self.postMessage({ type: 'callback-error', callbackId: msg.callbackId, message: String(err) }); }
}
}
if (msg.type === 'rpc-result') {
const pending = _pending.get(msg.id);
if (pending) {
_pending.delete(msg.id);
if (msg.error) pending.reject(new Error(msg.error));
else pending.resolve(msg.result);
}
}
};
`;
}
let workerBlobUrl: string | null = null;
function getWorkerBlobUrl(): string {
if (!workerBlobUrl) {
const blob = new Blob([buildWorkerScript()], { type: 'application/javascript' });
workerBlobUrl = URL.createObjectURL(blob);
}
return workerBlobUrl;
}
// ── Public API ───────────────────────────────────────────────────────────────
/**
* Load and execute a plugin in a Web Worker sandbox.
* Reads plugin code via RPC from Bun process.
*/
export async function loadPlugin(meta: PluginMeta): Promise<void> {
if (loadedPlugins.has(meta.id)) {
console.warn(`Plugin '${meta.id}' is already loaded`);
return;
}
// Validate permissions
const validPerms = new Set(['palette', 'notifications', 'messages', 'events']);
for (const p of meta.permissions) {
if (!validPerms.has(p)) {
throw new Error(`Plugin '${meta.id}' requests unknown permission: ${p}`);
}
}
// Read plugin code via RPC
let code: string;
try {
const res = await appRpc.request['plugin.readFile']({ pluginId: meta.id, filePath: meta.main });
if (!res.ok) throw new Error(res.error ?? 'Failed to read plugin file');
code = res.content;
} catch (e) {
throw new Error(`Failed to read plugin '${meta.id}' entry '${meta.main}': ${e}`);
}
const worker = new Worker(getWorkerBlobUrl(), { type: 'classic' });
const callbacks = new Map<string, () => void>();
const eventSubscriptions: Array<{ event: string; handler: (data: unknown) => void }> = [];
await new Promise<void>((resolve, reject) => {
worker.onmessage = (e) => {
const msg = e.data;
switch (msg.type) {
case 'loaded':
resolve();
break;
case 'error':
commandRemover?.(meta.id);
for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler);
worker.terminate();
reject(new Error(`Plugin '${meta.id}' failed: ${msg.message}`));
break;
case 'palette-register': {
const cbId = msg.callbackId as string;
const invoke = () => worker.postMessage({ type: 'invoke-callback', callbackId: cbId });
callbacks.set(cbId, invoke);
commandRegistry?.(meta.id, msg.label, invoke);
break;
}
case 'notification':
console.log(`[plugin:${meta.id}] notification:`, msg.message);
break;
case 'event-on': {
const cbId = msg.callbackId as string;
const handler = (data: unknown) => {
worker.postMessage({ type: 'invoke-callback', callbackId: cbId, data });
};
eventSubscriptions.push({ event: msg.event, handler });
eventBus?.on(msg.event, handler);
break;
}
case 'event-off': {
const idx = eventSubscriptions.findIndex(s => s.event === msg.event);
if (idx >= 0) {
eventBus?.off(eventSubscriptions[idx].event, eventSubscriptions[idx].handler);
eventSubscriptions.splice(idx, 1);
}
break;
}
case 'callback-error':
console.error(`Plugin '${meta.id}' callback error:`, msg.message);
break;
}
};
worker.onerror = (err) => reject(new Error(`Plugin '${meta.id}' worker error: ${err.message}`));
worker.postMessage({
type: 'init',
code,
permissions: meta.permissions,
meta: { id: meta.id, name: meta.name, version: meta.version, description: meta.description },
});
});
const cleanup = () => {
commandRemover?.(meta.id);
for (const sub of eventSubscriptions) eventBus?.off(sub.event, sub.handler);
eventSubscriptions.length = 0;
callbacks.clear();
worker.terminate();
};
loadedPlugins.set(meta.id, { meta, worker, callbacks, eventSubscriptions, cleanup });
}
/** Unload a plugin. */
export function unloadPlugin(id: string): void {
const plugin = loadedPlugins.get(id);
if (!plugin) return;
plugin.cleanup();
loadedPlugins.delete(id);
}
/** Get all loaded plugin metas. */
export function getLoadedPlugins(): PluginMeta[] {
return Array.from(loadedPlugins.values()).map(p => p.meta);
}
/** Unload all plugins. */
export function unloadAllPlugins(): void {
for (const [id] of loadedPlugins) unloadPlugin(id);
}

View file

@ -0,0 +1,136 @@
/**
* Plugin store Svelte 5 runes.
*
* Discovers plugins from ~/.config/agor/plugins/ via RPC.
* Manages command registry (for palette integration) and event bus.
* Coordinates with plugin-host.ts for Web Worker lifecycle.
*/
import { appRpc } from './rpc.ts';
import {
loadPlugin,
unloadPlugin,
unloadAllPlugins,
getLoadedPlugins,
setPluginRegistries,
type PluginMeta,
} from './plugin-host.ts';
// ── Types ────────────────────────────────────────────────────────────────────
export interface PluginCommand {
pluginId: string;
label: string;
callback: () => void;
}
// ── State ────────────────────────────────────────────────────────────────────
let discovered = $state<PluginMeta[]>([]);
let commands = $state<PluginCommand[]>([]);
let loaded = $derived(getLoadedPlugins());
// ── Event bus (simple pub/sub) ───────────────────────────────────────────────
type EventHandler = (data: unknown) => void;
const eventListeners = new Map<string, Set<EventHandler>>();
const pluginEventBus = {
on(event: string, handler: EventHandler): void {
let set = eventListeners.get(event);
if (!set) {
set = new Set();
eventListeners.set(event, set);
}
set.add(handler);
},
off(event: string, handler: EventHandler): void {
eventListeners.get(event)?.delete(handler);
},
emit(event: string, data: unknown): void {
const set = eventListeners.get(event);
if (!set) return;
for (const handler of set) {
try { handler(data); }
catch (err) { console.error(`[plugin-event] ${event}:`, err); }
}
},
};
// ── Command registry ─────────────────────────────────────────────────────────
function addPluginCommand(pluginId: string, label: string, callback: () => void): void {
commands = [...commands, { pluginId, label, callback }];
}
function removePluginCommands(pluginId: string): void {
commands = commands.filter(c => c.pluginId !== pluginId);
}
// Wire up registries to plugin-host
setPluginRegistries({
addCommand: addPluginCommand,
removeCommands: removePluginCommands,
eventBus: pluginEventBus,
});
// ── Public API ───────────────────────────────────────────────────────────────
/** Discover plugins from ~/.config/agor/plugins/ via RPC. */
export async function discoverPlugins(): Promise<PluginMeta[]> {
try {
const res = await appRpc.request['plugin.discover']({});
discovered = res.plugins ?? [];
return discovered;
} catch (err) {
console.error('[plugin-store] discover error:', err);
discovered = [];
return [];
}
}
/** Load a discovered plugin by id. */
export async function loadPluginById(pluginId: string): Promise<void> {
const meta = discovered.find(p => p.id === pluginId);
if (!meta) throw new Error(`Plugin not found: ${pluginId}`);
await loadPlugin(meta);
}
/** Unload a plugin by id. */
export function unloadPluginById(pluginId: string): void {
unloadPlugin(pluginId);
removePluginCommands(pluginId);
}
/** Load all discovered plugins. */
export async function loadAllPlugins(): Promise<void> {
const plugins = await discoverPlugins();
for (const meta of plugins) {
try {
await loadPlugin(meta);
} catch (err) {
console.error(`[plugin-store] Failed to load '${meta.id}':`, err);
}
}
}
/** Unload all plugins. */
export function unloadAll(): void {
unloadAllPlugins();
commands = [];
}
/** Get discovered plugins (reactive). */
export function getDiscoveredPlugins(): PluginMeta[] {
return discovered;
}
/** Get registered commands (reactive, for palette integration). */
export function getPluginCommands(): PluginCommand[] {
return commands;
}
/** Emit an event to all plugins listening for it. */
export function emitPluginEvent(event: string, data: unknown): void {
pluginEventBus.emit(event, data);
}