Skip to content

Paging cursor is a singleton in a store whose convention is slot-keyed Records #4100

Description

@rnoack1

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.

Activity

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

    area: dashboardDashboard UI and its backend handlersbugSomething is not workingneeds-investigationTriage: requires deep analysis before a fixrefactorInternal restructuring with no behavior change

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions