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:
parent
774016ba11
commit
8d09632879
1 changed files with 43 additions and 43 deletions
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue