Skip to content

feat(desktop): add workspace rail unread observer#1428

Merged
wesbillman merged 11 commits into
mainfrom
pinky/workspace-unread-observer
Jul 1, 2026
Merged

feat(desktop): add workspace rail unread observer#1428
wesbillman merged 11 commits into
mainfrom
pinky/workspace-unread-observer

Conversation

@wesbillman

@wesbillman wesbillman commented Jul 1, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • add a Discord-style workspace rail on the far-left of the desktop shell
  • add read-only inactive-workspace polling for rail unread state
  • show dot for any unread activity and a numeric badge for mention count
  • keep inactive observers isolated from the active relay singleton and active workspace caches
  • cache the channel list per relay so switching workspaces paints the sidebar instantly (see "Instant channel-list paint" below)

Testing

pnpm --dir desktop typecheck
cd desktop && node --import ./test-loader.mjs --experimental-strip-types --test \
  src/features/workspaces/workspaceUnreadObserver.test.mjs \
  src/features/channels/readState/readStateManager.test.mjs \
  src/features/sidebar/ui/WorkspaceRail.test.mjs \
  src/features/channels/channelSnapshot.test.mjs
pnpm --dir desktop check:file-sizes

Full desktop suite green: just desktop-check, just desktop-typecheck, just desktop-test (1428/1428).

Rail e2e coverage added in desktop/tests/e2e/workspace-rail.spec.ts and validated by Brain one test at a time locally:

  • rail shows a button per workspace and marks the active one
  • clicking a workspace persists the active workspace id
  • rail hides with a single workspace

Instant channel-list paint

Each workspace mounts a fresh React-Query client, so switching workspaces (or switching back to one just visited) started cold and blocked the sidebar on a multi-round-trip get_channels(). This adds a per-relay channel snapshot:

  • On every successful getChannels(), the sorted channel list is persisted to localStorage keyed by normalized relay URL (channelSnapshot.ts, mirroring the existing selfProfileStorage pattern — versioned, tolerant reads/writes).
  • useChannelsQuery seeds from that snapshot as initialData with initialDataUpdatedAt: 0, so the sidebar paints instantly with real data (names, types, unread) while the live fetch revalidates immediately in the background.
  • Snapshots are GC'd on workspace removal, alongside the self-profile cache cleanup.

Scoped by relay URL, which matches today's workspace de-dupe (one relay = one workspace). If multiple identities on the same relay are ever supported, the key gains a :pubkey suffix — a one-line change.

All in desktop/src — no relay/DB/Rust changes. Covered by channelSnapshot.test.mjs (round-trip, key normalization, malformed/wrong-version tolerance, storage-failure tolerance).

Notes

Cross-relay unread/mention e2e is intentionally skipped for this PR because the current harness does not run two live relays. Observer behavior is covered by focused unit tests.

Screenshots to be uploaded in a follow-up PR comment.

@wesbillman

Copy link
Copy Markdown
Collaborator Author

Workspace rail — screenshots

Discord/Slack-style workspace rail on the far left. Shows here with 3 workspaces (Alpha active + highlighted, Bravo, Charlie) and the + add button.

Sidebar expanded
rail expanded

Sidebar collapsed — the rail stays pinned on the far left, fully visible and unshifted (fixes the reported bug where the rail's space was reserved but its buttons were pushed off-screen).
rail collapsed

Unread dot + mention badge aren't visible in these shots (mock harness reports no unread); those render on ready state per the observer. Covered by the observer unit tests + workspaceRailIndicators unit tests.

Note: these reflect fix commit 6ce0b37e (rail moved to a sibling of SidebarProvider), which still needs to land on this PR branch — see the thread.

Add a Discord/Slack-style workspace rail on the far left of the app: one
button per workspace with initials, active highlight, click-to-switch
(reusing the existing switchWorkspace plumbing), and an Add Workspace
button. The rail auto-hides with a single workspace and is mounted as its
own column beside the sidebar so it stays visible when the sidebar is
collapsed. On macOS it starts its buttons below the traffic lights and the
top-chrome controls sit just past them.

Unread is observed for inactive workspaces without touching the active
relay singleton: a separate read-only relay client polls each inactive
workspace on an interval, reuses the shared NIP-RS read-state snapshot
parser, and reports { hasUnread, count (mentions), state } per workspace.
The rail renders a dot for any unread and a mention-count badge, and never
reports "no unread" for a relay it could not observe (unknown/error states).

Covered by rail + observer unit tests and rail e2e (visibility, switching,
collapse, macOS traffic-light clearance).

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman force-pushed the pinky/workspace-unread-observer branch from 3708248 to 0a75e77 Compare July 1, 2026 15:49
wesbillman and others added 10 commits July 1, 2026 09:55
Two small rail follow-ups:
- Remove the sidebar search header's top padding so the search bar
  top-aligns with the channel header on the right (was ~9px lower).
- Derive workspace-button initials from a punctuation-stripped name so a
  name like "B (relay)" shows "BR" instead of "B(". Add unit coverage.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Narrow the rail column (w-14 -> w-12) so the workspace buttons sit closer
to the sidebar and reduce the gap between the rail and the search bar.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Right-align the workspace squares within the rail (items-end) so they sit
close to the search bar / sidebar content instead of centered with a gap,
and so the active/unread pill lands at the rail's left edge instead of
clipping off the window. Closes the excess space between the rail buttons
and the search input.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Revert the buttons to centered in the rail so they keep an even margin on
both sides — right-aligning (items-end) made them too tight against the
main content panel when the sidebar is collapsed. Nudge the active/unread
pill so it renders at the rail's left edge instead of clipping.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Shrink the workspace button box h-10 -> h-9 so the square's top aligns
with the sidebar search input (the h-9 square was centered in an h-10
box, sitting 2px low). Recolor the active/unread pill from
bg-sidebar-foreground to bg-primary so it uses the accent.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Move the active/unread pill from left-[-4px] to left-[-6px] so it sits
flush at the rail's left edge with a small gap before the button, instead
of abutting it (now visible since the pill uses the accent color).

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Add a workspaceRail preview feature so the far-left rail is hidden by
default and opt-in via Settings > Experiments. AppShell gates both the
rail render and the hasWorkspaceRail layout flag on it, so with the flag
off the top chrome falls back to its normal traffic-light offset.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Apply biome formatting to the workspace rail/observer files and remove an
unused workspacesKey dependency flagged by the exhaustive-deps lint in
useWorkspaceUnread.

Co-authored-by: Pinky <44b8e82baa6e0e254e0208d68f335c283c94e7b78dd1fa10d5a49d3f13dd0435@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
Each workspace mounts a fresh React-Query client, so switching workspaces
(or back to one just visited) started cold and blocked the sidebar on a
multi-round-trip get_channels(). Snapshot the last successful channel list
to localStorage keyed by relay URL and seed useChannelsQuery with it as
already-stale initialData: the sidebar paints instantly from the snapshot
while the live fetch revalidates. Snapshots are GC'd on workspace removal,
mirroring the existing self-profile cache.

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
@wesbillman wesbillman merged commit ce1f13e into main Jul 1, 2026
25 checks passed
@wesbillman wesbillman deleted the pinky/workspace-unread-observer branch July 1, 2026 17:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant