refactor(chat): one module owns the loaded thread window - #29605
Draft
chrisnojima wants to merge 3 commits into
Draft
refactor(chat): one module owns the loaded thread window#29605chrisnojima wants to merge 3 commits into
chrisnojima wants to merge 3 commits into
Conversation
This was referenced Sep 9, 2026
chrisnojima
added this pull request to stack #29616
September 9, 2026 19:40
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
force-pushed
the
nojima/HOTPOT-arch-01-thread-window
branch
from
September 11, 2026 13:57
3cd2f79 to
fbc9762
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
One invariant — which response may become the loaded thread window — was implemented as six independent nonces across four modules:
clearVersion,liveUpdateVersion,windowCleared,windowGateOwnerthread-context.tsxnextLoadID(module global)thread-load.tsxthreadLoadGeneration,mountedRefthread-load-status-context.tsxNone could answer the question alone, so the modules carried ~80 lines of comments explaining why each was insufficient without the others.
thread-load-status-contextowned "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.LoadMoreMessagesParamshad 12 fields, two documented "Internal: callers leave it unset" — an interface nearly as complex as its implementation.Change
chat/conversation/thread-window.tsxowns the loaded window and its arbitration.Gate claim/release, clear-version comparison, cached-vs-full pass reconciliation, disjoint-page refusal and the back-page retry chain are now implementation.
LoadMoreMessagesParamsbecomes an unexportedWindowLoad, soretryBelowMessageID/retryCountare genuinely private.thread-load-status-context.tsxis deleted.Six nonces become one store-visible
generation(drives both the response gate and the desktopdataKey) plus a module-privateWindowGate.Deliberate equivalences, not silent drops
forceContainsLatestCalcdeleted — it was set exactly whencenteredwas, socentered && forceContainsLatestCalc ≡ centered.knownRemotesdeleted — no caller set it;thread-rpcalready 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.liveUpdateVersiondeliberately did not fold intogeneration: it encodes content churn inside one window (a streamed reaction landing under a focus refresh), not a new window. Folding it would change the desktopdataKeyon every incoming message and remount LegendList.Validation
lint:allclean —0 bailed out, 0 whole-props deps, tsc clean on desktop and native.jest --runInBand— 230 suites / 2238 tests (master baseline 230 / 2236).1,549 lines of tests moved out of
thread-context.test.tsxandthread-load.test.tsxintothread-window.test.tsx; net test count went up by 2.thread-load.test.tsx's ~160 lines of fabricated actions and hand-setwindowCleared/windowGateOwnerare gone — the gate suite now drives the real store, actions and gate.