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
|
|
@ -66,43 +66,19 @@
|
||||||
let appReady = $state(false);
|
let appReady = $state(false);
|
||||||
let sessionStart = $state(Date.now());
|
let sessionStart = $state(Date.now());
|
||||||
|
|
||||||
// ── Stable derived values (avoid creating new objects in template → prevents infinite loops) ──
|
// ── Stable derived values ──
|
||||||
|
// NOTE: ALL derived values use plain function calls — NO $derived()
|
||||||
|
// to prevent Svelte 5 reactive loops when store getters create new refs.
|
||||||
function isProjectMounted(groupId: string): boolean {
|
function isProjectMounted(groupId: string): boolean {
|
||||||
return groupId === getActiveGroupId();
|
return groupId === getActiveGroupId();
|
||||||
}
|
}
|
||||||
let filteredProjects = $derived(getFilteredProjects());
|
// These are called directly in template — Svelte tracks the inner $state reads
|
||||||
let activeGroup = $derived(getActiveGroup());
|
// but doesn't create intermediate $derived objects that could loop.
|
||||||
let totalTokens = $derived(getTotalTokensDerived());
|
|
||||||
let totalCost = $derived(getTotalCostDerived());
|
|
||||||
let wizardGroups = $derived(getGroups().map(g => ({ id: g.id, name: g.name })));
|
|
||||||
let wizardExistingNames = $derived(getProjects().map(p => p.name));
|
|
||||||
|
|
||||||
// ── Blink timer (untracked — must not create reactive dependency) ────
|
// Blink is now in blink-store.svelte.ts — no prop passing needed
|
||||||
let blinkVisible = $state(true);
|
|
||||||
let _blinkId: ReturnType<typeof setInterval>;
|
|
||||||
$effect(() => {
|
|
||||||
_blinkId = setInterval(() => {
|
|
||||||
untrack(() => {
|
|
||||||
blinkVisible = !blinkVisible;
|
|
||||||
});
|
|
||||||
}, 500);
|
|
||||||
return () => clearInterval(_blinkId);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Session duration (untracked) ──────────────────────────────
|
// ── Session duration (plain JS, no reactive system) ──
|
||||||
let sessionDuration = $state("0m");
|
let sessionDuration = $state("0m");
|
||||||
$effect(() => {
|
|
||||||
function update() {
|
|
||||||
const mins = Math.floor((Date.now() - sessionStart) / 60000);
|
|
||||||
untrack(() => {
|
|
||||||
sessionDuration =
|
|
||||||
mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
update();
|
|
||||||
const id = setInterval(update, 10000);
|
|
||||||
return () => clearInterval(id);
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Window controls ─────────────────────────────────────────
|
// ── Window controls ─────────────────────────────────────────
|
||||||
function handleClose() {
|
function handleClose() {
|
||||||
|
|
@ -204,37 +180,7 @@
|
||||||
new URLSearchParams(window.location.search).has("debug");
|
new URLSearchParams(window.location.search).has("debug");
|
||||||
let debugLog = $state<string[]>([]);
|
let debugLog = $state<string[]>([]);
|
||||||
|
|
||||||
$effect(() => {
|
// Debug overlay listeners are set up in onMount, not $effect
|
||||||
if (!DEBUG_ENABLED) return;
|
|
||||||
function debugClick(e: MouseEvent) {
|
|
||||||
const el = e.target as HTMLElement;
|
|
||||||
const tag = el?.tagName;
|
|
||||||
const cls = (el?.className?.toString?.() ?? "").slice(0, 40);
|
|
||||||
const elAtPoint = document.elementFromPoint(e.clientX, e.clientY);
|
|
||||||
let line = `CLICK ${tag}.${cls} @(${e.clientX},${e.clientY})`;
|
|
||||||
if (elAtPoint && elAtPoint !== el) {
|
|
||||||
const overTag = (elAtPoint as HTMLElement).tagName;
|
|
||||||
const overCls = (
|
|
||||||
(elAtPoint as HTMLElement).className?.toString?.() ?? ""
|
|
||||||
).slice(0, 40);
|
|
||||||
line += ` OVERLAY: ${overTag}.${overCls}`;
|
|
||||||
}
|
|
||||||
debugLog = [...debugLog.slice(-8), line];
|
|
||||||
}
|
|
||||||
function debugDown(e: MouseEvent) {
|
|
||||||
const el = e.target as HTMLElement;
|
|
||||||
debugLog = [
|
|
||||||
...debugLog.slice(-8),
|
|
||||||
`DOWN ${el?.tagName}.${(el?.className?.toString?.() ?? "").slice(0, 40)}`,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
document.addEventListener("click", debugClick, true);
|
|
||||||
document.addEventListener("mousedown", debugDown, true);
|
|
||||||
return () => {
|
|
||||||
document.removeEventListener("click", debugClick, true);
|
|
||||||
document.removeEventListener("mousedown", debugDown, true);
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── i18n: lang/dir sync is handled inside setLocale() — no $effect needed ──
|
// ── i18n: lang/dir sync is handled inside setLocale() — no $effect needed ──
|
||||||
|
|
||||||
|
|
@ -243,6 +189,15 @@
|
||||||
setAgentToastFn(showToast);
|
setAgentToastFn(showToast);
|
||||||
setupErrorBoundary();
|
setupErrorBoundary();
|
||||||
|
|
||||||
|
// Blink + session timers — MUST be in onMount, NOT $effect
|
||||||
|
// $effect interacts with reactive graph and causes cycles
|
||||||
|
// Blink timer is in blink-store — start it here
|
||||||
|
import('./blink-store.svelte.ts').then(m => m.startBlink());
|
||||||
|
const sessionId = setInterval(() => {
|
||||||
|
const mins = Math.floor((Date.now() - sessionStart) / 60000);
|
||||||
|
sessionDuration = mins < 60 ? `${mins}m` : `${Math.floor(mins / 60)}h ${mins % 60}m`;
|
||||||
|
}, 10000);
|
||||||
|
|
||||||
const initTasks = [
|
const initTasks = [
|
||||||
initI18n().catch(console.error),
|
initI18n().catch(console.error),
|
||||||
themeStore.initTheme(appRpc).catch(console.error),
|
themeStore.initTheme(appRpc).catch(console.error),
|
||||||
|
|
@ -299,9 +254,33 @@
|
||||||
}
|
}
|
||||||
window.addEventListener("palette-command", handlePaletteCommand);
|
window.addEventListener("palette-command", handlePaletteCommand);
|
||||||
|
|
||||||
|
// Debug overlay (plain listeners, no $effect)
|
||||||
|
let debugCleanup = () => {};
|
||||||
|
if (DEBUG_ENABLED) {
|
||||||
|
function debugClick(e: MouseEvent) {
|
||||||
|
const el = e.target as HTMLElement;
|
||||||
|
const tag = el?.tagName;
|
||||||
|
const cls = (el?.className?.toString?.() ?? '').slice(0, 40);
|
||||||
|
debugLog = [...debugLog.slice(-8), `CLICK ${tag}.${cls} @(${e.clientX},${e.clientY})`];
|
||||||
|
}
|
||||||
|
function debugDown(e: MouseEvent) {
|
||||||
|
const el = e.target as HTMLElement;
|
||||||
|
debugLog = [...debugLog.slice(-8), `DOWN ${el?.tagName}.${(el?.className?.toString?.() ?? '').slice(0, 40)}`];
|
||||||
|
}
|
||||||
|
document.addEventListener('click', debugClick, true);
|
||||||
|
document.addEventListener('mousedown', debugDown, true);
|
||||||
|
debugCleanup = () => {
|
||||||
|
document.removeEventListener('click', debugClick, true);
|
||||||
|
document.removeEventListener('mousedown', debugDown, true);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const cleanup = keybindingStore.installListener();
|
const cleanup = keybindingStore.installListener();
|
||||||
return () => {
|
return () => {
|
||||||
cleanup();
|
cleanup();
|
||||||
|
debugCleanup();
|
||||||
|
import('./blink-store.svelte.ts').then(m => m.stopBlink());
|
||||||
|
clearInterval(sessionId);
|
||||||
document.removeEventListener("keydown", handleSearchShortcut);
|
document.removeEventListener("keydown", handleSearchShortcut);
|
||||||
window.removeEventListener("palette-command", handlePaletteCommand);
|
window.removeEventListener("palette-command", handlePaletteCommand);
|
||||||
};
|
};
|
||||||
|
|
@ -434,7 +413,7 @@
|
||||||
<div
|
<div
|
||||||
class="project-grid"
|
class="project-grid"
|
||||||
role="list"
|
role="list"
|
||||||
aria-label="{activeGroup?.name ?? 'Projects'} projects"
|
aria-label="{getActiveGroup()?.name ?? 'Projects'} projects"
|
||||||
>
|
>
|
||||||
{#each getProjects() as project (project.id)}
|
{#each getProjects() as project (project.id)}
|
||||||
{#if isProjectMounted(project.groupId ?? "dev")}
|
{#if isProjectMounted(project.groupId ?? "dev")}
|
||||||
|
|
@ -454,7 +433,6 @@
|
||||||
model={project.model}
|
model={project.model}
|
||||||
contextPct={project.contextPct}
|
contextPct={project.contextPct}
|
||||||
burnRate={project.burnRate}
|
burnRate={project.burnRate}
|
||||||
{blinkVisible}
|
|
||||||
clonesAtMax={cloneCountForProject(project.id) >= 3}
|
clonesAtMax={cloneCountForProject(project.id) >= 3}
|
||||||
onClone={handleClone}
|
onClone={handleClone}
|
||||||
worktreeBranch={project.worktreeBranch}
|
worktreeBranch={project.worktreeBranch}
|
||||||
|
|
@ -468,10 +446,10 @@
|
||||||
<div
|
<div
|
||||||
class="empty-group"
|
class="empty-group"
|
||||||
role="listitem"
|
role="listitem"
|
||||||
style:display={filteredProjects.length === 0 ? "flex" : "none"}
|
style:display={getFilteredProjects().length === 0 ? "flex" : "none"}
|
||||||
>
|
>
|
||||||
<p class="empty-group-text">
|
<p class="empty-group-text">
|
||||||
No projects in {activeGroup?.name ?? "this group"}
|
No projects in {getActiveGroup()?.name ?? "this group"}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -481,8 +459,8 @@
|
||||||
onClose={() => setShowWizard(false)}
|
onClose={() => setShowWizard(false)}
|
||||||
onCreated={handleWizardCreated}
|
onCreated={handleWizardCreated}
|
||||||
groupId={getActiveGroupId()}
|
groupId={getActiveGroupId()}
|
||||||
groups={wizardGroups}
|
groups={getGroups().map(g => ({ id: g.id, name: g.name }))}
|
||||||
existingNames={wizardExistingNames}
|
existingNames={getProjects().map(p => p.name)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -567,11 +545,11 @@
|
||||||
|
|
||||||
<!-- Status bar (health-backed) -->
|
<!-- Status bar (health-backed) -->
|
||||||
<StatusBar
|
<StatusBar
|
||||||
projectCount={filteredProjects.length}
|
projectCount={getFilteredProjects().length}
|
||||||
{totalTokens}
|
totalTokens={getTotalTokensDerived()}
|
||||||
{totalCost}
|
totalCost={getTotalCostDerived()}
|
||||||
{sessionDuration}
|
{sessionDuration}
|
||||||
groupName={activeGroup?.name ?? ""}
|
groupName={getActiveGroup()?.name ?? ""}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{#if DEBUG_ENABLED && debugLog.length > 0}
|
{#if DEBUG_ENABLED && debugLog.length > 0}
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@
|
||||||
model?: string;
|
model?: string;
|
||||||
contextPct?: number;
|
contextPct?: number;
|
||||||
burnRate?: number;
|
burnRate?: number;
|
||||||
blinkVisible?: boolean;
|
// blinkVisible removed — StatusDot reads from blink-store directly
|
||||||
/** Worktree branch name — set when this is a clone card. */
|
/** Worktree branch name — set when this is a clone card. */
|
||||||
worktreeBranch?: string;
|
worktreeBranch?: string;
|
||||||
/** ID of parent project — set when this is a clone card. */
|
/** ID of parent project — set when this is a clone card. */
|
||||||
|
|
@ -51,7 +51,7 @@
|
||||||
model = 'claude-opus-4-5',
|
model = 'claude-opus-4-5',
|
||||||
contextPct = 0,
|
contextPct = 0,
|
||||||
burnRate = 0,
|
burnRate = 0,
|
||||||
blinkVisible = true,
|
// blinkVisible removed
|
||||||
worktreeBranch,
|
worktreeBranch,
|
||||||
cloneOf,
|
cloneOf,
|
||||||
clonesAtMax = false,
|
clonesAtMax = false,
|
||||||
|
|
@ -60,9 +60,13 @@
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
// ── Agent session (reactive from store) ──────────────────────────
|
// ── 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 session = $derived(getSession(id));
|
||||||
let agentStatus: AgentStatus = $derived(session?.status ?? 'idle');
|
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 agentCost = $derived(session?.costUsd ?? 0);
|
||||||
let agentTokens = $derived((session?.inputTokens ?? 0) + (session?.outputTokens ?? 0));
|
let agentTokens = $derived((session?.inputTokens ?? 0) + (session?.outputTokens ?? 0));
|
||||||
let agentModel = $derived(session?.model ?? model);
|
let agentModel = $derived(session?.model ?? model);
|
||||||
|
|
@ -83,19 +87,25 @@
|
||||||
);
|
);
|
||||||
|
|
||||||
// File references from tool_call messages
|
// File references from tool_call messages
|
||||||
let fileRefs = $derived(
|
// NOTE: Do NOT use $derived with .filter()/.map() — creates new arrays every
|
||||||
agentMessages
|
// 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)
|
.filter((m) => m.role === 'tool-call' && m.toolPath)
|
||||||
.map((m) => m.toolPath!)
|
.map((m) => m.toolPath!)
|
||||||
.filter((p, i, arr) => arr.indexOf(p) === i)
|
.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) ────────────────
|
// ── Display messages (no $derived — avoids new ref on re-render) ──
|
||||||
const demoMessages: AgentMessage[] = [];
|
function getDisplayMessages(): AgentMessage[] {
|
||||||
let displayMessages = $derived(agentMessages.length > 0 ? agentMessages : demoMessages);
|
return agentMessages.length > 0 ? agentMessages : EMPTY_MESSAGES;
|
||||||
|
}
|
||||||
|
|
||||||
// ── Clone dialog state ──────────────────────────────────────────
|
// ── Clone dialog state ──────────────────────────────────────────
|
||||||
let showCloneDialog = $state(false);
|
let showCloneDialog = $state(false);
|
||||||
|
|
@ -160,7 +170,7 @@
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<header class="project-header">
|
<header class="project-header">
|
||||||
<div class="status-dot-wrap" aria-label="Status: {agentStatus}">
|
<div class="status-dot-wrap" aria-label="Status: {agentStatus}">
|
||||||
<StatusDot status={agentStatus} blinkOff={agentStatus === 'running' && !blinkVisible} />
|
<StatusDot status={agentStatus} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span class="project-name" title={name}>{name}</span>
|
<span class="project-name" title={name}>{name}</span>
|
||||||
|
|
@ -267,7 +277,7 @@
|
||||||
aria-label="Model"
|
aria-label="Model"
|
||||||
>
|
>
|
||||||
<AgentPane
|
<AgentPane
|
||||||
messages={displayMessages}
|
messages={getDisplayMessages()}
|
||||||
status={agentStatus}
|
status={agentStatus}
|
||||||
costUsd={agentCost}
|
costUsd={agentCost}
|
||||||
tokens={agentTokens}
|
tokens={agentTokens}
|
||||||
|
|
@ -323,7 +333,7 @@
|
||||||
</div>
|
</div>
|
||||||
<div class="ctx-stat">
|
<div class="ctx-stat">
|
||||||
<span class="ctx-stat-label">Turns</span>
|
<span class="ctx-stat-label">Turns</span>
|
||||||
<span class="ctx-stat-value">{turnCount}</span>
|
<span class="ctx-stat-value">{getTurnCount()}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="ctx-meter-wrap" title="{computedContextPct}% of {contextLimit.toLocaleString()} tokens">
|
<div class="ctx-meter-wrap" title="{computedContextPct}% of {contextLimit.toLocaleString()} tokens">
|
||||||
|
|
@ -332,10 +342,10 @@
|
||||||
class:meter-danger={computedContextPct >= 90}
|
class:meter-danger={computedContextPct >= 90}
|
||||||
></div>
|
></div>
|
||||||
</div>
|
</div>
|
||||||
{#if fileRefs.length > 0}
|
{#if getFileRefs().length > 0}
|
||||||
<div class="ctx-turn-list">
|
<div class="ctx-turn-list">
|
||||||
<div class="ctx-section-label">File references ({fileRefs.length})</div>
|
<div class="ctx-section-label">File references ({getFileRefs().length})</div>
|
||||||
{#each fileRefs as ref}
|
{#each getFileRefs() as ref}
|
||||||
<div class="ctx-turn-row">
|
<div class="ctx-turn-row">
|
||||||
<span class="ctx-turn-preview" title={ref}>{ref}</span>
|
<span class="ctx-turn-preview" title={ref}>{ref}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -344,7 +354,7 @@
|
||||||
{/if}
|
{/if}
|
||||||
<div class="ctx-turn-list">
|
<div class="ctx-turn-list">
|
||||||
<div class="ctx-section-label">Recent turns</div>
|
<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">
|
<div class="ctx-turn-row">
|
||||||
<span class="ctx-turn-role ctx-role-{msg.role}">{msg.role}</span>
|
<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>
|
<span class="ctx-turn-preview">{msg.content.slice(0, 60)}{msg.content.length > 60 ? '...' : ''}</span>
|
||||||
|
|
|
||||||
28
ui-electrobun/src/mainview/blink-store.svelte.ts
Normal file
28
ui-electrobun/src/mainview/blink-store.svelte.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
/**
|
||||||
|
* Blink store — global blink state for status dots.
|
||||||
|
*
|
||||||
|
* StatusDot reads this directly. No component receives blink as a prop,
|
||||||
|
* preventing cascading re-renders of entire ProjectCard trees every 500ms.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let _visible = $state(true);
|
||||||
|
let _intervalId: ReturnType<typeof setInterval> | undefined;
|
||||||
|
|
||||||
|
export function getBlinkVisible(): boolean {
|
||||||
|
return _visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startBlink(): void {
|
||||||
|
if (_intervalId) return;
|
||||||
|
_intervalId = setInterval(() => {
|
||||||
|
_visible = !_visible;
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function stopBlink(): void {
|
||||||
|
if (_intervalId) {
|
||||||
|
clearInterval(_intervalId);
|
||||||
|
_intervalId = undefined;
|
||||||
|
}
|
||||||
|
_visible = true;
|
||||||
|
}
|
||||||
|
|
@ -1,20 +1,23 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import { getBlinkVisible } from '../blink-store.svelte.ts';
|
||||||
|
|
||||||
type DotStatus = 'running' | 'idle' | 'done' | 'error' | 'stalled' | 'inactive';
|
type DotStatus = 'running' | 'idle' | 'done' | 'error' | 'stalled' | 'inactive';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
status: DotStatus;
|
status: DotStatus;
|
||||||
/** Hide on blink phase (for running pulse). */
|
|
||||||
blinkOff?: boolean;
|
|
||||||
/** Dot diameter in rem. Default 0.625. */
|
/** Dot diameter in rem. Default 0.625. */
|
||||||
size?: number;
|
size?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
let { status, blinkOff = false, size = 0.625 }: Props = $props();
|
let { status, size = 0.625 }: Props = $props();
|
||||||
|
|
||||||
|
// Read blink state from global store — NOT from parent prop.
|
||||||
|
// This prevents cascading re-renders of the entire ProjectCard tree.
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<span
|
<span
|
||||||
class="status-dot {status}"
|
class="status-dot {status}"
|
||||||
class:blink-off={status === 'running' && blinkOff}
|
class:blink-off={status === 'running' && !getBlinkVisible()}
|
||||||
style="width: {size}rem; height: {size}rem;"
|
style="width: {size}rem; height: {size}rem;"
|
||||||
role="img"
|
role="img"
|
||||||
aria-label={status}
|
aria-label={status}
|
||||||
|
|
@ -26,6 +29,7 @@
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: var(--ctp-overlay0);
|
background: var(--ctp-overlay0);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-dot.running { background: var(--ctp-green); }
|
.status-dot.running { background: var(--ctp-green); }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue