Skip to content

fix(channels): keep client-only state (PSK selection, My Channels, unread) across a channel-list refresh #152

Closed

Description

@dborup

Summary

loadChannels() in public/channels.js replaces the channel list with the server snapshot and carries nothing across. State that only exists in the tab is lost on every refresh, and an open PSK conversation is closed. Same root cause as upstream Kpa-clawbot/CoreScope#2095 (proposed fix Kpa-clawbot/CoreScope#2096); this fork has one extra trigger (shared-channel approval).

Where (master 571fb2c8)

loadChannels() (around line 1856): fetch → map lastActivityMs → ChannelProposals.mergeApprovedChannels(...) → sort → renderChannelList() → reconcileSelectionAfterChannelRefresh(). mergeUserChannels() is not called here.

reconcileSelectionAfterChannelRefresh() (around line 249) clears the selection when selectedHash is not in channels. A user:* PSK hash is never in the server snapshot, so it always is cleared.

Triggers:

  1. Region filter change (RegionFilter.onChange, around line 1013).
  2. "Show encrypted channels" toggle (mc-channels-show-encrypted-changed, around line 1328).
  3. Fork only: shared-channel approval (ChannelProposals.mount({ onApproved }), around line 1047) calls mergeUserChannels() only after loadChannels() has already reconciled.

Symptoms

With a user-added PSK channel open:

  • the conversation closes ("Choose a channel from the sidebar"), messages is emptied and the URL is rewritten to #/channels;
  • the My Channels section disappears until something re-runs mergeUserChannels();
  • unread badges reset. Unread is only bumped for live-decrypted packets (decryptedLocally), i.e. mainly user:* rows, so those are the badges that matter;
  • the sidebar preview of a user:* row resets to "Encrypted — click to decrypt".

Proposed fix

In loadChannels(), capture the previous list, then:

fresh snapshot → mergeApprovedChannels → mergeUserChannels() → merge client state from the previous list → sort → render → reconcile

  • A pure helper (like mergeWsAppendedIntoRest()) carries unread, userAdded, userLabel by hash, for server rows and user:* rows, and never resurrects a row the fresh list does not contain (the region filter must keep working).
  • Newer live activity (lastActivityMs, lastSender, lastMessage, messageCount, moved together) is decided by a local WS sequence, not by comparing timestamps: the WS path stamps rows with an incrementing counter, loadChannels() samples it when the request starts, and prev's activity is kept only if its stamp is newer. Comparing the browser's Date.now() with the server's lastActivity depends on clock skew (review finding on the upstream PR).
  • The onApproved path then no longer needs its own mergeUserChannels() call (keep or remove deliberately, with a test).

Acceptance criteria

  • With a PSK channel open: region change, show-encrypted toggle and shared-channel approval keep the selection, the messages and the URL.
  • My Channels rows and labels survive every refresh.
  • Unread and preview on user:* rows survive a refresh.
  • A server channel the new snapshot omits (region filter) does not come back.
  • A WS update that arrives while the request is in flight wins regardless of browser/server clock skew; a stale client row never overrides a newer snapshot.
  • Inputs are not mutated; explicit unread: 0 is preserved.
  • Unit tests call the real loadChannels() (existing test hooks) and fail on master; mutation checks for the ordering, the sequence comparison and the user-row merge; a Playwright check of the region-change scenario with a PSK channel open, desktop and mobile width.

Non-goals

No server/API change; no fix for a brand-new WS channel dropped during the very first load (upstream leaves it too).

Activity

  1. dborup commented on Oct 4, 2026

    @dborup
    OwnerAuthor

    Deferred upstream assessment — 2026-10-04

    This existing issue covers the weekly upstream item; no duplicate is needed. Upstream PR 2096 is now merged.

    What it does / value: retains client-only channel state when a fresh server channel list arrives—My Channels, labels, unread state and newer live previews—so a refresh does not unexpectedly close an open PSK conversation or reset its sidebar state.

    What we already have: open #153 is our adaptation, with fork-specific shared-channel approval and sequence-based handling of concurrent WebSocket updates. #163 / open #166 track follow-ups; #154 separately covers overlapping requests returning in the wrong order. These are existing work, not reasons to copy upstream again.

    Why not a blind port: preserve our approved-channel merge and PSK behavior, distinguish client state from channels legitimately excluded by the current region, and avoid using browser/server timestamp comparison as an ordering guarantee. A brand-new WebSocket channel arriving during the first load is a separate case, not automatically solved by this upstream merge.

    Later checklist: review #153 and #166 together against current master; test region/encrypted-filter refresh and shared-channel approval with a PSK conversation open; cover unread, preview/count consistency, clock skew and overlapping loads; verify mobile and desktop behavior. Keep the scope boundary with #154 explicit.

    Recommendation for later consideration: continue evaluating the existing fork implementation rather than opening a second port. No merge or implementation decision is made here.

  2. dborup commented on Oct 4, 2026

    @dborup
    OwnerAuthor

    Fixed by #153 (merged as a2a151fb).

    loadChannels() now carries client-only state across a channel-list refresh: the PSK selection, My Channels and unread counts. This covers all three triggers: a region change, the "Show encrypted" toggle, and a shared-channel approval. Before the merge, #153 was brought up to date with master, and the review confirmed that none of master's channel changes were reverted.

    Leftovers:

  3. dborup commented on Oct 4, 2026

    @dborup
    OwnerAuthor

    The auto-approve refresh leftover is tracked in #232.

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

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions