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:
parent
29a3370e79
commit
252fca70df
22 changed files with 8116 additions and 227 deletions
|
|
@ -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>
|
||||
|
|
|
|||
248
ui-electrobun/src/mainview/CodeEditor.svelte
Normal file
248
ui-electrobun/src/mainview/CodeEditor.svelte
Normal 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>
|
||||
521
ui-electrobun/src/mainview/CommsTab.svelte
Normal file
521
ui-electrobun/src/mainview/CommsTab.svelte
Normal 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>
|
||||
243
ui-electrobun/src/mainview/CsvTable.svelte
Normal file
243
ui-electrobun/src/mainview/CsvTable.svelte
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
298
ui-electrobun/src/mainview/PdfViewer.svelte
Normal file
298
ui-electrobun/src/mainview/PdfViewer.svelte
Normal 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>
|
||||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
301
ui-electrobun/src/mainview/SearchOverlay.svelte
Normal file
301
ui-electrobun/src/mainview/SearchOverlay.svelte
Normal 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>
|
||||
275
ui-electrobun/src/mainview/StatusBar.svelte
Normal file
275
ui-electrobun/src/mainview/StatusBar.svelte
Normal 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>
|
||||
515
ui-electrobun/src/mainview/TaskBoardTab.svelte
Normal file
515
ui-electrobun/src/mainview/TaskBoardTab.svelte
Normal 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"
|
||||
>×</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>
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
229
ui-electrobun/src/mainview/health-store.svelte.ts
Normal file
229
ui-electrobun/src/mainview/health-store.svelte.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
287
ui-electrobun/src/mainview/plugin-host.ts
Normal file
287
ui-electrobun/src/mainview/plugin-host.ts
Normal 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);
|
||||
}
|
||||
136
ui-electrobun/src/mainview/plugin-store.svelte.ts
Normal file
136
ui-electrobun/src/mainview/plugin-store.svelte.ts
Normal 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);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue