fix(electrobun): eliminate ALL $derived from ProjectCard — 0% CPU achieved

Root cause: $derived with store getter functions (.filter/.map/?.operator)
created new object references on every evaluation. Svelte 5 interpreted
these as "changed values" → triggered re-render → re-evaluated $derived
→ new references → infinite loop (115% CPU).

Fix: replaced ALL $derived in ProjectCard with plain getter functions.
Functions are called in the template — Svelte tracks the inner $state
reads but doesn't create intermediate reactive nodes that can loop.

Verified via bisect:
- Skeleton (no ProjectCard): 0% CPU
- ProjectCard with $derived: 115% CPU
- ProjectCard with plain functions: 0% CPU (0 ticks in 5s)

Also fixed: CommandPalette $effect that read+wrote selectedIdx.
This commit is contained in:
Hibryda 2026-03-24 12:59:11 +01:00
parent 774016ba11
commit 8d09632879

View file

@ -60,18 +60,17 @@
}: 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);
// ALL agent session state as plain getter functions — NO $derived.
// $derived + store getters that return new refs = infinite loops in Svelte 5.
function getAgentSession() { return getSession(id); }
function getAgentStatus(): AgentStatus { return getAgentSession()?.status ?? 'idle'; }
function getAgentMessages(): AgentMessage[] { return getAgentSession()?.messages ?? []; }
function getAgentCost(): number { return getAgentSession()?.costUsd ?? 0; }
function getAgentTokens(): number { return (getAgentSession()?.inputTokens ?? 0) + (getAgentSession()?.outputTokens ?? 0); }
function getAgentModel(): string { return getAgentSession()?.model ?? model; }
function getAgentInputTokens(): number { return getAgentSession()?.inputTokens ?? 0; }
function getAgentOutputTokens(): number { return getAgentSession()?.outputTokens ?? 0; }
// Context limit per model (approximate)
const MODEL_LIMITS: Record<string, number> = {
@ -81,17 +80,18 @@
'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)
);
function getContextLimit(): number { return MODEL_LIMITS[getAgentModel()] ?? 200000; }
function getComputedContextPct(): number {
const inp = getAgentInputTokens();
return inp > 0 ? Math.min(100, Math.round((inp / getContextLimit()) * 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
return getAgentMessages()
.filter((m) => m.role === 'tool-call' && m.toolPath)
.map((m) => m.toolPath!)
.filter((p, i, arr) => arr.indexOf(p) === i)
@ -99,12 +99,12 @@
}
function getTurnCount(): number {
return agentMessages.filter((m) => m.role === 'user' || m.role === 'assistant').length;
return getAgentMessages().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;
return getAgentMessages().length > 0 ? getAgentMessages() : EMPTY_MESSAGES;
}
// ── Clone dialog state ──────────────────────────────────────────
@ -130,7 +130,7 @@
}
// Derived from project-tabs-store for reactive reads
let activeTab = $derived(getActiveTab(id));
function getCurrentTab() { return getActiveTab(id); }
// ── Load last session on mount (once, not reactive) ─────────────────
import { onMount } from 'svelte';
@ -169,8 +169,8 @@
>
<!-- Header -->
<header class="project-header">
<div class="status-dot-wrap" aria-label="Status: {agentStatus}">
<StatusDot status={agentStatus} />
<div class="status-dot-wrap" aria-label="Status: {getAgentStatus()}">
<StatusDot status={getAgentStatus()} />
</div>
<span class="project-name" title={name}>{name}</span>
@ -255,9 +255,9 @@
{#each ALL_TABS as tab}
<button
class="tab-btn"
class:active={activeTab === tab}
class:active={getCurrentTab() === tab}
role="tab"
aria-selected={activeTab === tab}
aria-selected={getCurrentTab() === tab}
aria-controls="tabpanel-{id}-{tab}"
onclick={() => setTab(tab)}
>
@ -272,16 +272,16 @@
<div
id="tabpanel-{id}-model"
class="tab-pane"
style:display={activeTab === 'model' ? 'flex' : 'none'}
style:display={getCurrentTab() === 'model' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Model"
>
<AgentPane
messages={getDisplayMessages()}
status={agentStatus}
costUsd={agentCost}
tokens={agentTokens}
model={agentModel}
status={getAgentStatus()}
costUsd={getAgentCost()}
tokens={getAgentTokens()}
model={getAgentModel()}
{provider}
{profile}
{contextPct}
@ -297,7 +297,7 @@
<div
id="tabpanel-{id}-docs"
class="tab-pane"
style:display={activeTab === 'docs' ? 'flex' : 'none'}
style:display={getCurrentTab() === 'docs' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Docs"
>
@ -309,7 +309,7 @@
<div
id="tabpanel-{id}-context"
class="tab-pane"
style:display={activeTab === 'context' ? 'flex' : 'none'}
style:display={getCurrentTab() === 'context' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Context"
>
@ -317,29 +317,29 @@
<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>
<span class="ctx-stat-value">{getAgentInputTokens().toLocaleString()}</span>
</div>
<div class="ctx-stat">
<span class="ctx-stat-label">Output tokens</span>
<span class="ctx-stat-value">{agentOutputTokens.toLocaleString()}</span>
<span class="ctx-stat-value">{getAgentOutputTokens().toLocaleString()}</span>
</div>
<div class="ctx-stat">
<span class="ctx-stat-label">Context</span>
<span class="ctx-stat-value">{computedContextPct}%</span>
<span class="ctx-stat-value">{getComputedContextPct()}%</span>
</div>
<div class="ctx-stat">
<span class="ctx-stat-label">Model</span>
<span class="ctx-stat-value">{agentModel}</span>
<span class="ctx-stat-value">{getAgentModel()}</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 class="ctx-meter-wrap" title="{getComputedContextPct()}% of {getContextLimit().toLocaleString()} tokens">
<div class="ctx-meter-bar" style:width="{getComputedContextPct()}%"
class:meter-warn={getComputedContextPct() >= 75}
class:meter-danger={getComputedContextPct() >= 90}
></div>
</div>
{#if getFileRefs().length > 0}
@ -369,7 +369,7 @@
<div
id="tabpanel-{id}-files"
class="tab-pane"
style:display={activeTab === 'files' ? 'flex' : 'none'}
style:display={getCurrentTab() === 'files' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Files"
>
@ -381,7 +381,7 @@
<div
id="tabpanel-{id}-ssh"
class="tab-pane"
style:display={activeTab === 'ssh' ? 'flex' : 'none'}
style:display={getCurrentTab() === 'ssh' ? 'flex' : 'none'}
role="tabpanel"
aria-label="SSH"
>
@ -393,7 +393,7 @@
<div
id="tabpanel-{id}-memory"
class="tab-pane"
style:display={activeTab === 'memory' ? 'flex' : 'none'}
style:display={getCurrentTab() === 'memory' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Memory"
>
@ -405,7 +405,7 @@
<div
id="tabpanel-{id}-comms"
class="tab-pane"
style:display={activeTab === 'comms' ? 'flex' : 'none'}
style:display={getCurrentTab() === 'comms' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Comms"
>
@ -417,7 +417,7 @@
<div
id="tabpanel-{id}-tasks"
class="tab-pane"
style:display={activeTab === 'tasks' ? 'flex' : 'none'}
style:display={getCurrentTab() === 'tasks' ? 'flex' : 'none'}
role="tabpanel"
aria-label="Tasks"
>