What
The older-history paging cursor is a singleton in ChatState, while almost everything else that
varies per chat is a slot-keyed Record. That mismatch is the root cause behind a run of races in
chatSlice.ts, and it forces an ownership protocol to exist at all.
Today the cursor is five scalar fields (website/src/store/chatSlice.ts:535-545):
| Field |
Purpose |
slotHasMore |
is there more history to page |
slotOldestIndex |
the offset the next page is fetched at |
slotCursorKey |
which chat the two values above belong to |
slotSwitchRequestId |
a switch is in flight and owns the cursor |
slotSwitchTarget |
which chat that switch is going to install a cursor for |
The last three exist only to answer "is this cursor mine?" and "is someone about to replace it?".
Meanwhile the same state holds 9 slot-keyed maps that never need to ask those questions, because
the map key already answers them: slotMessages, slotActivity, slotRun, slotHydrated,
slotSide, slotSideClosed, slotStatusDetail, slotContextPct, slotContextTokens.
Why it matters
Because the cursor is global, every reducer that installs messages has to re-coordinate it, and the
failure mode is silent — paging quietly does nothing, or pages at an offset belonging to a different
chat. Recent review of #4001 turned up several instances of that one shape in a row, each needing its
own guard:
- a superseded older-history fetch landing after a chat switch, prepending another chat's page
- a background refresh landing inside a switch window and re-validating a cursor the switch was
about to replace
- the clear reducers leaving the cursor in a state a waiting pinned-message jump could not resolve
Each was fixed, and all 8 cursor writers now go through one setPagingCursor helper that carries the
ownership check internally, so a writer cannot update one field and forget another. But the invariant
is still enforced by a helper rather than made impossible, and the two claim fields are pure
bookkeeping that exists only because the cursor is shared.
Proposal
Replace the singleton with a per-chat record, so the map key is the cursor key:
slotCursor: Record<string, { hasMore: boolean; oldestIndex: number }>
That change:
- deletes
slotCursorKey outright — a cursor read for chat X can only ever be chat X's
- deletes
slotSwitchRequestId / slotSwitchTarget as cursor-ownership state, because a switch
writing chat X's entry cannot corrupt chat Y's
- reduces the remaining ownership question to the single genuine case: a chat being reloaded in
place, where the new cursor should replace the old one
- brings the cursor in line with the 9 sibling maps, so the next person adding a message-installing
reducer has nothing to remember
Scope / why not in #4001
It touches every reader of the cursor, not just the writers, so folding it into a focused race fix
would have turned that fix into a refactor. Filing it separately as the structural follow-up both
reviewers of #4001 independently identified as the real fix.
Suggested sequencing: land the per-chat record and migrate readers first, then delete the three
now-unused scalars in a second change, so the two diffs stay reviewable.
What
The older-history paging cursor is a singleton in
ChatState, while almost everything else thatvaries per chat is a slot-keyed
Record. That mismatch is the root cause behind a run of races inchatSlice.ts, and it forces an ownership protocol to exist at all.Today the cursor is five scalar fields (
website/src/store/chatSlice.ts:535-545):slotHasMoreslotOldestIndexslotCursorKeyslotSwitchRequestIdslotSwitchTargetThe last three exist only to answer "is this cursor mine?" and "is someone about to replace it?".
Meanwhile the same state holds 9 slot-keyed maps that never need to ask those questions, because
the map key already answers them:
slotMessages,slotActivity,slotRun,slotHydrated,slotSide,slotSideClosed,slotStatusDetail,slotContextPct,slotContextTokens.Why it matters
Because the cursor is global, every reducer that installs messages has to re-coordinate it, and the
failure mode is silent — paging quietly does nothing, or pages at an offset belonging to a different
chat. Recent review of #4001 turned up several instances of that one shape in a row, each needing its
own guard:
about to replace
Each was fixed, and all 8 cursor writers now go through one
setPagingCursorhelper that carries theownership check internally, so a writer cannot update one field and forget another. But the invariant
is still enforced by a helper rather than made impossible, and the two claim fields are pure
bookkeeping that exists only because the cursor is shared.
Proposal
Replace the singleton with a per-chat record, so the map key is the cursor key:
That change:
slotCursorKeyoutright — a cursor read for chat X can only ever be chat X'sslotSwitchRequestId/slotSwitchTargetas cursor-ownership state, because a switchwriting chat X's entry cannot corrupt chat Y's
place, where the new cursor should replace the old one
reducer has nothing to remember
Scope / why not in #4001
It touches every reader of the cursor, not just the writers, so folding it into a focused race fix
would have turned that fix into a refactor. Filing it separately as the structural follow-up both
reviewers of #4001 independently identified as the real fix.
Suggested sequencing: land the per-chat record and migrate readers first, then delete the three
now-unused scalars in a second change, so the two diffs stay reviewable.