Skip to content

epic: deepen thread load and switch — extract ThreadHydrator #522

Closed

Description

@chuks-qua

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:

  • ChatView calls 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.
  • loadMessages either 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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. As a developer, I want the cross-store writes into taskStore and planStore to live behind the hydrator's seam, so that "load a thread" is not aware of every store it touches.
  6. 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.
  7. 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.
  8. 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.
  9. 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.
  10. As an end user, I want recently-visited threads to restore instantly, so that rapid back-and-forth navigation stays smooth.
  11. 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.
  12. 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.
  13. As an end user with MessageList.thread-switch behaviour today, I want that behaviour preserved exactly, so that prior testing investment carries over.
  14. As a developer, I want the messageCache module to either rename to reflect its new role (LRU of evicted ThreadRecords) or absorb into the hydrator, so that there is one clear home for "what's in memory for this thread."
  15. 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.
  16. As a developer, I want the lastHydratedByThread TTL gate to live inside the auxiliary fanout, so that the policy is not split across two call sites with two different defaults.
  17. As a developer, I want prefetch.ts to 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 populated ThreadRecord. One entry point for ChatView's active-thread effect, for hover prefetch, and for any other caller that today reaches into loadMessages or prefetchThread.
  • 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 latestTurnWithChanges derivation, 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.
  • threadStore state 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 from records. MessageCacheSnapshot and ThreadRecord collapse to one type.

Sequencing

The refactor lands in this order. Each step is independently shippable.

  1. Extract ThreadHydrator with the current state shape. loadMessages becomes a forward; prefetchThread becomes a forward. No state-shape change. This is where the deep module gets its tests.
  2. Move SnapshotBuilder and AuxiliaryHydrator inside the hydrator. Cache-hit and cache-miss branches inside the hydrator share one auxiliary fanout. The thin-snapshot fixup deletes.
  3. Migrate per-thread state to Map<threadId, ThreadRecord>. Selectors across MessageList, Composer, narrative components update to read from one record. Done behind useShallow discipline.
  4. Collapse messageCache into the hydrator. Cache becomes the LRU of evicted records; MessageCacheSnapshot deletes.
  5. Delete prefetch.ts if 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 cached ThreadRecord synchronously 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 (listNarrativeBatch for the last 20 assistant messages) lives inside the hydrator. The fallback to per-message loadNarrativeForMessage on batch failure is preserved.
  • The load-epoch monotonic counter survives the move and lives on the ThreadRecord itself; the rule (any in-flight loadOlderMessages whose 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 ThreadRecord synchronously, 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 hydrate call.
  • Cross-thread race: a hydrate(A) followed immediately by hydrate(B) does not commit A's RPC result over B's state.
  • Background mode: hydrate(threadId, "background") populates the cache but does not change currentThreadId and does not update the live store.
  • Narrative-batch failure falls back to per-message fetches.
  • Load-epoch bumps on each hydrate, discarding stale loadOlderMessages responses.

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 threadStore into 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 currentThreadId at the handler — that arrangement is preserved.
  • Renaming ThoughtSegment to NarrationSegment. Tracked separately as a planned rename per CONTEXT.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-load is currently empty against main; 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

  1. added
    perfPerformance improvements
    epicEpic: multi-feature business capability
    ready-for-agentTriaged; ready for an autonomous coding agent to pick up
    on May 27, 2026
  2. added a commit that references this issue on May 28, 2026
    05c3304
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    architectureArchitectural changesepicEpic: multi-feature business capabilityperfPerformance improvementsready-for-agentTriaged; ready for an autonomous coding agent to pick up

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions