Repository navigation
epic: deepen thread load and switch — extract ThreadHydrator #522
Description
Problem Statement
When the user opens a thread or switches between threads in mcode, the loading flow lives across three modules — threadStore.loadMessages, prefetch.ts, and messageCache — none of which fully owns "what does a thread look like in memory." The 385-line loadMessages function duplicates an auxiliary-fanout block across its cache-hit and cache-miss branches, writes a different snapshot shape than the prefetcher, and reaches across into taskStore, planStore, and workspaceStore to coordinate the load. Adding a new per-thread field today touches a ~3000-line threadStore and a separate cache module, with no single place to test the behaviour or reason about the snapshot contract.
The friction surfaces three ways:
- Adding or changing per-thread state is a multi-file edit with no clear owner.
- The cache-hit branch silently tops up "thin" snapshots written by the prefetcher, so the snapshot contract is implicit and asymmetric.
- The auxiliary fanout (pending permissions, tasks, plans, file-change snapshots) exists twice with different TTL semantics, drifting over time.
Solution
Extract one deep module — ThreadHydrator — that owns the entire "load this thread" flow behind a small interface. Two named sub-modules, SnapshotBuilder and AuxiliaryHydrator, sit inside the hydrator and remain internal seams used by hydrator tests. The per-thread state in threadStore collapses from ~30 parallel Record<string, X> maps into one Map<threadId, ThreadRecord> plus a current-thread pointer; the message cache becomes the LRU of evicted ThreadRecords — same shape end-to-end.
After the deepening:
ChatViewcalls one entry point on the hydrator when the active thread changes.- The sidebar's hover prefetch calls the same entry point with a background mode.
loadMessageseither disappears or becomes a one-line forward into the hydrator.- The auxiliary fanout exists once, with TTL inside.
- The snapshot contract is canonical by definition — no reader ever inspects completeness.
User Stories
- As a developer, I want a single entry point for loading a thread's in-memory state, so that I do not have to learn which of three modules to call.
- As a developer, I want adding a new per-thread field to be a one-type change, so that I do not have to thread it through 30 parallel maps and a separate cache module.
- As a developer, I want the snapshot contract to be canonical, so that I do not write code that inspects completeness or tops up missing fields.
- As a developer, I want the auxiliary fanout (pending permissions, tasks, plans, file-change snapshots) to exist once, so that TTL and diff-before-set policies do not drift between cache-hit and cache-miss branches.
- As a developer, I want the cross-store writes into
taskStoreandplanStoreto live behind the hydrator's seam, so that "load a thread" is not aware of every store it touches. - As a developer, I want the hover prefetch and the active-thread load to share one implementation, so that prefetched snapshots are always as complete as freshly-loaded ones.
- As a developer writing tests, I want the hydrator's interface to be the test surface, so that I can exercise cache-hit, cache-miss, stale, running-thread, and cross-thread-race flows through one entry point.
- As a developer writing tests, I want a real in-memory transport adapter, so that hydrator tests do not mock the transport call by call.
- As an end user, I want thread switches to be visually identical to today — no blank-flash, scroll memory preserved — so that the refactor is invisible to me.
- As an end user, I want recently-visited threads to restore instantly, so that rapid back-and-forth navigation stays smooth.
- As an end user with a thread mid-stream when I switch away and back, I want the live state to survive the switch, so that the agent's running turn is not disrupted.
- As an end user, I want the plan-question wizard, pending permissions, and file-change banner to still appear correctly after the refactor, so that gating UI does not regress.
- As an end user with
MessageList.thread-switchbehaviour today, I want that behaviour preserved exactly, so that prior testing investment carries over. - As a developer, I want the
messageCachemodule to either rename to reflect its new role (LRU of evictedThreadRecords) or absorb into the hydrator, so that there is one clear home for "what's in memory for this thread." - As a developer, I want the per-thread-record migration to be staged behind the hydrator extraction, so that the state-shape change has a single owner rather than touching the entire UI at once.
- As a developer, I want the
lastHydratedByThreadTTL gate to live inside the auxiliary fanout, so that the policy is not split across two call sites with two different defaults. - As a developer, I want
prefetch.tsto collapse to a debounced call into the hydrator, so that the snapshot-population shape is not duplicated.
Implementation Decisions
Modules
ThreadHydrator — new deep module. Owns the full "load this thread" flow.
- Interface:
hydrate(threadId, mode: "active" | "background", opts?: { force?: boolean })returning the populatedThreadRecord. One entry point forChatView's active-thread effect, for hover prefetch, and for any other caller that today reaches intoloadMessagesorprefetchThread. - Owns the cache-lookup / RPC / commit lifecycle, the load-epoch monotonic counter, the running-thread preservation rule (do not wipe tool-calls for a running thread), and the narrative-batch prefetch window (last 20 assistant messages).
- Transport is injected as an adapter — live WS transport in prod, in-memory fake in tests. Two adapters justify the seam from day one.
- Active mode commits to the live store and the cache. Background mode commits only to the cache.
SnapshotBuilder — internal sub-module of ThreadHydrator. Pure-ish.
- Interface:
build(rawMessages, snapshots, narratives, answeredPlanIds) -> ThreadSnapshot. One canonical shape, no completeness flags. - Absorbs the per-message tool-call-count rollup, the
latestTurnWithChangesderivation, and the answered-plan-question set hydration. - Has no IO. Tested implicitly through
ThreadHydrator.
AuxiliaryHydrator — internal sub-module of ThreadHydrator.
- Interface:
hydrate(threadId, { freshnessTtlMs }). Fan-out to pending permissions, thread tasks, thread plans, and (when needed) file-change snapshots. Owns the TTL gate and the diff-before-set discipline. - Owns the cross-store writes into
taskStore,planStore, and the permissions field. Caller does not see the constituent RPCs. - Failure of any individual fanout call is non-fatal and logged at debug level, same as today.
ThreadRecord — new state shape inside threadStore.
- One type holds every per-thread piece that today lives across ~30
Record<string, X>maps: messages, sequence cursors, has-more flag, tool-call counts, files-changed map, streaming buffers, tool calls, narration segments, permissions, hooks, plan-questions state, settings, context window / quota / rate-limit / api-retry state, compaction flag, handoff metadata, fork mode, awaiting-stop flags. threadStorestate becomes{ records: Map<threadId, ThreadRecord>, currentThreadId: string | null, runningThreadIds: Set<string> }plus module-level dedup maps that are not per-thread (e.g. dequeue timers, narrative inflight).- The message cache becomes the LRU of
ThreadRecords evicted fromrecords.MessageCacheSnapshotandThreadRecordcollapse to one type.
Sequencing
The refactor lands in this order. Each step is independently shippable.
- Extract
ThreadHydratorwith the current state shape.loadMessagesbecomes a forward;prefetchThreadbecomes a forward. No state-shape change. This is where the deep module gets its tests. - Move
SnapshotBuilderandAuxiliaryHydratorinside the hydrator. Cache-hit and cache-miss branches inside the hydrator share one auxiliary fanout. The thin-snapshot fixup deletes. - Migrate per-thread state to
Map<threadId, ThreadRecord>. Selectors acrossMessageList,Composer, narrative components update to read from one record. Done behinduseShallowdiscipline. - Collapse
messageCacheinto the hydrator. Cache becomes the LRU of evicted records;MessageCacheSnapshotdeletes. - Delete
prefetch.tsif its content reduces to a single debounced call.
Contract details
- The cache-hit optimisation today restores synchronously and triggers a non-blocking auxiliary refresh. That behaviour is preserved —
hydrate(threadId, "active")resolves with the cachedThreadRecordsynchronously when present and dispatches the auxiliary refresh as a tail call. - Running threads (
runningThreadIds.has(threadId)) keep their volatile state through a load. The hydrator preserves tool calls, streaming buffers, and start times for a running thread; non-running threads get them cleared. - The narrative-batch prefetch (
listNarrativeBatchfor the last 20 assistant messages) lives inside the hydrator. The fallback to per-messageloadNarrativeForMessageon batch failure is preserved. - The load-epoch monotonic counter survives the move and lives on the
ThreadRecorditself; the rule (any in-flightloadOlderMessageswhose epoch does not match the current epoch is discarded) is unchanged. - The 2-second TTL on the auxiliary refresh becomes a constant of
AuxiliaryHydrator. It can be made configurable later but is not part of the initial interface.
Out of interface
These are internal seams of ThreadHydrator and may be reshaped freely during implementation: the inflight-dedup map for narrative fetches, the queueMicrotask flush of pending text deltas, the renderer-cache-pressure trigger.
Testing Decisions
What makes a good test here
Tests must exercise externally observable behaviour through ThreadHydrator's public interface, not its internal sub-modules. A test that asserts "SnapshotBuilder.build was called with X" is a bad test; a test that asserts "after hydrate(threadId, "active") resolves, the store contains the expected messages and the auxiliary fanout fired exactly once" is a good one. The in-memory transport adapter is the seam — tests inject it, drive the hydrator, and assert on the resulting store state.
Implementation details out of bounds for tests: the specific RPC call order, the internal queueMicrotask boundary, the in-flight dedup map, the exact Promise.all shape inside the cache-miss branch.
Modules tested
ThreadHydrator — full behavioural surface:
- Cache hit: returns a populated
ThreadRecordsynchronously, fires the auxiliary refresh only once per TTL window. - Cache miss: fetches messages + snapshots + narratives, commits one canonical record, populates the cache.
- Stale auxiliary refresh: a second
hydrate(threadId)within the TTL window does not re-fanout. - Running thread: volatile state (tool calls, streaming buffers, start times) survives a
hydratecall. - Cross-thread race: a
hydrate(A)followed immediately byhydrate(B)does not commit A's RPC result over B's state. - Background mode:
hydrate(threadId, "background")populates the cache but does not changecurrentThreadIdand does not update the live store. - Narrative-batch failure falls back to per-message fetches.
- Load-epoch bumps on each
hydrate, discarding staleloadOlderMessagesresponses.
AuxiliaryHydrator — focused unit:
- TTL gate: two calls within the freshness window result in one fanout.
- Force flag bypasses the TTL gate.
- Diff-before-set: identical task / permission payloads do not trigger a re-render-worthy
set(). - Failure of one fanout call does not cancel the others.
Prior art
The repo already has MessageList.thread-switch.test.tsx (346 lines), which exercises the thread-switch flow end-to-end with the existing transport. That suite is the precedent for in-memory transport setup, store seeding, and store-state assertions. The new hydrator tests follow the same shape. The existing suite must continue to pass after the extraction; it is the regression net for the refactor.
For the per-record migration, the prior art is taskStore, planStore, queueStore, and toastStore — sibling stores that already model per-thread state without participating in the message-loading flow. Their selectors and useShallow patterns are the precedent for what MessageList and Composer will look like against the new shape.
Out of Scope
- Splitting
threadStoreinto multiple per-concern stores (streaming, tool-calls, plan-questions, handoffs each as their own store). The deepening here removes the friction without re-shaping the store boundaries. - Reworking the WebSocket push-event filtering. Push events today filter by
currentThreadIdat the handler — that arrangement is preserved. - Renaming
ThoughtSegmenttoNarrationSegment. Tracked separately as a planned rename perCONTEXT.md. - Adjusting the 100-message initial fetch size, the 50-message older-page size, or the 200-message sliding-window cap. The constants survive the move unchanged.
- Adjusting the 15-thread cache capacity default or the user-configurable cache-size setting. The capacity policy stays on the cache adapter; the hydrator does not redefine it.
- Backend changes (
message-repo,ws-router,MessageRepo.listByThread). The server interface is unchanged.
Further Notes
- Branch
perf/thread-loadis currently empty againstmain; this PRD defines the work that lands on it. - Treat the five-step sequencing as the implementation contract. Each step must be independently mergeable so the refactor can pause cleanly between steps.
Activity
refactor(threadStore): extract ThreadHydrator + collapse per-thread m…