agent-orchestrator/ui-electrobun/src/mainview/ProjectCard.svelte
Hibryda 2709600319 fix(electrobun): isolate blink state to store, prevent prop-cascade re-renders
Root cause found via bisect: blinkVisible prop changed every 500ms,
causing complete re-render of ALL ProjectCard trees (AgentPane, Terminal,
all tabs) — even display:none content is re-evaluated by Svelte 5.

Fix: blink-store.svelte.ts owns the timer. StatusDot reads directly
from store, not from parent prop. No prop cascades.

Also: replaced $derived with .filter()/.map() (creates new arrays)
with plain functions in ProjectCard to prevent reactive loops.
2026-03-24 12:05:39 +01:00

826 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 StatusDot from './ui/StatusDot.svelte';
import {
startAgent, stopAgent, sendPrompt, getSession, hasSession,
loadLastSession,
type AgentStatus, type AgentMessage,
} from './agent-store.svelte.ts';
import {
getActiveTab, setActiveTab, isTabActivated,
ALL_TABS, type ProjectTab,
} from './project-tabs-store.svelte.ts';
interface Props {
id: string;
name: string;
cwd: string;
accent: string;
provider?: string;
profile?: string;
model?: string;
contextPct?: number;
burnRate?: number;
// blinkVisible removed — StatusDot reads from blink-store directly
/** 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 removed
worktreeBranch,
cloneOf,
clonesAtMax = false,
onClone,
groupId = 'dev',
}: Props = $props();
// ── Agent session (reactive from store) ──────────────────────────
// CRITICAL: Use shared constants for fallback values. Creating new [] or {}
// inside $derived causes infinite loops because each evaluation returns a
// new reference → Svelte thinks the value changed → re-renders → new ref → loop.
const EMPTY_MESSAGES: AgentMessage[] = [];
let session = $derived(getSession(id));
let agentStatus: AgentStatus = $derived(session?.status ?? 'idle');
let agentMessages: AgentMessage[] = $derived(session?.messages ?? EMPTY_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
// NOTE: Do NOT use $derived with .filter()/.map() — creates new arrays every
// evaluation, which Svelte interprets as "changed" → re-render → new arrays → loop.
// Instead, compute these as plain functions called in template (no caching).
function getFileRefs(): string[] {
return agentMessages
.filter((m) => m.role === 'tool-call' && m.toolPath)
.map((m) => m.toolPath!)
.filter((p, i, arr) => arr.indexOf(p) === i)
.slice(0, 20);
}
function getTurnCount(): number {
return agentMessages.filter((m) => m.role === 'user' || m.role === 'assistant').length;
}
// ── Display messages (no $derived — avoids new ref on re-render) ──
function getDisplayMessages(): AgentMessage[] {
return agentMessages.length > 0 ? agentMessages : EMPTY_MESSAGES;
}
// ── 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;
}
// Derived from project-tabs-store for reactive reads
let activeTab = $derived(getActiveTab(id));
// ── Load last session on mount (once, not reactive) ─────────────────
import { onMount } from 'svelte';
onMount(() => { loadLastSession(id); });
function setTab(tab: ProjectTab) {
setActiveTab(id, 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}">
<StatusDot status={agentStatus} />
</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={getDisplayMessages()}
status={agentStatus}
costUsd={agentCost}
tokens={agentTokens}
model={agentModel}
{provider}
{profile}
{contextPct}
{burnRate}
onSend={handleSend}
onStop={handleStop}
/>
<TerminalTabs projectId={id} {accent} {cwd} />
</div>
<!-- Docs tab -->
<div
id="tabpanel-{id}-docs"
class="tab-pane"
style:display={activeTab === 'docs' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Docs"
>
<DocsTab {cwd} />
</div>
<!-- Context tab -->
<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">{getTurnCount()}</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 getFileRefs().length > 0}
<div class="ctx-turn-list">
<div class="ctx-section-label">File references ({getFileRefs().length})</div>
{#each getFileRefs() 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 getDisplayMessages().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>
<!-- Files tab -->
<div
id="tabpanel-{id}-files"
class="tab-pane"
style:display={activeTab === 'files' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Files"
>
<FileBrowser {cwd} />
</div>
<!-- SSH tab -->
<div
id="tabpanel-{id}-ssh"
class="tab-pane"
style:display={activeTab === 'ssh' ? 'flex' : 'none'}
role="tabpanel"
aria-label="SSH"
>
<SshTab projectId={id} />
</div>
<!-- Memory tab -->
<div
id="tabpanel-{id}-memory"
class="tab-pane"
style:display={activeTab === 'memory' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Memory"
>
<MemoryTab />
</div>
<!-- Comms tab (inter-agent messaging) -->
<div
id="tabpanel-{id}-comms"
class="tab-pane"
style:display={activeTab === 'comms' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Comms"
>
<CommsTab {groupId} agentId={id} />
</div>
<!-- Tasks tab (kanban board) -->
<div
id="tabpanel-{id}-tasks"
class="tab-pane"
style:display={activeTab === 'tasks' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Tasks"
>
<TaskBoardTab {groupId} agentId={id} />
</div>
</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;
}
.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>