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:
2026-06-16 20:32:08 -04:00
parent cd9846b857
commit 292005edbb
3 changed files with 91 additions and 44 deletions

View File

@@ -1,7 +1,7 @@
import { memo } from 'react';
import type { Card, Column, DocPage } from '../../lib/kanbanApi';
import type { CustomPage } from '../../lib/pagesApi';
import { ReferenceLinks } from './ReferenceLinks';
import type { UseOrchestrator } from './useOrchestrator';
const COLUMN_DOT: Record<Column, string> = {
done: 'var(--green)',
@@ -10,27 +10,34 @@ const COLUMN_DOT: Record<Column, string> = {
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 {
card: Card;
pages: DocPage[];
customPages: CustomPage[];
orch: UseOrchestrator;
onOpen: () => void;
/** True while an agent run is active on this card. */
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);
const run = orch.runForCard(card.id);
const bevyRunning = Boolean(run && orch.bevyIsRunning(run.id));
const active = agentRunning || bevyRunning;
/**
* Compact, click-to-open kanban card. Memoized: it re-renders only when its own
* props change (the card row, the page registries, or this card's run flags),
* so streaming events from an active run on another card don't force a
* 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 (
<div
@@ -48,13 +55,13 @@ export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCar
: {}),
borderLeft: `3px solid ${COLUMN_DOT[card.status]}`,
}}
onClick={onOpen}
onClick={() => onOpen(card.id)}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpen();
onOpen(card.id);
}
}}
title="Open card details"
@@ -65,7 +72,7 @@ export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCar
<span style={categoryStyle}>{card.category}</span>
{active && (
<span style={{ marginLeft: 'auto' }}>
<RunningBadge agent={agentRunning} bevy={bevyRunning} />
<RunningBadge agent={isRunning} bevy={bevyRunning} />
</span>
)}
{!active && (
@@ -79,7 +86,6 @@ export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCar
<h4 style={titleStyle}>{card.title}</h4>
<p style={descStyle}>{card.description}</p>
{/* References (compact) */}
<div style={{ marginTop: 'var(--sp-2)', marginBottom: 'var(--sp-1)' }}>
<ReferenceLinks references={card.references} pages={pages} customPages={customPages} compact />
@@ -107,7 +113,7 @@ export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCar
)}
</div>
);
}
});
/** Live activity badge: pulses while an agent or Bevy run is in flight. */
function RunningBadge({ agent, bevy }: { agent: boolean; bevy: boolean }) {

View File

@@ -53,6 +53,12 @@ export interface UseOrchestrator {
bevyIsRunning: (runId: string) => boolean;
/** Re-fetch a run's Bevy status from the server (truth after a reconnect). */
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 {
@@ -283,6 +289,21 @@ export function useOrchestrator(): UseOrchestrator {
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) => {
try {
const { running } = await orchestratorApi.bevyStatus(runId);
@@ -317,5 +338,6 @@ export function useOrchestrator(): UseOrchestrator {
stopBevy,
bevyIsRunning,
refreshBevyStatus,
activeByCard,
};
}