perf(desktop): stop cross-session churn re-rendering every composer status stack

$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.
This commit is contained in:
Brooklyn Nicholson 2026-07-26 21:03:28 -05:00
parent 6107624727
commit fc3af6095f
2 changed files with 51 additions and 9 deletions

View file

@ -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.

View file

@ -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<string, ComposerStatusItem[]> = {}
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)
}
)