Repository navigation
Description
#91 fixed this for thread:updated: memoized panes, split the streaming subscription from the list subscription, added patchThreadList so a streaming thread patches one row. The sibling channel never got the same treatment and now does exactly what #91 was closed for.
Measured on the live workspace (2026-08-20)
threads in store 545 (532 archived)
threads:changed payload 0.76 MB, all 545 decorated rows, every time
structuredClone of that payload 2.6 ms (Electron IPC path)
JSON.parse of that payload 1.4 ms (web / wireClient path)
pushThreadsChanged() call sites 59 in electron/runner.js
pushThreadsChanged (runner.js:1813-1815) is unconditional:
function pushThreadsChanged() {
pushFn("threads:changed", services.listThreads(store));
}It fires on per-turn state transitions — turn start, turn end, permission prompt raised, verify result, title landed, status flips (six of the 59 sites are inside the stream onEvent handler). Not per token, so this is not a render storm on one thread; it is a fixed 545-row cost paid on every state change of every thread, growing with every thread ever created. 532 of those rows are archived and cannot have changed.
The main-process cache cannot do what its comment says
listThreads (services.js:3321-3346) is carefully written to preserve row identity:
// ...reuse decorated rows keyed on the thread object so an updateThread that
// only patched one row keeps every other row's identity (the renderer's
// patchThreadList and memo'd cards key on that).Object identity does not survive ipcRenderer structured clone (or JSON.parse on the wire path). Every row arrives in the renderer as a brand-new object no matter what the cache did. The compute saving is real — listThreads returns in 0–1 ms — but the stated purpose, keeping the renderer's memos alive, is unreachable from that side of the boundary.
The renderer then throws away what identity it had:
// useCoder.ts:618
const applyThreads = useCallback((next: ThreadInfo[]) => {
threadsRef.current = next;
setThreads(next);
}, []);Straight replace. So each broadcast invalidates every memo keyed on threads: buildWaitStates(threads), liveById, projectById, displayThreads, buildFlatSidebar(...) over all 545 rows (Sidebar.tsx:1363-1440), and every card's props are new objects.
Someone already hit this and patched one memo by hand — threadTitles (Sidebar.tsx:1326-1340) does a manual field-wise compare and returns the previous Map when nothing changed. That workaround is the proof of the pattern; it just lives in one memo instead of at the boundary.
Fix: reconcile by value where the payload lands
One function in applyThreads: keep a by-id map of the current rows, and for each incoming row reuse the previous object when the fields are equal.
value-diff of the live 545-row payload: 2.7 ms, 544/545 rows reused
That 2.7 ms is a deliberately pessimistic measurement (whole-row JSON.stringify compare); a field-wise compare over 42 known keys is far cheaper. In exchange, 544 rows keep their identity, every list memo short-circuits, and every memo'd card skips its re-render — and the main-process cache's promise finally holds end to end.
Why this over trimming the protocol: most of the 59 sites change exactly one thread and could push a row patch instead of a whole list, but that is 59 call-site edits plus a protocol change, and it would not help the sites that genuinely do change list shape. One value-diff at the landing point fixes every channel and every caller at once. Trimming the payload can come later if the 0.76 MB serialize itself shows up in a profile.
Tests
applyThreadswith an identical payload returns rows that are===to the previous ones;setThreadsis called with an array whose members are the prior objects.- One changed row: that row is a new object, the other 544 are reused.
- Add / remove / reorder still produce the correct list.
test/sidebar.test.tsx: athreads:changedthat changes nothing does not re-runbuildFlatSidebar(assert via a render counter on a card).
Related
#91 (same defect, fixed for the other channel), #564 (renderer transcript windowing — the other half of what the renderer holds), #225 (the same 545 threads cost the main process 310 ms per store flush), #418 (architecture umbrella), #553 (sidebar filters will add more list-level memos on top of this).
Activity
currentbits commentedon Aug 21, 2026
Implemented in this worktree: value-diff at the
applyThreadslanding point.reconcileThreadListreuses previous row objects when fields match (and keeps the array identity when nothing moved), so athreads:changedclone of 545 rows no longer invalidates list memos or memo'd cards. Protocol still ships the full list; trimming that payload is deferred as the issue said.Tests:
test/threadPatch.test.ts(identity / one-row / add-remove-reorder),test/threadPatchWiring.test.tsx(applyThreadsthroughuseCoder),test/sidebar.test.tsx(card render counter on a no-op push).currentbits commentedon Aug 22, 2026
Landed on
mainin the 2026-08-22 backlog merge (2d33d30..bf7c4dc, released as nightlynightly-202608220926-bf7c4dc).Evidence on main: src/threadPatch.ts; threads:changed ships a patch, not all rows
The branch had been finished for a while but was never merged; verification after landing: typecheck clean,
vite buildok, core 38/38, renderer 1468/1468, memory 204/204, electron 1848/1848.Reorder threads by drag, PR hover previews, diff folder tree, and 13 …
Long threads load in pages and render lighter, safer file restores, a…