agent-orchestrator/ui-electrobun/src/mainview/ProjectCard.svelte
Hibryda 8e756d3523 feat(electrobun): final 5% — full integration, real data, polish
1. Claude CLI: additionalDirectories + worktreeName passthrough
2. Agent-store: reads settings (default_cwd, provider model, permission)
3. Project hydration: SQLite replaces hardcoded PROJECTS, add/remove UI
4. Group hydration: SQLite groups, add/delete in sidebar
5. Terminal auto-spawn: reads default_cwd from settings
6. Context tab: real tokens from agent-store, file refs, turn count
7. Memory tab: Memora DB integration (read-only, graceful if missing)
8. Docs tab: markdown viewer (files.list + files.read + inline renderer)
9. SSH tab: CRUD connections, spawn PTY with ssh command
10. Error handling: global unhandledrejection → toast notifications
11. Notifications: agent done/error/stall → toasts, 15min stall timer
12. Command palette: all 18 commands (was 10)

+1,198 lines, 13 files. Electrobun now 100% feature-complete vs Tauri v3.
2026-03-22 02:02:54 +01:00

843 lines
22 KiB
Svelte

<script lang="ts">
import AgentPane from './AgentPane.svelte';
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 DocsTab from './DocsTab.svelte';
import SshTab from './SshTab.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' | 'comms' | 'tasks';
interface Props {
id: string;
name: string;
cwd: string;
accent: string;
provider?: string;
profile?: string;
model?: string;
contextPct?: number;
burnRate?: number;
blinkVisible?: boolean;
/** Worktree branch name — set when this is a clone card. */
worktreeBranch?: string;
/** ID of parent project — set when this is a clone card. */
cloneOf?: string;
/** Max clones reached for this project. */
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 {
id,
name,
cwd,
accent,
provider = 'claude',
profile,
model = 'claude-opus-4-5',
contextPct = 0,
burnRate = 0,
blinkVisible = true,
worktreeBranch,
cloneOf,
clonesAtMax = false,
onClone,
groupId = 'dev',
}: Props = $props();
// ── Agent session (reactive from store) ──────────────────────────
let session = $derived(getSession(id));
let agentStatus: AgentStatus = $derived(session?.status ?? 'idle');
let agentMessages: AgentMessage[] = $derived(session?.messages ?? []);
let agentCost = $derived(session?.costUsd ?? 0);
let agentTokens = $derived((session?.inputTokens ?? 0) + (session?.outputTokens ?? 0));
let agentModel = $derived(session?.model ?? model);
let agentInputTokens = $derived(session?.inputTokens ?? 0);
let agentOutputTokens = $derived(session?.outputTokens ?? 0);
// Context limit per model (approximate)
const MODEL_LIMITS: Record<string, number> = {
'claude-opus-4-5': 200000,
'claude-sonnet-4-5': 200000,
'claude-haiku-4-5': 200000,
'gpt-5.4': 128000,
'qwen3:8b': 32000,
};
let contextLimit = $derived(MODEL_LIMITS[agentModel] ?? 200000);
let computedContextPct = $derived(
agentInputTokens > 0 ? Math.min(100, Math.round((agentInputTokens / contextLimit) * 100)) : (contextPct ?? 0)
);
// File references from tool_call messages
let fileRefs = $derived(
agentMessages
.filter((m) => m.role === 'tool-call' && m.toolPath)
.map((m) => m.toolPath!)
.filter((p, i, arr) => arr.indexOf(p) === i)
.slice(0, 20)
);
let turnCount = $derived(agentMessages.filter((m) => m.role === 'user' || m.role === 'assistant').length);
// ── Demo messages (fallback when no real session) ────────────────
const demoMessages: AgentMessage[] = [];
let displayMessages = $derived(agentMessages.length > 0 ? agentMessages : demoMessages);
// ── Clone dialog state ──────────────────────────────────────────
let showCloneDialog = $state(false);
let cloneBranchName = $state('');
let cloneError = $state('');
const BRANCH_RE = /^[a-zA-Z0-9/_.-]+$/;
function openCloneDialog() {
cloneBranchName = '';
cloneError = '';
showCloneDialog = true;
}
function submitClone() {
if (!BRANCH_RE.test(cloneBranchName)) {
cloneError = 'Use only letters, numbers, /, _, -, .';
return;
}
onClone?.(id, cloneBranchName);
showCloneDialog = false;
}
let activeTab = $state<ProjectTab>('model');
// Track which project tabs have been activated (PERSISTED-LAZY pattern)
let activatedTabs = $state<Set<ProjectTab>>(new Set(['model']));
const ALL_TABS: ProjectTab[] = ['model', 'docs', 'context', 'files', 'ssh', 'memory', 'comms', 'tasks'];
// ── Load last session on mount ──────────────────────────────────────
$effect(() => {
loadLastSession(id);
});
function setTab(tab: ProjectTab) {
activeTab = tab;
activatedTabs = new Set([...activatedTabs, tab]);
}
function handleSend(text: string) {
if (hasSession(id)) {
// Session exists — send follow-up prompt
sendPrompt(id, text).catch((err) => {
console.error('[agent.prompt] error:', err);
});
} else {
// No session — start a new agent
startAgent(id, provider, text, { cwd, model }).catch((err) => {
console.error('[agent.start] error:', err);
});
}
}
function handleStop() {
stopAgent(id).catch((err) => {
console.error('[agent.stop] error:', err);
});
}
</script>
<article
class="project-card"
class:is-clone={!!cloneOf}
style="--accent: {accent}"
aria-label="Project: {name}{cloneOf ? ' (worktree clone)' : ''}"
>
<!-- Header -->
<header class="project-header">
<div class="status-dot-wrap" aria-label="Status: {agentStatus}">
<div
class="status-dot {agentStatus}"
class:blink-off={agentStatus === 'running' && !blinkVisible}
role="img"
aria-label={agentStatus}
></div>
</div>
<span class="project-name" title={name}>{name}</span>
<span class="project-cwd" title={cwd}>{cwd}</span>
{#if worktreeBranch}
<span class="wt-badge" title="Worktree branch: {worktreeBranch}">
WT · {worktreeBranch}
</span>
{/if}
<span class="provider-badge" title="Provider: {provider}">{provider}</span>
{#if profile}
<span class="profile-badge" title="Profile: {profile}">{profile}</span>
{/if}
{#if contextPct > 50}
<span
class="ctx-badge"
class:ctx-warn={contextPct >= 75}
class:ctx-danger={contextPct >= 90}
title="Context window {contextPct}% used"
>{contextPct}%</span>
{/if}
{#if burnRate > 0}
<span class="burn-badge" title="Burn rate">${burnRate.toFixed(2)}/hr</span>
{/if}
<!-- Clone button (only on non-clone cards) -->
{#if !cloneOf && onClone}
<button
class="clone-btn"
onclick={openCloneDialog}
disabled={clonesAtMax}
title={clonesAtMax ? 'Maximum 3 clones reached' : 'Clone into git worktree'}
aria-label="Clone project into worktree"
>
<!-- Fork / branch SVG icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="6" y1="3" x2="6" y2="15"/>
<circle cx="18" cy="6" r="3"/>
<circle cx="6" cy="18" r="3"/>
<path d="M18 9a9 9 0 0 1-9 9"/>
</svg>
</button>
{/if}
</header>
<!-- Clone dialog (inline, shown above tab bar) -->
{#if showCloneDialog}
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
<div
class="clone-dialog"
role="dialog"
aria-label="Create worktree clone"
onkeydown={(e) => { if (e.key === 'Escape') showCloneDialog = false; }}
>
<span class="clone-dialog-label">Branch name</span>
<input
class="clone-dialog-input"
type="text"
placeholder="feature/my-branch"
bind:value={cloneBranchName}
onkeydown={(e) => { if (e.key === 'Enter') submitClone(); }}
autofocus
aria-label="New branch name for worktree"
/>
{#if cloneError}
<span class="clone-dialog-error">{cloneError}</span>
{/if}
<div class="clone-dialog-actions">
<button class="clone-dialog-cancel" onclick={() => showCloneDialog = false}>Cancel</button>
<button class="clone-dialog-submit" onclick={submitClone}>Create</button>
</div>
</div>
{/if}
<!-- Project tab bar -->
<div class="tab-bar" role="tablist" aria-label="{name} tabs">
{#each ALL_TABS as tab}
<button
class="tab-btn"
class:active={activeTab === tab}
role="tab"
aria-selected={activeTab === tab}
aria-controls="tabpanel-{id}-{tab}"
onclick={() => setTab(tab)}
>
{tab.charAt(0).toUpperCase() + tab.slice(1)}
</button>
{/each}
</div>
<!-- Tab content: display:flex/none to keep agent session mounted -->
<div class="tab-content">
<!-- Model tab: agent pane + terminal tabs — always mounted -->
<div
id="tabpanel-{id}-model"
class="tab-pane"
style:display={activeTab === 'model' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Model"
>
<AgentPane
messages={displayMessages}
status={agentStatus}
costUsd={agentCost}
tokens={agentTokens}
model={agentModel}
{provider}
{profile}
{contextPct}
{burnRate}
onSend={handleSend}
onStop={handleStop}
/>
<TerminalTabs projectId={id} {accent} {cwd} />
</div>
<!-- Docs tab -->
{#if activatedTabs.has('docs')}
<div
id="tabpanel-{id}-docs"
class="tab-pane"
style:display={activeTab === 'docs' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Docs"
>
<DocsTab {cwd} />
</div>
{/if}
<!-- Context tab -->
{#if activatedTabs.has('context')}
<div
id="tabpanel-{id}-context"
class="tab-pane"
style:display={activeTab === 'context' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Context"
>
<div class="context-pane">
<div class="ctx-stats-row">
<div class="ctx-stat">
<span class="ctx-stat-label">Input tokens</span>
<span class="ctx-stat-value">{agentInputTokens.toLocaleString()}</span>
</div>
<div class="ctx-stat">
<span class="ctx-stat-label">Output tokens</span>
<span class="ctx-stat-value">{agentOutputTokens.toLocaleString()}</span>
</div>
<div class="ctx-stat">
<span class="ctx-stat-label">Context</span>
<span class="ctx-stat-value">{computedContextPct}%</span>
</div>
<div class="ctx-stat">
<span class="ctx-stat-label">Model</span>
<span class="ctx-stat-value">{agentModel}</span>
</div>
<div class="ctx-stat">
<span class="ctx-stat-label">Turns</span>
<span class="ctx-stat-value">{turnCount}</span>
</div>
</div>
<div class="ctx-meter-wrap" title="{computedContextPct}% of {contextLimit.toLocaleString()} tokens">
<div class="ctx-meter-bar" style:width="{computedContextPct}%"
class:meter-warn={computedContextPct >= 75}
class:meter-danger={computedContextPct >= 90}
></div>
</div>
{#if fileRefs.length > 0}
<div class="ctx-turn-list">
<div class="ctx-section-label">File references ({fileRefs.length})</div>
{#each fileRefs as ref}
<div class="ctx-turn-row">
<span class="ctx-turn-preview" title={ref}>{ref}</span>
</div>
{/each}
</div>
{/if}
<div class="ctx-turn-list">
<div class="ctx-section-label">Recent turns</div>
{#each displayMessages.slice(-10) as msg}
<div class="ctx-turn-row">
<span class="ctx-turn-role ctx-role-{msg.role}">{msg.role}</span>
<span class="ctx-turn-preview">{msg.content.slice(0, 60)}{msg.content.length > 60 ? '...' : ''}</span>
</div>
{/each}
</div>
</div>
</div>
{/if}
<!-- Files tab -->
{#if activatedTabs.has('files')}
<div
id="tabpanel-{id}-files"
class="tab-pane"
style:display={activeTab === 'files' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Files"
>
<FileBrowser {cwd} />
</div>
{/if}
<!-- SSH tab -->
{#if activatedTabs.has('ssh')}
<div
id="tabpanel-{id}-ssh"
class="tab-pane"
style:display={activeTab === 'ssh' ? 'flex' : 'none'}
role="tabpanel"
aria-label="SSH"
>
<SshTab projectId={id} />
</div>
{/if}
<!-- Memory tab -->
{#if activatedTabs.has('memory')}
<div
id="tabpanel-{id}-memory"
class="tab-pane"
style:display={activeTab === 'memory' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Memory"
>
<MemoryTab />
</div>
{/if}
<!-- Comms tab (inter-agent messaging) -->
{#if activatedTabs.has('comms')}
<div
id="tabpanel-{id}-comms"
class="tab-pane"
style:display={activeTab === 'comms' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Comms"
>
<CommsTab {groupId} agentId={id} />
</div>
{/if}
<!-- Tasks tab (kanban board) -->
{#if activatedTabs.has('tasks')}
<div
id="tabpanel-{id}-tasks"
class="tab-pane"
style:display={activeTab === 'tasks' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Tasks"
>
<TaskBoardTab {groupId} agentId={id} />
</div>
{/if}
</div>
</article>
<style>
.project-card {
background: var(--ctp-base);
border: 1px solid var(--ctp-surface0);
border-radius: 0.5rem;
display: flex;
flex-direction: column;
overflow: hidden;
min-height: 0;
position: relative;
}
/* Accent stripe on left edge */
.project-card::before {
content: '';
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 3px;
background: var(--accent, var(--ctp-mauve));
border-radius: 0.5rem 0 0 0.5rem;
}
/* Worktree clone: accent top border instead of left stripe */
.project-card.is-clone {
border-top: 2px solid var(--accent, var(--ctp-mauve));
}
.project-card.is-clone::before {
display: none;
}
/* Header */
.project-header {
height: 2.5rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
display: flex;
align-items: center;
gap: 0.375rem;
padding: 0 0.625rem 0 0.875rem;
flex-shrink: 0;
overflow: hidden;
}
.status-dot-wrap {
flex-shrink: 0;
width: 0.625rem;
height: 0.625rem;
position: relative;
}
.status-dot {
width: 100%;
height: 100%;
border-radius: 50%;
background: var(--ctp-overlay0);
}
.status-dot.running { background: var(--ctp-green); }
.status-dot.idle { background: var(--ctp-overlay1); }
.status-dot.done { background: var(--ctp-green); }
.status-dot.error { background: var(--ctp-red); }
.status-dot.stalled { background: var(--ctp-peach); }
.status-dot.blink-off { opacity: 0.3; }
.project-name {
font-weight: 600;
color: var(--ctp-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex-shrink: 1;
min-width: 0;
}
.project-cwd {
font-size: 0.6875rem;
color: var(--ctp-subtext0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
direction: rtl;
max-width: 8rem;
flex-shrink: 2;
}
/* Badges */
.provider-badge,
.profile-badge,
.ctx-badge,
.burn-badge {
flex-shrink: 0;
padding: 0.1rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
}
.provider-badge {
background: color-mix(in srgb, var(--accent, var(--ctp-mauve)) 15%, transparent);
color: var(--accent, var(--ctp-mauve));
}
.profile-badge {
background: color-mix(in srgb, var(--ctp-blue) 12%, transparent);
color: var(--ctp-blue);
}
.ctx-badge {
background: color-mix(in srgb, var(--ctp-yellow) 15%, transparent);
color: var(--ctp-yellow);
}
.ctx-badge.ctx-warn { color: var(--ctp-peach); background: color-mix(in srgb, var(--ctp-peach) 15%, transparent); }
.ctx-badge.ctx-danger { color: var(--ctp-red); background: color-mix(in srgb, var(--ctp-red) 15%, transparent); }
.burn-badge {
background: color-mix(in srgb, var(--ctp-peach) 10%, transparent);
color: var(--ctp-peach);
}
/* Worktree badge */
.wt-badge {
flex-shrink: 0;
padding: 0.1rem 0.375rem;
border-radius: 0.25rem;
font-size: 0.6rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
white-space: nowrap;
background: color-mix(in srgb, var(--accent, var(--ctp-mauve)) 20%, transparent);
color: var(--accent, var(--ctp-mauve));
border: 1px solid color-mix(in srgb, var(--accent, var(--ctp-mauve)) 40%, transparent);
}
/* Clone button */
.clone-btn {
flex-shrink: 0;
width: 1.5rem;
height: 1.5rem;
background: transparent;
border: none;
color: var(--ctp-overlay1);
cursor: pointer;
border-radius: 0.25rem;
display: flex;
align-items: center;
justify-content: center;
padding: 0;
transition: background 0.12s, color 0.12s;
margin-left: auto;
}
.clone-btn:hover:not(:disabled) {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.clone-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.clone-btn svg {
width: 0.875rem;
height: 0.875rem;
}
/* Inline clone dialog */
.clone-dialog {
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;
flex-wrap: wrap;
}
.clone-dialog-label {
font-size: 0.75rem;
color: var(--ctp-subtext0);
flex-shrink: 0;
}
.clone-dialog-input {
flex: 1;
min-width: 8rem;
height: 1.625rem;
background: var(--ctp-surface0);
border: 1px solid var(--ctp-surface1);
border-radius: 0.25rem;
color: var(--ctp-text);
font-family: var(--term-font-family);
font-size: 0.75rem;
padding: 0 0.375rem;
outline: none;
}
.clone-dialog-input:focus { border-color: var(--ctp-mauve); }
.clone-dialog-error {
width: 100%;
font-size: 0.6875rem;
color: var(--ctp-red);
}
.clone-dialog-actions {
display: flex;
gap: 0.25rem;
flex-shrink: 0;
}
.clone-dialog-cancel,
.clone-dialog-submit {
padding: 0.125rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.75rem;
font-family: var(--ui-font-family);
cursor: pointer;
transition: background 0.1s, border-color 0.1s;
}
.clone-dialog-cancel {
background: transparent;
border: 1px solid var(--ctp-surface1);
color: var(--ctp-subtext0);
}
.clone-dialog-cancel:hover {
background: var(--ctp-surface0);
color: var(--ctp-text);
}
.clone-dialog-submit {
background: color-mix(in srgb, var(--ctp-mauve) 20%, transparent);
border: 1px solid var(--ctp-mauve);
color: var(--ctp-mauve);
}
.clone-dialog-submit:hover {
background: color-mix(in srgb, var(--ctp-mauve) 35%, transparent);
}
/* Tab bar */
.tab-bar {
height: 2rem;
background: var(--ctp-mantle);
border-bottom: 1px solid var(--ctp-surface0);
display: flex;
align-items: stretch;
flex-shrink: 0;
padding: 0 0.25rem;
gap: 0.125rem;
overflow-x: auto;
scrollbar-width: none;
}
.tab-bar::-webkit-scrollbar { display: none; }
.tab-btn {
padding: 0 0.625rem;
background: transparent;
border: none;
border-bottom: 2px solid transparent;
color: var(--ctp-subtext0);
font-family: var(--ui-font-family);
font-size: 0.8125rem;
cursor: pointer;
white-space: nowrap;
transition: color 0.12s, border-color 0.12s;
margin-bottom: -1px;
flex-shrink: 0;
}
.tab-btn:hover { color: var(--ctp-text); }
.tab-btn.active {
color: var(--ctp-text);
border-bottom-color: var(--accent, var(--ctp-mauve));
}
/* Tab content */
.tab-content {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
}
.tab-pane {
flex: 1;
min-height: 0;
flex-direction: column;
overflow: hidden;
}
.placeholder-pane {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
color: var(--ctp-overlay0);
font-size: 0.8125rem;
font-style: italic;
}
/* Context tab */
.context-pane {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 0.625rem;
overflow-y: auto;
flex: 1;
}
.ctx-stats-row {
display: flex;
gap: 1rem;
}
.ctx-stat {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.ctx-stat-label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ctp-overlay0);
font-weight: 600;
}
.ctx-stat-value {
font-size: 0.875rem;
font-weight: 600;
color: var(--ctp-text);
font-family: var(--term-font-family);
}
.ctx-meter-wrap {
height: 0.375rem;
background: var(--ctp-surface0);
border-radius: 0.25rem;
overflow: hidden;
}
.ctx-meter-bar {
height: 100%;
background: var(--ctp-teal);
border-radius: 0.25rem;
transition: width 0.3s;
}
.ctx-meter-bar.meter-warn { background: var(--ctp-peach); }
.ctx-meter-bar.meter-danger { background: var(--ctp-red); }
.ctx-section-label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ctp-overlay0);
font-weight: 600;
margin-bottom: 0.25rem;
}
.ctx-turn-list {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.ctx-turn-row {
display: flex;
align-items: baseline;
gap: 0.5rem;
font-size: 0.75rem;
}
.ctx-turn-role {
flex-shrink: 0;
font-size: 0.625rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
min-width: 4.5rem;
}
.ctx-role-user { color: var(--ctp-blue); }
.ctx-role-assistant { color: var(--ctp-mauve); }
.ctx-role-tool-call { color: var(--ctp-peach); }
.ctx-role-tool-result { color: var(--ctp-teal); }
.ctx-turn-preview {
color: var(--ctp-subtext0);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
}
</style>