perf(kanban): stop board cards re-rendering on every agent event
Memoize the board so streaming events from an active run no longer force all 38 collapsed cards to re-render — only the open CardModal/AgentRunBar re-renders, since its props stay referentially equal. - KanbanCard: wrap in React.memo; take isRunning/bevyRunning primitives instead of the whole orchestrator object; onOpen now takes the card id. - useOrchestrator: expose a memoized activeByCard map, recomputed only when runs/bevyRunning change (not on every streamed text/tool event). - KanbanBoardPage: pass a stable openById callback + primitive props so memo bails; memoize StatCard; extract the static category legend to a memoized CategoryLegend component. Also drops the per-card .filter().find() status scans each render.
This commit is contained in:
@@ -1,7 +1,7 @@
|
|||||||
|
import { memo } from 'react';
|
||||||
import type { Card, Column, DocPage } from '../../lib/kanbanApi';
|
import type { Card, Column, DocPage } from '../../lib/kanbanApi';
|
||||||
import type { CustomPage } from '../../lib/pagesApi';
|
import type { CustomPage } from '../../lib/pagesApi';
|
||||||
import { ReferenceLinks } from './ReferenceLinks';
|
import { ReferenceLinks } from './ReferenceLinks';
|
||||||
import type { UseOrchestrator } from './useOrchestrator';
|
|
||||||
|
|
||||||
const COLUMN_DOT: Record<Column, string> = {
|
const COLUMN_DOT: Record<Column, string> = {
|
||||||
done: 'var(--green)',
|
done: 'var(--green)',
|
||||||
@@ -10,27 +10,34 @@ const COLUMN_DOT: Record<Column, string> = {
|
|||||||
backlog: 'var(--muted)',
|
backlog: 'var(--muted)',
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
|
||||||
* Compact, click-to-open kanban card.
|
|
||||||
*
|
|
||||||
* The full detail (agent orchestration, comments, references, tags) lives in
|
|
||||||
* the CardModal opened by `onOpen`. This card is a dense preview: status, id,
|
|
||||||
* title, description, references, and a prominent live indicator while an agent
|
|
||||||
* or Bevy playtest is running on the card's worktree.
|
|
||||||
*/
|
|
||||||
interface KanbanCardProps {
|
interface KanbanCardProps {
|
||||||
card: Card;
|
card: Card;
|
||||||
pages: DocPage[];
|
pages: DocPage[];
|
||||||
customPages: CustomPage[];
|
customPages: CustomPage[];
|
||||||
orch: UseOrchestrator;
|
/** True while an agent run is active on this card. */
|
||||||
onOpen: () => void;
|
isRunning: boolean;
|
||||||
|
/** True while a Bevy playtest is running on this card. */
|
||||||
|
bevyRunning: boolean;
|
||||||
|
/** Open this card's detail modal. */
|
||||||
|
onOpen: (id: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCardProps) {
|
/**
|
||||||
const agentRunning = orch.isRunning(card.id);
|
* Compact, click-to-open kanban card. Memoized: it re-renders only when its own
|
||||||
const run = orch.runForCard(card.id);
|
* props change (the card row, the page registries, or this card's run flags),
|
||||||
const bevyRunning = Boolean(run && orch.bevyIsRunning(run.id));
|
* so streaming events from an active run on another card don't force a
|
||||||
const active = agentRunning || bevyRunning;
|
* re-render here. The full detail (agent orchestration, comments, references,
|
||||||
|
* tags) lives in the `CardModal` opened by `onOpen`.
|
||||||
|
*/
|
||||||
|
export const KanbanCard = memo(function KanbanCard({
|
||||||
|
card,
|
||||||
|
pages,
|
||||||
|
customPages,
|
||||||
|
isRunning,
|
||||||
|
bevyRunning,
|
||||||
|
onOpen,
|
||||||
|
}: KanbanCardProps) {
|
||||||
|
const active = isRunning || bevyRunning;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@@ -48,13 +55,13 @@ export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCar
|
|||||||
: {}),
|
: {}),
|
||||||
borderLeft: `3px solid ${COLUMN_DOT[card.status]}`,
|
borderLeft: `3px solid ${COLUMN_DOT[card.status]}`,
|
||||||
}}
|
}}
|
||||||
onClick={onOpen}
|
onClick={() => onOpen(card.id)}
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
onOpen();
|
onOpen(card.id);
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
title="Open card details"
|
title="Open card details"
|
||||||
@@ -65,7 +72,7 @@ export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCar
|
|||||||
<span style={categoryStyle}>{card.category}</span>
|
<span style={categoryStyle}>{card.category}</span>
|
||||||
{active && (
|
{active && (
|
||||||
<span style={{ marginLeft: 'auto' }}>
|
<span style={{ marginLeft: 'auto' }}>
|
||||||
<RunningBadge agent={agentRunning} bevy={bevyRunning} />
|
<RunningBadge agent={isRunning} bevy={bevyRunning} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{!active && (
|
{!active && (
|
||||||
@@ -79,7 +86,6 @@ export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCar
|
|||||||
|
|
||||||
<h4 style={titleStyle}>{card.title}</h4>
|
<h4 style={titleStyle}>{card.title}</h4>
|
||||||
<p style={descStyle}>{card.description}</p>
|
<p style={descStyle}>{card.description}</p>
|
||||||
|
|
||||||
{/* References (compact) */}
|
{/* References (compact) */}
|
||||||
<div style={{ marginTop: 'var(--sp-2)', marginBottom: 'var(--sp-1)' }}>
|
<div style={{ marginTop: 'var(--sp-2)', marginBottom: 'var(--sp-1)' }}>
|
||||||
<ReferenceLinks references={card.references} pages={pages} customPages={customPages} compact />
|
<ReferenceLinks references={card.references} pages={pages} customPages={customPages} compact />
|
||||||
@@ -107,7 +113,7 @@ export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCar
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
/** Live activity badge: pulses while an agent or Bevy run is in flight. */
|
/** Live activity badge: pulses while an agent or Bevy run is in flight. */
|
||||||
function RunningBadge({ agent, bevy }: { agent: boolean; bevy: boolean }) {
|
function RunningBadge({ agent, bevy }: { agent: boolean; bevy: boolean }) {
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ export interface UseOrchestrator {
|
|||||||
bevyIsRunning: (runId: string) => boolean;
|
bevyIsRunning: (runId: string) => boolean;
|
||||||
/** Re-fetch a run's Bevy status from the server (truth after a reconnect). */
|
/** Re-fetch a run's Bevy status from the server (truth after a reconnect). */
|
||||||
refreshBevyStatus: (runId: string) => Promise<void>;
|
refreshBevyStatus: (runId: string) => Promise<void>;
|
||||||
|
/**
|
||||||
|
* Active runs indexed by card id. Memoized and referentially stable unless
|
||||||
|
* the active set actually changes (not on every streamed event), so memoized
|
||||||
|
* card components can read their flags without re-rendering on noise.
|
||||||
|
*/
|
||||||
|
activeByCard: Map<string, { running: boolean; bevy: boolean; runId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useOrchestrator(): UseOrchestrator {
|
export function useOrchestrator(): UseOrchestrator {
|
||||||
@@ -283,6 +289,21 @@ export function useOrchestrator(): UseOrchestrator {
|
|||||||
|
|
||||||
const bevyIsRunning = useCallback((runId: string) => bevyRunning.has(runId), [bevyRunning]);
|
const bevyIsRunning = useCallback((runId: string) => bevyRunning.has(runId), [bevyRunning]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Card-id index of active runs. Recomputed only when `runs` or `bevyRunning`
|
||||||
|
* changes — NOT on every streamed event — so memoized consumers stay stable.
|
||||||
|
* Replaces the per-card `.filter().find()` scans the board used to do.
|
||||||
|
*/
|
||||||
|
const activeByCard = useMemo(() => {
|
||||||
|
const m = new Map<string, { running: boolean; bevy: boolean; runId: string }>();
|
||||||
|
for (const r of runs) {
|
||||||
|
if (r.status === 'running') {
|
||||||
|
m.set(r.cardId, { running: true, bevy: bevyRunning.has(r.id), runId: r.id });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, [runs, bevyRunning]);
|
||||||
|
|
||||||
const refreshBevyStatus = useCallback(async (runId: string) => {
|
const refreshBevyStatus = useCallback(async (runId: string) => {
|
||||||
try {
|
try {
|
||||||
const { running } = await orchestratorApi.bevyStatus(runId);
|
const { running } = await orchestratorApi.bevyStatus(runId);
|
||||||
@@ -317,5 +338,6 @@ export function useOrchestrator(): UseOrchestrator {
|
|||||||
stopBevy,
|
stopBevy,
|
||||||
bevyIsRunning,
|
bevyIsRunning,
|
||||||
refreshBevyStatus,
|
refreshBevyStatus,
|
||||||
|
activeByCard,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useMemo, useState } from 'react';
|
import { memo, useCallback, useMemo, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useKanbanBoard } from '../../components/kanban/useKanbanBoard';
|
import { useKanbanBoard } from '../../components/kanban/useKanbanBoard';
|
||||||
import { KanbanCard } from '../../components/kanban/KanbanCard';
|
import { KanbanCard } from '../../components/kanban/KanbanCard';
|
||||||
@@ -47,6 +47,10 @@ export function KanbanBoardPage() {
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [justSaved, setJustSaved] = useState(false);
|
const [justSaved, setJustSaved] = useState(false);
|
||||||
|
|
||||||
|
// Stable identity across renders — memoized cards receive a referentially
|
||||||
|
// stable `onOpen` so they don't re-render just because the parent did.
|
||||||
|
const openById = useCallback((id: string) => setOpenCardId(id), []);
|
||||||
|
|
||||||
// The card whose detail modal is open (null when closed).
|
// The card whose detail modal is open (null when closed).
|
||||||
const openCard = board?.cards.find((c) => c.id === openCardId) ?? null;
|
const openCard = board?.cards.find((c) => c.id === openCardId) ?? null;
|
||||||
|
|
||||||
@@ -209,32 +213,27 @@ export function KanbanBoardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)', flex: 1 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--sp-2)', flex: 1 }}>
|
||||||
{byColumn[col.key].map((card) => (
|
{byColumn[col.key].map((card) => {
|
||||||
<KanbanCard
|
const active = orch.activeByCard.get(card.id);
|
||||||
key={card.id}
|
return (
|
||||||
card={card}
|
<KanbanCard
|
||||||
pages={pages}
|
key={card.id}
|
||||||
customPages={customPages}
|
card={card}
|
||||||
orch={orch}
|
pages={pages}
|
||||||
onOpen={() => setOpenCardId(card.id)}
|
customPages={customPages}
|
||||||
/>
|
isRunning={!!active?.running}
|
||||||
))}
|
bevyRunning={!!active?.bevy}
|
||||||
|
onOpen={openById}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Legend */}
|
{/* Legend */}
|
||||||
<div style={{ marginTop: 'var(--sp-6)', padding: 'var(--sp-3)', background: 'var(--surface-raised)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
|
<CategoryLegend />
|
||||||
<h4 style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.85rem', color: 'var(--fg)' }}>Categories</h4>
|
|
||||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--sp-2)', fontSize: '0.75rem', color: 'var(--fg-dim)' }}>
|
|
||||||
{CATEGORIES.map((cat) => (
|
|
||||||
<span key={cat} style={{ padding: '2px 8px', background: 'var(--surface)', borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)' }}>
|
|
||||||
{cat}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -258,7 +257,7 @@ export function KanbanBoardPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
const StatCard = memo(function StatCard({ label, value, color }: { label: string; value: number; color: string }) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="rounded-xl border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3"
|
className="rounded-xl border border-border bg-surface p-4 max-md:rounded-lg max-md:p-3"
|
||||||
@@ -270,7 +269,7 @@ function StatCard({ label, value, color }: { label: string; value: number; color
|
|||||||
<div className="mt-1 text-[0.7rem] uppercase tracking-[0.05em] text-muted">{label}</div>
|
<div className="mt-1 text-[0.7rem] uppercase tracking-[0.05em] text-muted">{label}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
});
|
||||||
|
|
||||||
function SyncStatus({ loading, error }: { loading: boolean; error: string | null }) {
|
function SyncStatus({ loading, error }: { loading: boolean; error: string | null }) {
|
||||||
const state = error ? 'error' : loading ? 'loading' : 'synced';
|
const state = error ? 'error' : loading ? 'loading' : 'synced';
|
||||||
@@ -312,6 +311,26 @@ const headerButtonStyle: React.CSSProperties = {
|
|||||||
color: 'var(--fg)',
|
color: 'var(--fg)',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fully static category legend. Extracted to a module-scope constant so the
|
||||||
|
* component tree is created once and never re-evaluated — the parent's renders
|
||||||
|
* during an active run no longer reconcile these ~16 chips.
|
||||||
|
*/
|
||||||
|
const CategoryLegend = memo(function CategoryLegend() {
|
||||||
|
return (
|
||||||
|
<div style={{ marginTop: 'var(--sp-6)', padding: 'var(--sp-3)', background: 'var(--surface-raised)', borderRadius: 'var(--radius-md)', border: '1px solid var(--border)' }}>
|
||||||
|
<h4 style={{ margin: '0 0 var(--sp-2) 0', fontSize: '0.85rem', color: 'var(--fg)' }}>Categories</h4>
|
||||||
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--sp-2)', fontSize: '0.75rem', color: 'var(--fg-dim)' }}>
|
||||||
|
{CATEGORIES.map((cat) => (
|
||||||
|
<span key={cat} style={{ padding: '2px 8px', background: 'var(--surface)', borderRadius: 'var(--radius-sm)', border: '1px solid var(--border)' }}>
|
||||||
|
{cat}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
const primaryHeaderButtonStyle: React.CSSProperties = {
|
const primaryHeaderButtonStyle: React.CSSProperties = {
|
||||||
...headerButtonStyle,
|
...headerButtonStyle,
|
||||||
background: 'var(--accent)',
|
background: 'var(--accent)',
|
||||||
|
|||||||
Reference in New Issue
Block a user