From fc3af6095f8be5524c01f0166a39ed7bb0606e5c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sun, 26 Jul 2026 21:03:28 -0500 Subject: [PATCH] perf(desktop): stop cross-session churn re-rendering every composer status stack MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit $statusItemsBySession rebuilt its whole output map — fresh arrays, fresh item objects — on every recompute, and its inputs churn constantly (subagent ticks, 5s background polls, todo updates, in ANY session). A whole-map useStore in ComposerStatusStack then re-rendered every mounted stack — one per open tile — on all of it, and the fresh item objects defeated row memoization downstream. Two halves, per the documented slice contract (use-session-slice.ts): - producer: stabilize $statusItemsBySession per key — an unchanged session keeps its previous array and item objects, and a fully unchanged map keeps its previous reference so computed skips the notify entirely ('preserve reference identity on no-ops'). - consumer: the stack subscribes to its OWN session's slice via useSessionSlice instead of the whole map. --- .../app/chat/composer/status-stack/index.tsx | 18 ++++---- apps/desktop/src/store/composer-status.ts | 42 ++++++++++++++++++- 2 files changed, 51 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx index a3d1563262d2..fbbd2b94d5aa 100644 --- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx +++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx @@ -12,6 +12,7 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { type Translations, useI18n } from '@/i18n' +import { useSessionSlice } from '@/lib/use-session-slice' import { cn } from '@/lib/utils' import { $billingBlock } from '@/store/billing-block' import { @@ -84,17 +85,18 @@ interface ComposerStatusStackProps { export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackProps) { const { t } = useI18n() const navigate = useNavigate() - const itemsBySession = useStore($statusItemsBySession) - const previewsBySession = useStore($previewStatusBySession) + // Subscribe to THIS session's slice only. Both maps churn on other + // sessions' activity (subagent ticks, background polls, preview updates in + // any tile); a whole-map `useStore` re-rendered every mounted stack — one + // per open tile — on all of it. The per-key arrays are referentially stable + // across unrelated writes, so the slice hook bails out unless OUR session's + // items actually changed. + const items = useSessionSlice($statusItemsBySession, sessionId) + const previews = useSessionSlice($previewStatusBySession, sessionId) const scrolledUp = useStore($threadScrolledUp) const billing = useStore($billingBlock) - const groups = useMemo( - () => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []), - [itemsBySession, sessionId] - ) - - const previews = sessionId ? (previewsBySession[sessionId] ?? []) : [] + const groups = useMemo(() => groupStatusItems(items), [items]) // Seed from the registry on session open; event-driven refreshes (terminal / // process tool completions) live in use-message-stream. diff --git a/apps/desktop/src/store/composer-status.ts b/apps/desktop/src/store/composer-status.ts index 5d3b890c2883..46152e076d32 100644 --- a/apps/desktop/src/store/composer-status.ts +++ b/apps/desktop/src/store/composer-status.ts @@ -156,6 +156,39 @@ const goalToItem = (goal: { detail?: string; status: GoalStatus; title: string } }) // The single thing the stack reads: a typed, merged item list per session. +// +// Identity contract: this computed's inputs churn constantly during a turn (a +// subagent tick, a 5s background poll, a todo update — in ANY session), but +// the merged output for most sessions is unchanged. Rebuilding fresh arrays +// and item objects every time handed every mounted composer stack a new +// reference per recompute — cross-session churn × open tiles. Stabilize both +// levels: an unchanged session keeps its previous array (and item objects), +// and a fully-unchanged map keeps its previous reference so `computed` skips +// the notify entirely ("preserve reference identity on no-ops"). +const sameStatusItem = (a: ComposerStatusItem, b: ComposerStatusItem) => + a.id === b.id && + a.type === b.type && + a.state === b.state && + a.title === b.title && + a.output === b.output && + a.exitCode === b.exitCode && + a.currentTool === b.currentTool && + a.goalStatus === b.goalStatus && + a.todoStatus === b.todoStatus && + a.sessionId === b.sessionId + +const stabilizeItems = (prev: ComposerStatusItem[] | undefined, next: ComposerStatusItem[]): ComposerStatusItem[] => { + if (!prev) { + return next + } + + const merged = next.map((item, i) => (prev[i] && sameStatusItem(prev[i], item) ? prev[i] : item)) + + return merged.length === prev.length && merged.every((item, i) => item === prev[i]) ? prev : merged +} + +let prevStatusItems: Record = {} + export const $statusItemsBySession = computed( [$goalsBySession, $subagentsBySession, $backgroundStatusBySession, $todosBySession], (goals, subs, background, todos) => { @@ -183,7 +216,14 @@ export const $statusItemsBySession = computed( push(sid, list) } - return out + let unchanged = Object.keys(prevStatusItems).length === Object.keys(out).length + + for (const sid of Object.keys(out)) { + out[sid] = stabilizeItems(prevStatusItems[sid], out[sid]!) + unchanged &&= out[sid] === prevStatusItems[sid] + } + + return (prevStatusItems = unchanged ? prevStatusItems : out) } )