Skip to content

refactor(chat): one module owns the loaded thread window - #29605

Draft
chrisnojima wants to merge 3 commits into
masterfrom
nojima/HOTPOT-arch-01-thread-window
Draft

refactor(chat): one module owns the loaded thread window#29605
chrisnojima wants to merge 3 commits into
masterfrom
nojima/HOTPOT-arch-01-thread-window

Conversation

@chrisnojima

@chrisnojima chrisnojima commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Problem

One invariant — which response may become the loaded thread window — was implemented as six independent nonces across four modules:

Nonce Lived in
clearVersion, liveUpdateVersion, windowCleared, windowGateOwner thread-context.tsx
nextLoadID (module global) thread-load.tsx
threadLoadGeneration, mountedRef thread-load-status-context.tsx

None could answer the question alone, so the modules carried ~80 lines of comments explaining why each was insufficient without the others. thread-load-status-context owned "is this load still current?" but was mounted below the store whose loads it arbitrated, so the answer had to be handed back up as a callback struct on every load call.

LoadMoreMessagesParams had 12 fields, two documented "Internal: callers leave it unset" — an interface nearly as complex as its implementation.

Change

chat/conversation/thread-window.tsx owns the loaded window and its arbitration.

requestWindow({anchor, reason})   // anchor: 'newest' | 'older' | 'newer' | {centeredOn}
useThreadWindow() // → {generation, loaded, moreToLoadBack, moreToLoadForward, ordinals}

Gate claim/release, clear-version comparison, cached-vs-full pass reconciliation, disjoint-page refusal and the back-page retry chain are now implementation. LoadMoreMessagesParams becomes an unexported WindowLoad, so retryBelowMessageID/retryCount are genuinely private. thread-load-status-context.tsx is deleted.

Six nonces become one store-visible generation (drives both the response gate and the desktop dataKey) plus a module-private WindowGate.

Deliberate equivalences, not silent drops

  • forceContainsLatestCalc deleted — it was set exactly when centered was, so centered && forceContainsLatestCalc ≡ centered.
  • knownRemotes deleted — no caller set it; thread-rpc already defaults it to [].
  • isCurrentLoad() now also refuses after a clear, so a superseded in-flight load skips its post-await work. The reload the clear issued hits the same conditions one round-trip later.
  • Gate release keys on generation, not on mounted — an unmounted load is precisely the one with nothing after it, so it must still release.

liveUpdateVersion deliberately did not fold into generation: it encodes content churn inside one window (a streamed reaction landing under a focus refresh), not a new window. Folding it would change the desktop dataKey on every incoming message and remount LegendList.

Validation

lint:all clean — 0 bailed out, 0 whole-props deps, tsc clean on desktop and native.
jest --runInBand230 suites / 2238 tests (master baseline 230 / 2236).

1,549 lines of tests moved out of thread-context.test.tsx and thread-load.test.tsx into thread-window.test.tsx; net test count went up by 2. thread-load.test.tsx's ~160 lines of fabricated actions and hand-set windowCleared/windowGateOwner are gone — the gate suite now drives the real store, actions and gate.

This comment was marked as outdated.

chrisnojima and others added 3 commits September 11, 2026 09:57
Which response may become the loaded window was arbitrated by six nonces
spread across four modules: clearVersion, liveUpdateVersion, windowCleared
and windowGateOwner in the thread store, a nextLoadID module global in
thread-load, and a load generation the thread-load-status provider handed
back UP to the store that owns the loads it was arbitrating.

thread-window.tsx now owns it. Callers name a place in the thread -
requestWindow({anchor, reason}) with anchor 'newest' | 'older' | 'newer' |
{centeredOn} - and read the window back through useThreadWindow(). The gate
claim and release, the clear-and-reload, the refusal of a disjoint page, the
cached-vs-full pass reconciliation, the back-page retry chain, the load
throttle, the scroll repeat gates and the is-this-load-current check are all
implementation.

clearVersion becomes `generation`, the one nonce: it moves when the window is
dropped and when the conversation changes, and both the response gate and the
desktop list's dataKey read it. windowGateOwner and nextLoadID become a
private gate record in the one module that consults them.
ThreadLoadStatusOptions is gone from every call site, and with it the twelve
field LoadMoreMessagesParams. liveUpdateVersion stays: it is content churn
inside one window rather than a new window, and folding it into the
generation would remount the thread list on every incoming message.

thread-load-status-context.tsx is deleted. Its ChatThreadsStale and
ChatInboxSynced listeners join the other chat notification listeners in
thread-engine; its mount-time selectConversation joins the load it issues;
useThreadLoadStatus, the one enum load-status.tsx consumes, moves as is.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015rccpV5nLxxC5opF5xzrz7
The refusal exists because a pass that does not join the loaded window is
poisonous twice over: merging it produces the disjoint window, and reconciling
against it takes out every row between the few messages it happens to carry. The
disjoint pass was the one case that skipped the pass without arming it.

That is enough on its own, because the passes are not independent. The service
records the cached thread as sent before it marshals it, so `sawCachedReport` is
already set by the time the pass is dropped - and the full pass behind a warm
cache is INCREMENTAL, carrying only what changed. So the load continued with the
prune armed and nothing in `carried` from the pass that was dropped, and the full
pass reconciled a centered window against the handful of rows it held: everything
between them deleted, an old window marked read on the way out.

The test drives it the way it actually happens - a cached pass from newer than
the window the reader is sitting on, then a full pass spanning it - and without
the refusal the two rows between the span's ends are gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYPqG8z11HmGRn5MHSu89S
The architecture doc still sent thread loads through `loadMoreMessages` ->
`loadConversationThreadMessages` in thread-load.tsx. Both are gone as of this
branch: callers name a place in the thread with requestWindow and read it back
with useThreadWindow, and runThreadWindowLoad issues the RPC behind that.

thread-load.tsx is still there and still worth naming, but it no longer holds the
RPC - what is left is what both the window and the row derivation draw on: meta
and current-user lookups, exploding-mode-from-gregor, snapshot accessors,
pagination sizing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RYPqG8z11HmGRn5MHSu89S
@chrisnojima
chrisnojima force-pushed the nojima/HOTPOT-arch-01-thread-window branch from 3cd2f79 to fbc9762 Compare September 11, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants