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.
This commit is contained in:
parent
d08227fc98
commit
2709600319
4 changed files with 114 additions and 94 deletions
|
|
@ -28,7 +28,7 @@
|
|||
model?: string;
|
||||
contextPct?: number;
|
||||
burnRate?: number;
|
||||
blinkVisible?: boolean;
|
||||
// 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. */
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
model = 'claude-opus-4-5',
|
||||
contextPct = 0,
|
||||
burnRate = 0,
|
||||
blinkVisible = true,
|
||||
// blinkVisible removed
|
||||
worktreeBranch,
|
||||
cloneOf,
|
||||
clonesAtMax = false,
|
||||
|
|
@ -60,9 +60,13 @@
|
|||
}: 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 ?? []);
|
||||
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);
|
||||
|
|
@ -83,19 +87,25 @@
|
|||
);
|
||||
|
||||
// File references from tool_call messages
|
||||
let fileRefs = $derived(
|
||||
agentMessages
|
||||
// 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)
|
||||
);
|
||||
.slice(0, 20);
|
||||
}
|
||||
|
||||
let turnCount = $derived(agentMessages.filter((m) => m.role === 'user' || m.role === 'assistant').length);
|
||||
function getTurnCount(): number {
|
||||
return 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);
|
||||
// ── 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);
|
||||
|
|
@ -160,7 +170,7 @@
|
|||
<!-- Header -->
|
||||
<header class="project-header">
|
||||
<div class="status-dot-wrap" aria-label="Status: {agentStatus}">
|
||||
<StatusDot status={agentStatus} blinkOff={agentStatus === 'running' && !blinkVisible} />
|
||||
<StatusDot status={agentStatus} />
|
||||
</div>
|
||||
|
||||
<span class="project-name" title={name}>{name}</span>
|
||||
|
|
@ -267,7 +277,7 @@
|
|||
aria-label="Model"
|
||||
>
|
||||
<AgentPane
|
||||
messages={displayMessages}
|
||||
messages={getDisplayMessages()}
|
||||
status={agentStatus}
|
||||
costUsd={agentCost}
|
||||
tokens={agentTokens}
|
||||
|
|
@ -323,7 +333,7 @@
|
|||
</div>
|
||||
<div class="ctx-stat">
|
||||
<span class="ctx-stat-label">Turns</span>
|
||||
<span class="ctx-stat-value">{turnCount}</span>
|
||||
<span class="ctx-stat-value">{getTurnCount()}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ctx-meter-wrap" title="{computedContextPct}% of {contextLimit.toLocaleString()} tokens">
|
||||
|
|
@ -332,10 +342,10 @@
|
|||
class:meter-danger={computedContextPct >= 90}
|
||||
></div>
|
||||
</div>
|
||||
{#if fileRefs.length > 0}
|
||||
{#if getFileRefs().length > 0}
|
||||
<div class="ctx-turn-list">
|
||||
<div class="ctx-section-label">File references ({fileRefs.length})</div>
|
||||
{#each fileRefs as ref}
|
||||
<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>
|
||||
|
|
@ -344,7 +354,7 @@
|
|||
{/if}
|
||||
<div class="ctx-turn-list">
|
||||
<div class="ctx-section-label">Recent turns</div>
|
||||
{#each displayMessages.slice(-10) as msg}
|
||||
{#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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue