From 292005edbb74e692671a6346f98382c3f6a445ee Mon Sep 17 00:00:00 2001 From: francy51 Date: Tue, 16 Jun 2026 20:32:08 -0400 Subject: [PATCH] perf(kanban): stop board cards re-rendering on every agent event MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../docs/src/components/kanban/KanbanCard.tsx | 48 ++++++++------ .../src/components/kanban/useOrchestrator.ts | 22 +++++++ apps/docs/src/pages/docs/KanbanBoardPage.tsx | 65 ++++++++++++------- 3 files changed, 91 insertions(+), 44 deletions(-) diff --git a/apps/docs/src/components/kanban/KanbanCard.tsx b/apps/docs/src/components/kanban/KanbanCard.tsx index f4eeb03..423d967 100644 --- a/apps/docs/src/components/kanban/KanbanCard.tsx +++ b/apps/docs/src/components/kanban/KanbanCard.tsx @@ -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 = { done: 'var(--green)', @@ -10,27 +10,34 @@ const COLUMN_DOT: Record = { 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 (
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 {card.category} {active && ( - + )} {!active && ( @@ -79,7 +86,6 @@ export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCar

{card.title}

{card.description}

- {/* References (compact) */}
@@ -107,7 +113,7 @@ export function KanbanCard({ card, pages, customPages, orch, onOpen }: KanbanCar )}
); -} +}); /** Live activity badge: pulses while an agent or Bevy run is in flight. */ function RunningBadge({ agent, bevy }: { agent: boolean; bevy: boolean }) { diff --git a/apps/docs/src/components/kanban/useOrchestrator.ts b/apps/docs/src/components/kanban/useOrchestrator.ts index cccf82b..6c612ab 100644 --- a/apps/docs/src/components/kanban/useOrchestrator.ts +++ b/apps/docs/src/components/kanban/useOrchestrator.ts @@ -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; + /** + * 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; } 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(); + 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, }; } diff --git a/apps/docs/src/pages/docs/KanbanBoardPage.tsx b/apps/docs/src/pages/docs/KanbanBoardPage.tsx index 4d9c2cd..6ff7ee8 100644 --- a/apps/docs/src/pages/docs/KanbanBoardPage.tsx +++ b/apps/docs/src/pages/docs/KanbanBoardPage.tsx @@ -1,4 +1,4 @@ -import { useMemo, useState } from 'react'; +import { memo, useCallback, useMemo, useState } from 'react'; import { Link } from 'react-router-dom'; import { useKanbanBoard } from '../../components/kanban/useKanbanBoard'; import { KanbanCard } from '../../components/kanban/KanbanCard'; @@ -47,6 +47,10 @@ export function KanbanBoardPage() { const [saving, setSaving] = 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). const openCard = board?.cards.find((c) => c.id === openCardId) ?? null; @@ -209,32 +213,27 @@ export function KanbanBoardPage() {
- {byColumn[col.key].map((card) => ( - setOpenCardId(card.id)} - /> - ))} + {byColumn[col.key].map((card) => { + const active = orch.activeByCard.get(card.id); + return ( + + ); + })}
))} {/* Legend */} -
-

Categories

-
- {CATEGORIES.map((cat) => ( - - {cat} - - ))} -
-
+ )} @@ -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 (
{label}
); -} +}); function SyncStatus({ loading, error }: { loading: boolean; error: string | null }) { const state = error ? 'error' : loading ? 'loading' : 'synced'; @@ -312,6 +311,26 @@ const headerButtonStyle: React.CSSProperties = { 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 ( +
+

Categories

+
+ {CATEGORIES.map((cat) => ( + + {cat} + + ))} +
+
+ ); +}); + const primaryHeaderButtonStyle: React.CSSProperties = { ...headerButtonStyle, background: 'var(--accent)',