fix(desktop): fix workspace rail badge disappearance and persist mark-as-unread#1747
Merged
wpfleger96 merged 4 commits intoJul 11, 2026
Merged
Conversation
…ker advance
When switching workspaces, the router URL persisted into the new workspace
context because the router is a module singleton (createHashHistory lives
outside any React component). ChannelScreen's read effect kept firing
markChannelRead({ topLevelOnly: true }) for the previous workspace's channel,
advancing its NIP-RS read markers via a 5s debounce publish. The next 30s
rail poll of the now-inactive workspace saw the advanced markers and returned
hasUnread:false, dropping the rail badge.
Introduce handleSwitchWorkspace in AppShell that calls goHome() before
switchWorkspace(). Clearing the URL unmounts ChannelScreen for the previous
workspace before the relay is torn down, eliminating the spurious marker
advance. Applied to all three workspace-switch call sites (WorkspaceRail,
sidebar WorkspaceSwitcher, add-workspace flow) via a single memoized handler.
Co-authored-by: Will Pfleger <pfleger.will@gmail.com>
Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
mark-as-unread (right-click → Mark as Unread) previously stored only an in-memory React.useRef flag (forcedUnreadRef) that was cleared on reload and invisible to the workspace rail observer which reads only NIP-RS relay state. Introduce forcedUnreadStore — a per-pubkey localStorage store using the existing makeRootIdStore pattern — keyed by channelId. Three write-through clear points mirror the existing in-memory clear semantics: - channel-open: markChannelRead (topLevelOnly or explicit) - identity/relay change: reset effect loads from store for new pubkey - cross-device synced read advance: drainSyncedAdvances effect markAllChannelsRead also drains and persists the store. The rail observer (fetchWorkspaceUnread) reads the forced-unread set for the observed workspace's pubkey via a readForcedUnread injectable and ORs matching channels into hasUnread before the relay fetch loop. Muted channels are still skipped first so the mute wins. No extra relay fetches needed — the store is already drained at write time. Add 4 observer tests covering: forced-unread lights dot, non-member channel ignored, muted channel wins, empty-set falls through to relay gate. Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…nt stale cross-device state Thufir Pass 1 fixes: 1. Rewrite forcedUnreadStore from a makeRootIdStore id-set to a purpose-built per-pubkey record store (ForcedUnreadMap = Record<string, number | null>). Each entry stores the channel's own NIP-RS read marker at force-time (markerAtWhenForced) so the observer can detect when a later cross-device read has covered the force. 2. workspaceUnreadObserver: compute readAt first in the per-channel loop, then gate the forced-unread OR on readAt === null || (markerAtWhenForced !== null && readAt <= markerAtWhenForced). This ensures stale forced-unread entries do not light the rail after a cross-device read advances the NIP-RS marker past the stored baseline. The drain path in useUnreadChannels only runs while the workspace is active, so the observer cannot rely on the store being pruned for inactive workspaces. 3. useUnreadChannels: markChannelUnread captures getOwnTimestamp(channelId) as the baseline. All four write-through points (markChannelUnread, markChannelRead, drainSyncedAdvances, markAllChannelsRead) updated to operate on the record shape (delete key instead of Set.delete). forcedUnreadRef.current shape updated to ForcedUnreadMap throughout. Sidebar consumer updated from .has() to 'in' operator. 4. AppShell: guard handleSwitchWorkspace so goHome() only fires when the selected workspace differs from the currently active one, preventing unexpected channel-clear on re-click of the active workspace rail entry. 5. forcedUnreadStore comment corrected: identity change swaps the in-memory map to the current pubkey's data; it does NOT wipe persisted state. 6. Three new observer tests: forced+marker-advanced-past-baseline → false, forced+marker-not-advanced → true, forced-null-baseline+readAt-present → false. Existing four forced-unread tests updated for new record shape. File-size override updated to 1022 (gate uses split count, not wc -l). Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
…otype keys Replace in-operator membership tests on ForcedUnreadMap plain objects with Object.hasOwn(), which only matches own enumerable properties and cannot be confused by inherited prototype keys such as toString or constructor. The map is deserialized from user-writable localStorage, so own-property checks are the correct guard even though real hex channel ids can't collide with Object.prototype in practice. Add ES2022 to tsconfig lib (not target) to type Object.hasOwn without changing the ES2020 compilation output. Five call sites updated: - workspaceUnreadObserver.ts: channel.id in forcedUnreadMap - useUnreadChannels.ts (x4): channelId/channel.id in forcedUnreadRef.current (drainSyncedAdvances loop, markChannelRead, markChannelUnread negated guard, sidebar unread memo) Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
tlongwell-block
pushed a commit
that referenced
this pull request
Jul 11, 2026
Co-authored-by: Tyler Longwell <tlongwell@block.xyz> Signed-off-by: Tyler Longwell <tlongwell@block.xyz> * origin/main: fix(desktop): preserve archived observer history (#1752) fix(dev): install Lefthook hooks into shared .git/hooks dir (#1751) fix(desktop): surface codex config-parse failures and -32603 internal errors clearly (#1745) fix(desktop): fix workspace rail badge disappearance and persist mark-as-unread (#1747) chore(desktop): remove container-only npm-preflight E2E harness (#1749) fix(desktop): preflight npm prefix writability in doctor installs (#1732)
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.
What
Two independent rail-unread fixes:
Fix 1: Clear URL on workspace switch (A→B→A→B badge vanishes)
The router is a module singleton ( outside any React component), so switching workspaces did not reset .
ChannelScreen's read effect kept firingmarkChannelRead({ topLevelOnly: true })for the previous workspace's channel — advancing its NIP-RS read markers via a 5s debounce publish. The next 30s rail poll saw the advanced markers and returnedhasUnread:false, dropping A's badge after A→B→A→B.Fix: wrap all three
onSwitchWorkspacecall sites in a memoizedhandleSwitchWorkspacethat callsgoHome()first, clearing the URL before the workspace teardown.ChannelScreenunmounts cleanly, no spurious marker advance.Fix 2: Persist mark-as-unread to localStorage + show in rail
markChannelUnread(right-click → Mark as Unread) wrote only to an in-memoryReact.useRefflag — cleared on reload, invisible to the rail observer which reads only NIP-RS relay state.Fix: introduce
forcedUnreadStore— a per-pubkey localStorage store using the existingmakeRootIdStorepattern. Three write-through clear points mirror the existing in-memory semantics: channel-open, identity change, and cross-device synced read advance. The rail observer reads the store and ORs matching channels intohasUnread(muted channels still win).NIP-RS markers are monotonic and cannot represent a retrograde unread state, so localStorage is the honest best-effort for per-device persistence. No relay sync.
Tests
Gates
just desktop-typecheck✅just desktop-build✅just desktop-check✅ (2 pre-existing warnings, 0 errors)just desktop-test✅ 2496 pass, 0 fail