From 0a75e77e5118688f2ead1452d3849ffd0317f679 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Wed, 1 Jul 2026 09:49:04 -0600
Subject: [PATCH 01/11] feat(desktop): workspace rail with cross-relay unread

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>
---
 desktop/playwright.config.ts                  |   1 +
 desktop/src/app/AppShell.tsx                  |  12 +-
 desktop/src/app/AppTopChrome.tsx              |  18 +-
 .../agent-memory/ui/MemorySection.tsx         | 193 ++-----
 .../channels/readState/readStateManager.ts    | 113 +---
 .../channels/readState/readStateSnapshot.ts   |  86 +++
 .../lib/channelSectionsStorage.test.mjs       |  12 -
 .../sidebar/lib/channelSectionsStorage.ts     |  32 +-
 .../sidebar/lib/channelSectionsSync.ts        |   1 -
 .../sidebar/lib/useChannelSections.ts         |  24 +-
 .../src/features/sidebar/ui/AppSidebar.tsx    |   9 +-
 .../sidebar/ui/ChannelSectionDialogs.tsx      | 123 +----
 .../sidebar/ui/CustomChannelSection.tsx       | 514 ++++++------------
 .../sidebar/ui/WorkspaceRail.test.mjs         |  71 +++
 .../src/features/sidebar/ui/WorkspaceRail.tsx | 180 ++++++
 .../features/workspaces/useWorkspaceUnread.ts | 166 ++++++
 .../workspaceUnreadObserver.test.mjs          | 154 ++++++
 .../workspaces/workspaceUnreadObserver.ts     | 236 ++++++++
 desktop/src/shared/api/readOnlyRelayClient.ts | 263 +++++++++
 desktop/src/shared/ui/popover.tsx             |  62 +--
 desktop/tests/e2e/sidebar.spec.ts             | 333 +-----------
 desktop/tests/e2e/workspace-rail.spec.ts      | 148 +++++
 22 files changed, 1621 insertions(+), 1130 deletions(-)
 create mode 100644 desktop/src/features/channels/readState/readStateSnapshot.ts
 create mode 100644 desktop/src/features/sidebar/ui/WorkspaceRail.test.mjs
 create mode 100644 desktop/src/features/sidebar/ui/WorkspaceRail.tsx
 create mode 100644 desktop/src/features/workspaces/useWorkspaceUnread.ts
 create mode 100644 desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs
 create mode 100644 desktop/src/features/workspaces/workspaceUnreadObserver.ts
 create mode 100644 desktop/src/shared/api/readOnlyRelayClient.ts
 create mode 100644 desktop/tests/e2e/workspace-rail.spec.ts

diff --git a/desktop/playwright.config.ts b/desktop/playwright.config.ts
index 250cdd3adf..5ac058c7e8 100644
--- a/desktop/playwright.config.ts
+++ b/desktop/playwright.config.ts
@@ -50,6 +50,7 @@ export default defineConfig({
         "**/sidebar-more-unread-overlap.spec.ts",
         "**/home-collapsed-top-chrome.spec.ts",
         "**/thread-unread.spec.ts",
+        "**/workspace-rail.spec.ts",
         "**/thread-reply-anchor-roleplay.spec.ts",
         "**/threadpane-ultrawide.spec.ts",
         "**/animated-avatar.spec.ts",
diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 1973d882c2..7639c884fc 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -60,6 +60,7 @@ import { useDueReminderBadgeCount } from "@/features/reminders/hooks";
 import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider";
 import { useReminderNotifications } from "@/features/reminders/useReminderNotifications";
 import { AppSidebar } from "@/features/sidebar/ui/AppSidebar";
+import { WorkspaceRail } from "@/features/sidebar/ui/WorkspaceRail";
 import { useChannelMutes } from "@/features/sidebar/lib/useChannelMutes";
 import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
 import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
@@ -606,15 +607,24 @@ export function AppShell() {
               >
                 <div
                   className={cn(
-                    "buzz-huddle-app-surface z-10 flex min-h-0 flex-col overflow-hidden bg-background",
+                    "buzz-huddle-app-surface z-10 flex min-h-0 flex-row overflow-hidden bg-background",
                     isHuddleDrawerOpen && "buzz-huddle-app-surface-open",
                   )}
                 >
+                  <WorkspaceRail
+                    activeWorkspaceId={
+                      workspacesHook.activeWorkspace?.id ?? null
+                    }
+                    onAddWorkspace={() => setIsAddWorkspaceOpen(true)}
+                    onSwitchWorkspace={workspacesHook.switchWorkspace}
+                    workspaces={workspacesHook.workspaces}
+                  />
                   <SidebarProvider className="min-h-0 flex-1 flex-col overflow-hidden">
                     {!settingsOpen ? (
                       <AppTopChrome
                         canGoBack={canGoBack}
                         canGoForward={canGoForward}
+                        hasWorkspaceRail={workspacesHook.workspaces.length > 1}
                         onGoBack={goBack}
                         onGoForward={goForward}
                       />
diff --git a/desktop/src/app/AppTopChrome.tsx b/desktop/src/app/AppTopChrome.tsx
index 3b725e8728..4270e16815 100644
--- a/desktop/src/app/AppTopChrome.tsx
+++ b/desktop/src/app/AppTopChrome.tsx
@@ -18,6 +18,7 @@ type AppTopChromeProps = {
   canGoForward: boolean;
   onGoBack: () => void;
   onGoForward: () => void;
+  hasWorkspaceRail?: boolean;
 };
 
 const TOP_CHROME_ICON_BUTTON_CLASS =
@@ -54,17 +55,22 @@ export function AppTopChrome({
   canGoForward,
   onGoBack,
   onGoForward,
+  hasWorkspaceRail = false,
 }: AppTopChromeProps) {
   const topChromeRef = React.useRef<HTMLDivElement>(null);
   const isFullscreen = useIsFullscreen();
   // On macOS the traffic-light buttons overlay the chrome (see
   // `trafficLightPosition` in `tauri.conf.json`), so the nav row clears their
-  // x-position and shifts to align the nav icon centers with the native dot
-  // centers. In fullscreen those buttons hide, so use the standard alignment.
-  const navRowPaddingClass =
-    isMacPlatform() && !isFullscreen ? "pl-20" : "pl-3";
-  const navRowAlignmentClass =
-    isMacPlatform() && !isFullscreen ? "translate-y-[3px]" : null;
+  // x-position. When the workspace rail is present it already occupies the far
+  // left, so the nav row only needs to clear the lights past the rail edge
+  // rather than the full offset. In fullscreen those buttons hide.
+  const macChrome = isMacPlatform() && !isFullscreen;
+  const navRowPaddingClass = macChrome
+    ? hasWorkspaceRail
+      ? "pl-8"
+      : "pl-20"
+    : "pl-3";
+  const navRowAlignmentClass = macChrome ? "translate-y-[3px]" : null;
 
   React.useEffect(() => {
     const topChrome = topChromeRef.current;
diff --git a/desktop/src/features/agent-memory/ui/MemorySection.tsx b/desktop/src/features/agent-memory/ui/MemorySection.tsx
index 9d4b2cc88d..cfc5ed7bd9 100644
--- a/desktop/src/features/agent-memory/ui/MemorySection.tsx
+++ b/desktop/src/features/agent-memory/ui/MemorySection.tsx
@@ -1,11 +1,5 @@
 import * as React from "react";
-import {
-  AlertTriangle,
-  Brain,
-  ChevronDown,
-  Copy,
-  RefreshCw,
-} from "lucide-react";
+import { AlertTriangle, Brain, ChevronDown, RefreshCw } from "lucide-react";
 
 import { useAgentMemoryGraph } from "@/features/agent-memory/hooks";
 import type { MemoryTreeNode } from "@/features/agent-memory/lib/buildMemoryGraph";
@@ -14,7 +8,6 @@ import { cn } from "@/shared/lib/cn";
 import { Button, type ButtonProps } from "@/shared/ui/button";
 import { Skeleton } from "@/shared/ui/skeleton";
 import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
-import { toast } from "sonner";
 
 const MEMORY_LIST_PREVIEW_LIMIT = 3;
 
@@ -268,14 +261,6 @@ function MemoryGraphView({
         </p>
       ) : null}
 
-      <div className="mb-1 flex items-center justify-end">
-        <MemoryCopyButton
-          label={`Copy all ${entries.length} memories`}
-          successMessage="Copied memories to clipboard"
-          value={formatMemoriesForClipboard(entries)}
-        />
-      </div>
-
       <div className="space-y-2" data-testid="agent-memory-list">
         {visibleEntries.map((entry) => (
           <MemoryEntryAccordion
@@ -371,66 +356,6 @@ function flattenTreeDescendants(node: MemoryTreeNode): EngramEntry[] {
   return entries;
 }
 
-function formatMemoryForClipboard(entry: EngramEntry): string {
-  return `${entry.slug}\n${entry.body}`;
-}
-
-function formatMemoriesForClipboard(entries: EngramEntry[]): string {
-  return entries.map(formatMemoryForClipboard).join("\n\n");
-}
-
-function MemoryCopyButton({
-  label,
-  successMessage,
-  value,
-}: {
-  label: string;
-  successMessage: string;
-  value: string;
-}) {
-  const [copying, setCopying] = React.useState(false);
-
-  const handleCopy = React.useCallback(
-    async (event: React.MouseEvent<HTMLButtonElement>) => {
-      event.preventDefault();
-      event.stopPropagation();
-      setCopying(true);
-
-      try {
-        await navigator.clipboard.writeText(value);
-        toast.success(successMessage);
-      } catch (error) {
-        console.error("Failed to copy memory", error);
-        toast.error("Failed to copy memory");
-      } finally {
-        setCopying(false);
-      }
-    },
-    [successMessage, value],
-  );
-
-  return (
-    <Tooltip>
-      <TooltipTrigger asChild>
-        <Button
-          aria-label={label}
-          className="h-7 w-7 text-muted-foreground hover:text-foreground"
-          data-testid="agent-memory-copy"
-          disabled={copying}
-          onClick={handleCopy}
-          size="icon"
-          type="button"
-          variant="ghost"
-        >
-          <Copy className="h-4 w-4" />
-          <span className="sr-only">{label}</span>
-        </Button>
-      </TooltipTrigger>
-      <TooltipContent>{label}</TooltipContent>
-    </Tooltip>
-  );
-}
-
 const MEMORY_REF_PATTERN = /\[\[([^\]]+)\]\]/g;
 
 function MemoryBodyText({ body }: { body: string }) {
@@ -579,33 +504,43 @@ function MemoryEntryAccordion({
   }, [open]);
 
   const content = (
-    <div className="min-w-0 flex-1">
-      <div
-        className={cn(
-          "text-sm font-semibold text-foreground",
-          !open && "line-clamp-2",
-        )}
-        ref={titleRef}
-      >
-        {hasDanglingRefs ? (
-          <AlertTriangle className="mr-1 inline-block h-4 w-4 align-[-2px] text-warning" />
-        ) : null}
-        <MemorySlugTitle slug={entry.slug} />
-      </div>
-      <div
-        className={cn(
-          "mt-1 text-xs leading-5 text-foreground/70",
-          open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
-        )}
-        ref={bodyRef}
-      >
-        {isEmpty ? (
-          <span className="italic text-foreground/50">(empty)</span>
-        ) : (
-          <MemoryBodyText body={entry.body} />
-        )}
+    <>
+      <div className="min-w-0 flex-1">
+        <div
+          className={cn(
+            "text-sm font-semibold text-foreground",
+            !open && "line-clamp-2",
+          )}
+          ref={titleRef}
+        >
+          {hasDanglingRefs ? (
+            <AlertTriangle className="mr-1 inline-block h-4 w-4 align-[-2px] text-warning" />
+          ) : null}
+          <MemorySlugTitle slug={entry.slug} />
+        </div>
+        <div
+          className={cn(
+            "mt-1 text-xs leading-5 text-foreground/70",
+            open ? "whitespace-pre-wrap wrap-break-word" : "line-clamp-2",
+          )}
+          ref={bodyRef}
+        >
+          {isEmpty ? (
+            <span className="italic text-foreground/50">(empty)</span>
+          ) : (
+            <MemoryBodyText body={entry.body} />
+          )}
+        </div>
       </div>
-    </div>
+      {canExpand ? (
+        <ChevronDown
+          className={cn(
+            "mt-0.5 h-4 w-4 shrink-0 text-muted-foreground transition-transform",
+            open && "rotate-180",
+          )}
+        />
+      ) : null}
+    </>
   );
 
   return (
@@ -614,49 +549,19 @@ function MemoryEntryAccordion({
       ref={articleRef}
     >
       {canExpand ? (
-        <div className="flex items-start gap-2 px-4 py-3 transition-colors hover:bg-muted/50">
-          <button
-            aria-expanded={open}
-            className="min-w-0 flex-1 text-left"
-            onClick={() => setOpen((value) => !value)}
-            type="button"
-          >
-            {content}
-            {hasDanglingRefs && open ? (
-              <MemoryDanglingRefsHint slugs={danglingRefsForEntry} />
-            ) : null}
-          </button>
-          <MemoryCopyButton
-            label={`Copy ${entry.slug} memory`}
-            successMessage="Copied memory to clipboard"
-            value={formatMemoryForClipboard(entry)}
-          />
-          <Button
-            aria-expanded={open}
-            aria-label={open ? "Collapse memory" : "Expand memory"}
-            className="h-7 w-7 text-muted-foreground hover:text-foreground"
-            onClick={() => setOpen((value) => !value)}
-            size="icon"
-            type="button"
-            variant="ghost"
-          >
-            <ChevronDown
-              className={cn(
-                "h-4 w-4 transition-transform",
-                open && "rotate-180",
-              )}
-            />
-          </Button>
-        </div>
+        <button
+          aria-expanded={open}
+          className="w-full px-4 py-3 text-left transition-colors hover:bg-muted/50"
+          onClick={() => setOpen((value) => !value)}
+          type="button"
+        >
+          <div className="flex items-start gap-3">{content}</div>
+          {hasDanglingRefs && open ? (
+            <MemoryDanglingRefsHint slugs={danglingRefsForEntry} />
+          ) : null}
+        </button>
       ) : (
-        <div className="flex items-start gap-2 px-4 py-3">
-          {content}
-          <MemoryCopyButton
-            label={`Copy ${entry.slug} memory`}
-            successMessage="Copied memory to clipboard"
-            value={formatMemoryForClipboard(entry)}
-          />
-        </div>
+        <div className="flex items-start gap-3 px-4 py-3">{content}</div>
       )}
     </article>
   );
diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts
index 604e188543..6d0d41c326 100644
--- a/desktop/src/features/channels/readState/readStateManager.ts
+++ b/desktop/src/features/channels/readState/readStateManager.ts
@@ -1,6 +1,5 @@
 import {
   nip44EncryptToSelf,
-  nip44DecryptFromSelf,
   signRelayEvent,
 } from "@/shared/api/tauri";
 import type { RelayClient } from "@/shared/api/relayClientSession";
@@ -14,12 +13,10 @@ import {
   READ_STATE_MAX_SLOTS,
   MSG_PREFIX,
   THREAD_PREFIX,
-  isValidBlob,
-  isValidReadStateDTag,
-  sanitizeContexts,
   localExtraSlotIdsKey,
   type ReadStateBlob,
 } from "@/features/channels/readState/readStateFormat";
+import { parseReadStateEvent } from "@/features/channels/readState/readStateSnapshot";
 import {
   readStoredReadState,
   writeStoredReadState,
@@ -484,48 +481,21 @@ export class ReadStateManager {
     >();
 
     for (const event of events) {
-      if (event.pubkey !== this.pubkey) continue;
-
-      const dTags = event.tags.filter((t) => t[0] === "d");
-      if (dTags.length !== 1) continue;
-      const dTag = dTags[0];
-      if (!isValidReadStateDTag(dTag[1])) continue;
-
-      const tTags = event.tags.filter(
-        (t) => t[0] === "t" && t[1] === "read-state",
-      );
-      if (tTags.length !== 1) continue;
+      const parsed = await parseReadStateEvent(event, this.pubkey);
+      if (!parsed) continue;
 
       this.maxFetchedCreatedAt = Math.max(
         this.maxFetchedCreatedAt,
-        event.created_at,
+        parsed.createdAt,
       );
 
-      let blob: ReadStateBlob;
-      try {
-        const plaintext = await nip44DecryptFromSelf(event.content);
-        const parsed = JSON.parse(plaintext);
-        if (!isValidBlob(parsed)) continue;
-        blob = {
-          v: 1,
-          client_id: parsed.client_id,
-          contexts: sanitizeContexts(parsed.contexts),
-        };
-      } catch (error) {
-        console.debug(
-          `[ReadStateManager] mergeEvents decrypt failed event=${event.id.substring(0, 8)}…:`,
-          error,
-        );
-        continue;
-      }
-
-      for (const [ctx, ts] of Object.entries(blob.contexts)) {
+      for (const [ctx, ts] of Object.entries(parsed.blob.contexts)) {
         const result = applyRemoteContextTimestamp({
           effectiveState: this.effectiveState,
           contextSourceCreatedAt: this.contextSourceCreatedAt,
           contextId: ctx,
           timestamp: ts,
-          eventCreatedAt: event.created_at,
+          eventCreatedAt: parsed.createdAt,
         });
         if (result !== "unchanged") {
           this.pendingSyncedAdvances.add(ctx);
@@ -533,11 +503,13 @@ export class ReadStateManager {
         }
       }
 
-      if (blob.client_id === this.clientId) {
-        const slotKey = dTag[1];
-        const existing = ownBlobsBySlot.get(slotKey);
-        if (!existing || event.created_at > existing.createdAt) {
-          ownBlobsBySlot.set(slotKey, { blob, createdAt: event.created_at });
+      if (parsed.blob.client_id === this.clientId) {
+        const existing = ownBlobsBySlot.get(parsed.dTag);
+        if (!existing || parsed.createdAt > existing.createdAt) {
+          ownBlobsBySlot.set(parsed.dTag, {
+            blob: parsed.blob,
+            createdAt: parsed.createdAt,
+          });
         }
       }
     }
@@ -545,25 +517,12 @@ export class ReadStateManager {
     // Conflict detection: check if another client_id is squatting on our
     // d-tag coordinate. If so, rotate our slotId to avoid clobbering.
     for (const event of events) {
-      if (event.pubkey !== this.pubkey) continue;
-      const dTag = event.tags.find(
-        (t) => t[0] === "d" && t[1] === `read-state:${this.slotId}`,
-      );
-      if (!dTag) continue;
-      try {
-        const plaintext = await nip44DecryptFromSelf(event.content);
-        const parsed = JSON.parse(plaintext);
-        if (isValidBlob(parsed) && parsed.client_id !== this.clientId) {
-          this.slotId = generateHex(16);
-          localStorage.setItem(slotIdKey(this.pubkey), this.slotId);
-          break;
-        }
-      } catch (error) {
-        console.debug(
-          `[ReadStateManager] conflict check decrypt failed event=${event.id.substring(0, 8)}…:`,
-          error,
-        );
-        // Decrypt failure — skip this event
+      const parsed = await parseReadStateEvent(event, this.pubkey);
+      if (!parsed || parsed.dTag !== `read-state:${this.slotId}`) continue;
+      if (parsed.blob.client_id !== this.clientId) {
+        this.slotId = generateHex(16);
+        localStorage.setItem(slotIdKey(this.pubkey), this.slotId);
+        break;
       }
     }
 
@@ -617,39 +576,15 @@ export class ReadStateManager {
       `[ReadStateManager] incoming event=${event.id.substring(0, 8)}… created_at=${event.created_at}`,
     );
 
-    const dTags = event.tags.filter((t) => t[0] === "d");
-    if (dTags.length !== 1) return;
-    const dTag = dTags[0];
-    if (!isValidReadStateDTag(dTag[1])) return;
-
-    const tTags = event.tags.filter(
-      (t) => t[0] === "t" && t[1] === "read-state",
-    );
-    if (tTags.length !== 1) return;
+    const parsed = await parseReadStateEvent(event, this.pubkey);
+    if (!parsed) return;
 
     this.maxFetchedCreatedAt = Math.max(
       this.maxFetchedCreatedAt,
-      event.created_at,
+      parsed.createdAt,
     );
 
-    let blob: ReadStateBlob;
-    try {
-      const plaintext = await nip44DecryptFromSelf(event.content);
-      const parsed = JSON.parse(plaintext);
-      if (!isValidBlob(parsed)) return;
-      blob = {
-        v: 1,
-        client_id: parsed.client_id,
-        contexts: sanitizeContexts(parsed.contexts),
-      };
-    } catch (error) {
-      console.debug(
-        `[ReadStateManager] incoming event decrypt/parse failed event=${event.id.substring(0, 8)}…:`,
-        error,
-      );
-      return;
-    }
-
+    const { blob } = parsed;
     let anyAdvanced = false;
     for (const [ctx, ts] of Object.entries(blob.contexts)) {
       const result = applyRemoteContextTimestamp({
@@ -657,7 +592,7 @@ export class ReadStateManager {
         contextSourceCreatedAt: this.contextSourceCreatedAt,
         contextId: ctx,
         timestamp: ts,
-        eventCreatedAt: event.created_at,
+        eventCreatedAt: parsed.createdAt,
       });
       if (result === "advanced") {
         this.pendingSyncedAdvances.add(ctx);
diff --git a/desktop/src/features/channels/readState/readStateSnapshot.ts b/desktop/src/features/channels/readState/readStateSnapshot.ts
new file mode 100644
index 0000000000..38a6c87999
--- /dev/null
+++ b/desktop/src/features/channels/readState/readStateSnapshot.ts
@@ -0,0 +1,86 @@
+import { nip44DecryptFromSelf } from "@/shared/api/tauri";
+import type { RelayEvent } from "@/shared/api/types";
+import {
+  isValidBlob,
+  isValidReadStateDTag,
+  sanitizeContexts,
+  type ReadStateBlob,
+} from "@/features/channels/readState/readStateFormat";
+
+export type ReadStateDecrypt = (ciphertext: string) => Promise<string>;
+
+export type ParsedReadStateEvent = {
+  dTag: string;
+  blob: ReadStateBlob;
+  createdAt: number;
+};
+
+export async function parseReadStateEvent(
+  event: RelayEvent,
+  pubkey: string,
+  decrypt: ReadStateDecrypt = nip44DecryptFromSelf,
+): Promise<ParsedReadStateEvent | null> {
+  if (event.pubkey !== pubkey) return null;
+
+  const dTags = event.tags.filter((tag) => tag[0] === "d");
+  if (dTags.length !== 1) return null;
+  const dTag = dTags[0]?.[1];
+  if (!isValidReadStateDTag(dTag)) return null;
+
+  const tTags = event.tags.filter(
+    (tag) => tag[0] === "t" && tag[1] === "read-state",
+  );
+  if (tTags.length !== 1) return null;
+
+  try {
+    const plaintext = await decrypt(event.content);
+    const parsed = JSON.parse(plaintext);
+    if (!isValidBlob(parsed)) return null;
+    return {
+      dTag,
+      blob: {
+        v: 1,
+        client_id: parsed.client_id,
+        contexts: sanitizeContexts(parsed.contexts),
+      },
+      createdAt: event.created_at,
+    };
+  } catch (error) {
+    console.debug(
+      `[ReadStateSnapshot] decrypt/parse failed event=${event.id.substring(0, 8)}…:`,
+      error,
+    );
+    return null;
+  }
+}
+
+export async function mergeReadStateEvents(
+  events: RelayEvent[],
+  pubkey: string,
+  decrypt?: ReadStateDecrypt,
+): Promise<Map<string, number>> {
+  const contexts = new Map<string, number>();
+
+  for (const event of events) {
+    const parsed = await parseReadStateEvent(event, pubkey, decrypt);
+    if (!parsed) continue;
+
+    for (const [contextId, timestamp] of Object.entries(
+      parsed.blob.contexts,
+    )) {
+      const current = contexts.get(contextId) ?? 0;
+      if (timestamp > current) {
+        contexts.set(contextId, timestamp);
+      }
+    }
+  }
+
+  return contexts;
+}
+
+export function getSnapshotReadTimestamp(
+  contexts: ReadonlyMap<string, number>,
+  contextId: string,
+): number | null {
+  return contexts.get(contextId) ?? null;
+}
diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs
index c4de6cc523..2fa1114907 100644
--- a/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs
+++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.test.mjs
@@ -48,18 +48,6 @@ test("parseChannelSectionPayload: valid complete payload returns correct store",
   });
 });
 
-test("parseChannelSectionPayload: preserves optional section icons", () => {
-  const payload = {
-    version: 1,
-    sections: [{ id: "s1", name: "Work", icon: ":sparkles:", order: 0 }],
-    assignments: {},
-  };
-  const result = parseChannelSectionPayload(payload);
-  assert.deepEqual(result?.sections, [
-    { id: "s1", name: "Work", icon: ":sparkles:", order: 0 },
-  ]);
-});
-
 test("parseChannelSectionPayload: null input returns null", () => {
   assert.equal(parseChannelSectionPayload(null), null);
 });
diff --git a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts
index 3ee602c5d6..d4a5be7083 100644
--- a/desktop/src/features/sidebar/lib/channelSectionsStorage.ts
+++ b/desktop/src/features/sidebar/lib/channelSectionsStorage.ts
@@ -3,7 +3,6 @@ const STORAGE_KEY_PREFIX = "buzz-channel-sections.v1";
 export type ChannelSection = {
   id: string;
   name: string;
-  icon?: string;
   order: number;
 };
 
@@ -41,29 +40,14 @@ export function parseChannelSectionPayload(
   if (typeof json !== "object" || json === null) return null;
   const obj = json as Record<string, unknown>;
   const sections: ChannelSection[] = Array.isArray(obj.sections)
-    ? obj.sections.flatMap((entry: unknown): ChannelSection[] => {
-        if (typeof entry !== "object" || entry === null) return [];
-        const section = entry as Record<string, unknown>;
-        if (
-          typeof section.id !== "string" ||
-          typeof section.name !== "string" ||
-          typeof section.order !== "number"
-        ) {
-          return [];
-        }
-        const icon =
-          typeof section.icon === "string" && section.icon.trim().length > 0
-            ? section.icon.trim()
-            : undefined;
-        return [
-          {
-            id: section.id,
-            name: section.name,
-            ...(icon ? { icon } : {}),
-            order: section.order,
-          },
-        ];
-      })
+    ? obj.sections.filter(
+        (entry: unknown): entry is ChannelSection =>
+          typeof entry === "object" &&
+          entry !== null &&
+          typeof (entry as Record<string, unknown>).id === "string" &&
+          typeof (entry as Record<string, unknown>).name === "string" &&
+          typeof (entry as Record<string, unknown>).order === "number",
+      )
     : [];
   const assignments: Record<string, string> =
     typeof obj.assignments === "object" &&
diff --git a/desktop/src/features/sidebar/lib/channelSectionsSync.ts b/desktop/src/features/sidebar/lib/channelSectionsSync.ts
index cdcb52d087..9a5f8a4e48 100644
--- a/desktop/src/features/sidebar/lib/channelSectionsSync.ts
+++ b/desktop/src/features/sidebar/lib/channelSectionsSync.ts
@@ -126,7 +126,6 @@ export class ChannelSectionSyncManager {
         !last ||
         last.id !== current.id ||
         last.name !== current.name ||
-        last.icon !== current.icon ||
         last.order !== current.order
       )
         return false;
diff --git a/desktop/src/features/sidebar/lib/useChannelSections.ts b/desktop/src/features/sidebar/lib/useChannelSections.ts
index a0ef7be456..c86fc30826 100644
--- a/desktop/src/features/sidebar/lib/useChannelSections.ts
+++ b/desktop/src/features/sidebar/lib/useChannelSections.ts
@@ -21,8 +21,8 @@ import type {
 export function useChannelSections(pubkey: string | undefined): {
   sections: ChannelSection[];
   assignments: Record<string, string>;
-  createSection: (name: string, icon?: string) => ChannelSection | null;
-  renameSection: (sectionId: string, newName: string, icon?: string) => void;
+  createSection: (name: string) => ChannelSection | null;
+  renameSection: (sectionId: string, newName: string) => void;
   deleteSection: (sectionId: string) => void;
   moveSectionUp: (sectionId: string) => void;
   moveSectionDown: (sectionId: string) => void;
@@ -165,18 +165,16 @@ export function useChannelSections(pubkey: string | undefined): {
   );
 
   const createSection = React.useCallback(
-    (name: string, icon?: string): ChannelSection | null => {
+    (name: string): ChannelSection | null => {
       if (!pubkey) return null;
       const prev = readChannelSectionsStore(pubkey);
       const maxOrder =
         prev.sections.length > 0
           ? Math.max(...prev.sections.map((s) => s.order))
           : -1;
-      const trimmedIcon = icon?.trim();
       const section: ChannelSection = {
         id: crypto.randomUUID(),
         name,
-        ...(trimmedIcon ? { icon: trimmedIcon } : {}),
         order: maxOrder + 1,
       };
       setStore((current) => {
@@ -194,24 +192,16 @@ export function useChannelSections(pubkey: string | undefined): {
   );
 
   const renameSection = React.useCallback(
-    (sectionId: string, newName: string, icon?: string) => {
+    (sectionId: string, newName: string) => {
       if (!pubkey) {
         return;
       }
-      const trimmedIcon = icon?.trim();
       setStore((prev) => {
         const next: ChannelSectionStore = {
           ...prev,
-          sections: prev.sections.map((s) => {
-            if (s.id !== sectionId) return s;
-            const nextSection: ChannelSection = { ...s, name: newName };
-            if (trimmedIcon) {
-              nextSection.icon = trimmedIcon;
-            } else {
-              delete nextSection.icon;
-            }
-            return nextSection;
-          }),
+          sections: prev.sections.map((s) =>
+            s.id === sectionId ? { ...s, name: newName } : s,
+          ),
         };
         if (!writeChannelSectionsStore(pubkey, next)) {
           return prev;
diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx
index 4e883602f3..2ed2e6d151 100644
--- a/desktop/src/features/sidebar/ui/AppSidebar.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx
@@ -421,8 +421,8 @@ export function AppSidebar({
   );
 
   const handleCreateSectionConfirm = React.useCallback(
-    (value: { name: string; icon?: string }) => {
-      const section = createSection(value.name, value.icon);
+    (name: string) => {
+      const section = createSection(name);
       if (!section) {
         return;
       }
@@ -877,10 +877,9 @@ export function AppSidebar({
           if (!open) setRenameSectionTarget(null);
         }}
         sectionName={renameSectionTarget?.name ?? ""}
-        sectionIcon={renameSectionTarget?.icon ?? ""}
-        onConfirm={(value) => {
+        onConfirm={(newName) => {
           if (renameSectionTarget) {
-            renameSection(renameSectionTarget.id, value.name, value.icon);
+            renameSection(renameSectionTarget.id, newName);
           }
           setRenameSectionTarget(null);
         }}
diff --git a/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx
index 5957d92c48..f604693ee5 100644
--- a/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx
+++ b/desktop/src/features/sidebar/ui/ChannelSectionDialogs.tsx
@@ -1,8 +1,5 @@
 import * as React from "react";
 
-import { X } from "lucide-react";
-
-import { EmojiPicker } from "@/features/custom-emoji/ui/EmojiPicker";
 import {
   AlertDialog,
   AlertDialogAction,
@@ -22,27 +19,19 @@ import {
   DialogHeader,
   DialogTitle,
 } from "@/shared/ui/dialog";
-import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
 import { Input } from "@/shared/ui/input";
-import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover";
 import type { Channel } from "@/shared/api/types";
 import { useLeaveChannelMutation } from "@/features/channels/hooks";
 
-export type SectionDialogValue = {
-  name: string;
-  icon?: string;
-};
-
 type SectionNameDialogProps = {
   open: boolean;
   onOpenChange: (open: boolean) => void;
   title: string;
   description: string;
   initialValue: string;
-  initialIcon?: string;
   confirmLabel: string;
-  isConfirmDisabled: (trimmed: string, icon: string) => boolean;
-  onConfirm: (value: SectionDialogValue) => void;
+  isConfirmDisabled: (trimmed: string) => boolean;
+  onConfirm: (name: string) => void;
 };
 
 function SectionNameDialog({
@@ -51,44 +40,28 @@ function SectionNameDialog({
   title,
   description,
   initialValue,
-  initialIcon = "",
   confirmLabel,
   isConfirmDisabled,
   onConfirm,
 }: SectionNameDialogProps) {
   const [name, setName] = React.useState(initialValue);
-  const [icon, setIcon] = React.useState(initialIcon);
-  const [pickerOpen, setPickerOpen] = React.useState(false);
   const inputRef = React.useRef<HTMLInputElement>(null);
 
   React.useEffect(() => {
-    if (!open) {
-      setPickerOpen(false);
-      return;
-    }
+    if (!open) return;
     setName(initialValue);
-    setIcon(initialIcon);
     // Small delay to let dialog animation start before focusing
     const timerId = globalThis.setTimeout(() => {
       inputRef.current?.focus();
     }, 50);
     return () => globalThis.clearTimeout(timerId);
-  }, [open, initialValue, initialIcon]);
-
-  function handleIconSelect(selectedIcon: string) {
-    setIcon(selectedIcon);
-    setPickerOpen(false);
-  }
+  }, [open, initialValue]);
 
   function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
     event.preventDefault();
     const trimmed = name.trim();
-    const trimmedIcon = icon.trim();
-    if (isConfirmDisabled(trimmed, trimmedIcon)) return;
-    onConfirm({
-      name: trimmed,
-      ...(trimmedIcon ? { icon: trimmedIcon } : {}),
-    });
+    if (isConfirmDisabled(trimmed)) return;
+    onConfirm(trimmed);
   }
 
   return (
@@ -99,66 +72,23 @@ function SectionNameDialog({
           <DialogDescription>{description}</DialogDescription>
         </DialogHeader>
         <form onSubmit={handleSubmit}>
-          <div className="flex items-center gap-2">
-            <Popover onOpenChange={setPickerOpen} open={pickerOpen}>
-              <div className="relative shrink-0">
-                <PopoverTrigger asChild>
-                  <button
-                    aria-label="Choose section icon"
-                    className="flex h-9 w-9 items-center justify-center rounded-md border border-input text-lg transition-colors hover:bg-accent"
-                    type="button"
-                  >
-                    {icon ? (
-                      <StatusEmoji className="h-5 w-5" value={icon} />
-                    ) : (
-                      <span className="text-sm font-medium">#</span>
-                    )}
-                  </button>
-                </PopoverTrigger>
-                {icon ? (
-                  <button
-                    aria-label="Clear section icon"
-                    className="absolute -right-1 -top-1 flex h-4 w-4 items-center justify-center rounded-full border border-background bg-muted text-muted-foreground hover:bg-accent hover:text-foreground"
-                    onClick={(event) => {
-                      event.stopPropagation();
-                      setIcon("");
-                    }}
-                    type="button"
-                  >
-                    <X className="h-3 w-3" />
-                  </button>
-                ) : null}
-              </div>
-              <PopoverContent
-                align="start"
-                className="w-auto overflow-hidden rounded-2xl p-0"
-                portalled={false}
-                sideOffset={4}
-              >
-                <EmojiPicker autoFocus onSelect={handleIconSelect} />
-              </PopoverContent>
-            </Popover>
-            <Input
-              autoCapitalize="none"
-              autoComplete="off"
-              autoCorrect="off"
-              onChange={(event) => setName(event.target.value)}
-              placeholder="Section name"
-              ref={inputRef}
-              spellCheck={false}
-              value={name}
-            />
-          </div>
+          <Input
+            autoCapitalize="none"
+            autoComplete="off"
+            autoCorrect="off"
+            onChange={(event) => setName(event.target.value)}
+            placeholder="Section name"
+            ref={inputRef}
+            spellCheck={false}
+            value={name}
+          />
           <div className="flex justify-end gap-2 mt-4">
             <DialogClose asChild>
               <Button variant="ghost" type="button">
                 Cancel
               </Button>
             </DialogClose>
-            <Button
-              type="submit"
-              disabled={isConfirmDisabled(name.trim(), icon.trim())}
-            >
+            <Button type="submit" disabled={isConfirmDisabled(name.trim())}>
               {confirmLabel}
             </Button>
           </div>
@@ -171,7 +101,7 @@ function SectionNameDialog({
 export type CreateSectionDialogProps = {
   open: boolean;
   onOpenChange: (open: boolean) => void;
-  onConfirm: (value: SectionDialogValue) => void;
+  onConfirm: (name: string) => void;
 };
 
 export function CreateSectionDialog({
@@ -184,9 +114,8 @@ export function CreateSectionDialog({
       open={open}
       onOpenChange={onOpenChange}
       title="Create section"
-      description="Choose an icon and name for this sidebar section."
+      description="Sections let you group related channels in the sidebar."
       initialValue=""
-      initialIcon=""
       confirmLabel="Create"
       isConfirmDisabled={(trimmed) => trimmed.length === 0}
       onConfirm={onConfirm}
@@ -198,15 +127,13 @@ export type RenameSectionDialogProps = {
   open: boolean;
   onOpenChange: (open: boolean) => void;
   sectionName: string;
-  sectionIcon?: string;
-  onConfirm: (value: SectionDialogValue) => void;
+  onConfirm: (newName: string) => void;
 };
 
 export function RenameSectionDialog({
   open,
   onOpenChange,
   sectionName,
-  sectionIcon = "",
   onConfirm,
 }: RenameSectionDialogProps) {
   return (
@@ -214,13 +141,11 @@ export function RenameSectionDialog({
       open={open}
       onOpenChange={onOpenChange}
       title="Rename section"
-      description="Choose an icon and name for this sidebar section."
+      description="Enter a new name for this section."
       initialValue={sectionName}
-      initialIcon={sectionIcon}
-      confirmLabel="Save"
-      isConfirmDisabled={(trimmed, icon) =>
-        trimmed.length === 0 ||
-        (trimmed === sectionName.trim() && icon === sectionIcon.trim())
+      confirmLabel="Rename"
+      isConfirmDisabled={(trimmed) =>
+        trimmed.length === 0 || trimmed === sectionName
       }
       onConfirm={onConfirm}
     />
diff --git a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
index 8fc404bd72..fe15cfce34 100644
--- a/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
+++ b/desktop/src/features/sidebar/ui/CustomChannelSection.tsx
@@ -8,8 +8,9 @@ import {
   CheckCircle2,
   ChevronDown,
   CircleDot,
+  Clipboard,
   Copy,
-  EllipsisVertical,
+  GripVertical,
   LogOut,
   Pencil,
   Plus,
@@ -17,7 +18,6 @@ import {
   StarOff,
   Trash2,
 } from "lucide-react";
-import { useRef, type ReactNode } from "react";
 
 import { toast } from "sonner";
 
@@ -31,13 +31,6 @@ import {
   ContextMenuSubTrigger,
   ContextMenuTrigger,
 } from "@/shared/ui/context-menu";
-import {
-  DropdownMenu,
-  DropdownMenuContent,
-  DropdownMenuItem,
-  DropdownMenuSeparator,
-  DropdownMenuTrigger,
-} from "@/shared/ui/dropdown-menu";
 import {
   SidebarGroup,
   SidebarGroupContent,
@@ -58,39 +51,16 @@ import {
 } from "@/features/sidebar/ui/sidebarSectionStyles";
 import type { ActiveChannelTurnSummary } from "@/features/agents/activeAgentTurnsStore";
 import type { ChannelSection } from "@/features/sidebar/lib/useChannelSections";
-import { StatusEmoji } from "@/features/user-status/ui/StatusEmoji";
 import type { Channel } from "@/shared/api/types";
 import { cn } from "@/shared/lib/cn";
 import { HashSearch } from "@/shared/ui/icons";
 
 const SECTION_LABEL_BUTTON_CLASS =
   "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-1 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
-const CUSTOM_SECTION_LABEL_BUTTON_CLASS =
-  "group/section-label flex w-fit max-w-[calc(100%-3rem)] cursor-pointer appearance-none items-center gap-2 text-left transition-colors hover:text-sidebar-foreground focus-visible:text-sidebar-foreground";
 const SECTION_LABEL_CHEVRON_CLASS =
   "relative size-2.5 shrink-0 text-current opacity-0 transition-[color,opacity] group-hover/sidebar-section:opacity-100 group-hover/section-label:opacity-100 group-focus-within/sidebar-section:opacity-100 group-focus-visible/section-label:opacity-100";
 const SECTION_LABEL_CHEVRON_ICON_CLASS =
   "absolute left-1/2 top-1/2 size-2.5 -translate-x-1/2 -translate-y-1/2";
-const CUSTOM_SECTION_ACTION_VISIBILITY_CLASS =
-  "opacity-0 transition-opacity group-hover/sidebar-section:opacity-100 focus-visible:opacity-100 data-[state=open]:opacity-100";
-const SIDEBAR_CONTEXT_ICON_SLOT_CLASS =
-  "flex h-4 w-4 shrink-0 items-center justify-center";
-
-function deferContextMenuAction(action: () => void) {
-  globalThis.setTimeout(action, 0);
-}
-
-function ContextMenuIconSlot({ children }: { children?: ReactNode }) {
-  return (
-    <span
-      aria-hidden="true"
-      className={SIDEBAR_CONTEXT_ICON_SLOT_CLASS}
-      data-sidebar-context-icon-slot
-    >
-      {children}
-    </span>
-  );
-}
 
 function MoveToSectionSubmenu({
   channelId,
@@ -111,49 +81,29 @@ function MoveToSectionSubmenu({
 
   return (
     <ContextMenuSub>
-      <ContextMenuSubTrigger>
-        <ContextMenuIconSlot />
-        <span>Move to section</span>
-      </ContextMenuSubTrigger>
+      <ContextMenuSubTrigger>Move to section</ContextMenuSubTrigger>
       <ContextMenuSubContent>
         {sections.map((section) => (
           <ContextMenuItem
             key={section.id}
-            onSelect={() =>
-              deferContextMenuAction(() =>
-                onAssignChannel(channelId, section.id),
-              )
-            }
+            onClick={() => onAssignChannel(channelId, section.id)}
           >
-            <ContextMenuIconSlot>
-              {currentSectionId === section.id ? (
-                <Check className="h-4 w-4" />
-              ) : section.icon ? (
-                <StatusEmoji className="h-4 w-4" value={section.icon} />
-              ) : null}
-            </ContextMenuIconSlot>
-            <span>{section.name}</span>
+            {currentSectionId === section.id ? (
+              <Check className="h-4 w-4" />
+            ) : (
+              <span className="h-4 w-4" />
+            )}
+            {section.name}
           </ContextMenuItem>
         ))}
         {sections.length > 0 ? <ContextMenuSeparator /> : null}
-        <ContextMenuItem
-          onSelect={() =>
-            deferContextMenuAction(() => onCreateSectionForChannel(channelId))
-          }
-        >
-          <ContextMenuIconSlot>
-            <Plus className="h-4 w-4" />
-          </ContextMenuIconSlot>
-          <span>New section...</span>
+        <ContextMenuItem onClick={() => onCreateSectionForChannel(channelId)}>
+          <Plus className="h-4 w-4" />
+          New section...
         </ContextMenuItem>
         {currentSectionId ? (
-          <ContextMenuItem
-            onSelect={() =>
-              deferContextMenuAction(() => onUnassignChannel(channelId))
-            }
-          >
-            <ContextMenuIconSlot />
-            <span>Remove from section</span>
+          <ContextMenuItem onClick={() => onUnassignChannel(channelId)}>
+            Remove from section
           </ContextMenuItem>
         ) : null}
       </ContextMenuSubContent>
@@ -172,35 +122,6 @@ function copyToClipboard(text: string, successMessage: string) {
     });
 }
 
-function CopyChannelSubmenu({ channel }: { channel: Channel }) {
-  return (
-    <ContextMenuSub>
-      <ContextMenuSubTrigger>
-        <ContextMenuIconSlot>
-          <Copy className="h-4 w-4" />
-        </ContextMenuIconSlot>
-        <span>Copy</span>
-      </ContextMenuSubTrigger>
-      <ContextMenuSubContent>
-        <ContextMenuItem
-          onSelect={() =>
-            copyToClipboard(channel.name, "Channel name copied to clipboard")
-          }
-        >
-          <span>Copy channel name</span>
-        </ContextMenuItem>
-        <ContextMenuItem
-          onSelect={() =>
-            copyToClipboard(channel.id, "Channel ID copied to clipboard")
-          }
-        >
-          <span>Copy channel ID</span>
-        </ContextMenuItem>
-      </ContextMenuSubContent>
-    </ContextMenuSub>
-  );
-}
-
 export function ChannelContextMenuItems({
   channel,
   hasUnread,
@@ -243,118 +164,94 @@ export function ChannelContextMenuItems({
   const showReadToggle = hasUnread
     ? Boolean(onMarkChannelRead)
     : Boolean(onMarkChannelUnread);
-  const showMuteToggle = Boolean(onMuteChannel && onUnmuteChannel);
-  const showMove = Boolean(
-    sections &&
-      assignments &&
-      onAssignChannel &&
-      onUnassignChannel &&
-      onCreateSectionForChannel,
-  );
-
   return (
     <>
-      <CopyChannelSubmenu channel={channel} />
-      {showMove ? (
-        <MoveToSectionSubmenu
-          channelId={channel.id}
-          sections={sections ?? []}
-          assignments={assignments ?? {}}
-          onAssignChannel={onAssignChannel ?? (() => {})}
-          onUnassignChannel={onUnassignChannel ?? (() => {})}
-          onCreateSectionForChannel={onCreateSectionForChannel ?? (() => {})}
-        />
+      {showStar ? (
+        isStarred ? (
+          <ContextMenuItem onClick={() => onUnstarChannel?.(channel.id)}>
+            <StarOff className="h-4 w-4" />
+            Unstar channel
+          </ContextMenuItem>
+        ) : (
+          <ContextMenuItem onClick={() => onStarChannel?.(channel.id)}>
+            <Star className="h-4 w-4" />
+            Star channel
+          </ContextMenuItem>
+        )
       ) : null}
-      {showReadToggle ? <ContextMenuSeparator /> : null}
+      {showStar && showReadToggle ? <ContextMenuSeparator /> : null}
       {hasUnread && onMarkChannelRead ? (
         <ContextMenuItem
-          onSelect={() =>
-            deferContextMenuAction(() =>
-              onMarkChannelRead(channel.id, channel.lastMessageAt),
-            )
-          }
+          onClick={() => onMarkChannelRead(channel.id, channel.lastMessageAt)}
         >
-          <ContextMenuIconSlot>
-            <CheckCircle2 className="h-4 w-4" />
-          </ContextMenuIconSlot>
-          <span>Mark as read</span>
+          <CheckCircle2 className="h-4 w-4" />
+          Mark as read
         </ContextMenuItem>
       ) : !hasUnread && onMarkChannelUnread ? (
-        <ContextMenuItem
-          onSelect={() =>
-            deferContextMenuAction(() => onMarkChannelUnread(channel.id))
-          }
-        >
-          <ContextMenuIconSlot>
-            <CircleDot className="h-4 w-4" />
-          </ContextMenuIconSlot>
-          <span>Mark unread</span>
+        <ContextMenuItem onClick={() => onMarkChannelUnread(channel.id)}>
+          <CircleDot className="h-4 w-4" />
+          Mark unread
         </ContextMenuItem>
       ) : null}
-      {showMuteToggle || showStar ? <ContextMenuSeparator /> : null}
-      {showMuteToggle ? (
-        isMuted ? (
-          <ContextMenuItem
-            onSelect={() =>
-              deferContextMenuAction(() => onUnmuteChannel?.(channel.id))
-            }
-          >
-            <ContextMenuIconSlot>
+      {onMuteChannel && onUnmuteChannel ? (
+        <>
+          <ContextMenuSeparator />
+          {isMuted ? (
+            <ContextMenuItem onClick={() => onUnmuteChannel(channel.id)}>
               <Bell className="h-4 w-4" />
-            </ContextMenuIconSlot>
-            <span>Unmute channel</span>
-          </ContextMenuItem>
-        ) : (
-          <ContextMenuItem
-            onSelect={() =>
-              deferContextMenuAction(() => onMuteChannel?.(channel.id))
-            }
-          >
-            <ContextMenuIconSlot>
+              Unmute channel
+            </ContextMenuItem>
+          ) : (
+            <ContextMenuItem onClick={() => onMuteChannel(channel.id)}>
               <BellOff className="h-4 w-4" />
-            </ContextMenuIconSlot>
-            <span>Mute channel</span>
-          </ContextMenuItem>
-        )
+              Mute channel
+            </ContextMenuItem>
+          )}
+        </>
       ) : null}
-      {showStar ? (
-        isStarred ? (
-          <ContextMenuItem
-            onSelect={() =>
-              deferContextMenuAction(() => onUnstarChannel?.(channel.id))
-            }
-          >
-            <ContextMenuIconSlot>
-              <StarOff className="h-4 w-4" />
-            </ContextMenuIconSlot>
-            <span>Unstar channel</span>
-          </ContextMenuItem>
-        ) : (
-          <ContextMenuItem
-            onSelect={() =>
-              deferContextMenuAction(() => onStarChannel?.(channel.id))
-            }
-          >
-            <ContextMenuIconSlot>
-              <Star className="h-4 w-4" />
-            </ContextMenuIconSlot>
-            <span>Star channel</span>
-          </ContextMenuItem>
-        )
+      {sections &&
+      assignments &&
+      onAssignChannel &&
+      onUnassignChannel &&
+      onCreateSectionForChannel ? (
+        <>
+          <ContextMenuSeparator />
+          <MoveToSectionSubmenu
+            channelId={channel.id}
+            sections={sections}
+            assignments={assignments}
+            onAssignChannel={onAssignChannel}
+            onUnassignChannel={onUnassignChannel}
+            onCreateSectionForChannel={onCreateSectionForChannel}
+          />
+        </>
       ) : null}
+      <ContextMenuSeparator />
+      <ContextMenuItem
+        onClick={() =>
+          copyToClipboard(channel.name, "Channel name copied to clipboard")
+        }
+      >
+        <Copy className="h-4 w-4" />
+        Copy channel name
+      </ContextMenuItem>
+      <ContextMenuItem
+        onClick={() =>
+          copyToClipboard(channel.id, "Channel ID copied to clipboard")
+        }
+      >
+        <Clipboard className="h-4 w-4" />
+        Copy channel ID
+      </ContextMenuItem>
       {onLeaveChannel ? (
         <>
           <ContextMenuSeparator />
           <ContextMenuItem
             className="text-destructive focus:text-destructive"
-            onSelect={() =>
-              deferContextMenuAction(() => onLeaveChannel(channel))
-            }
+            onClick={() => onLeaveChannel(channel)}
           >
-            <ContextMenuIconSlot>
-              <LogOut className="h-4 w-4" />
-            </ContextMenuIconSlot>
-            <span>Leave channel</span>
+            <LogOut className="h-4 w-4" />
+            Leave channel
           </ContextMenuItem>
         </>
       ) : null}
@@ -424,97 +321,6 @@ function SectionHeaderActions({
   );
 }
 
-function CustomSectionActionsMenu({
-  sectionId,
-  sectionName,
-  hasUnread,
-  isFirst,
-  isLast,
-  onMarkSectionRead,
-  onRenameSection,
-  onDeleteSection,
-  onMoveSectionUp,
-  onMoveSectionDown,
-}: {
-  sectionId: string;
-  sectionName: string;
-  hasUnread: boolean;
-  isFirst: boolean;
-  isLast: boolean;
-  onMarkSectionRead: () => void;
-  onRenameSection: () => void;
-  onDeleteSection: () => void;
-  onMoveSectionUp: () => void;
-  onMoveSectionDown: () => void;
-}) {
-  const triggerRef = useRef<HTMLButtonElement>(null);
-
-  return (
-    <DropdownMenu>
-      <DropdownMenuTrigger asChild>
-        <button
-          aria-label={`Open actions for ${sectionName}`}
-          className={cn(
-            SECTION_ICON_BUTTON_CLASS,
-            CUSTOM_SECTION_ACTION_VISIBILITY_CLASS,
-          )}
-          data-testid={`section-actions-${sectionId}`}
-          onClick={(event) => event.stopPropagation()}
-          onPointerDown={(event) => event.stopPropagation()}
-          ref={triggerRef}
-          type="button"
-        >
-          <EllipsisVertical className="h-4 w-4" />
-        </button>
-      </DropdownMenuTrigger>
-      <DropdownMenuContent
-        align="end"
-        onCloseAutoFocus={(event) => {
-          event.preventDefault();
-          triggerRef.current?.blur();
-        }}
-      >
-        {hasUnread ? (
-          <DropdownMenuItem
-            onSelect={() => deferContextMenuAction(onMarkSectionRead)}
-          >
-            <CheckCheck className="h-4 w-4" />
-            <span>Mark all as read</span>
-          </DropdownMenuItem>
-        ) : null}
-        <DropdownMenuItem
-          onSelect={() => deferContextMenuAction(onRenameSection)}
-        >
-          <Pencil className="h-4 w-4" />
-          <span>Rename section</span>
-        </DropdownMenuItem>
-        <DropdownMenuItem
-          disabled={isFirst}
-          onSelect={() => deferContextMenuAction(onMoveSectionUp)}
-        >
-          <ArrowUp className="h-4 w-4" />
-          <span>Move up</span>
-        </DropdownMenuItem>
-        <DropdownMenuItem
-          disabled={isLast}
-          onSelect={() => deferContextMenuAction(onMoveSectionDown)}
-        >
-          <ArrowDown className="h-4 w-4" />
-          <span>Move down</span>
-        </DropdownMenuItem>
-        <DropdownMenuSeparator />
-        <DropdownMenuItem
-          className="text-destructive focus:text-destructive"
-          onSelect={() => deferContextMenuAction(onDeleteSection)}
-        >
-          <Trash2 className="h-4 w-4" />
-          <span>Delete section</span>
-        </DropdownMenuItem>
-      </DropdownMenuContent>
-    </DropdownMenu>
-  );
-}
-
 export function ChannelGroupSection({
   browseAriaLabel,
   createAriaLabel,
@@ -780,111 +586,97 @@ export function CustomChannelSection({
           >
             <ContextMenu>
               <ContextMenuTrigger asChild>
-                <div
-                  className="group/section-header relative"
-                  {...dragHandleProps}
-                >
+                <div className="relative" {...dragHandleProps}>
                   <SidebarGroupLabel asChild>
                     <button
                       aria-controls={contentId}
                       aria-expanded={!isCollapsed}
-                      className={CUSTOM_SECTION_LABEL_BUTTON_CLASS}
+                      className={SECTION_LABEL_BUTTON_CLASS}
                       onClick={onToggleCollapsed}
                       type="button"
                     >
-                      {section.icon ? (
-                        <span
-                          aria-hidden="true"
-                          className="flex h-4 w-4 shrink-0 items-center justify-center"
-                          data-testid={`section-icon-${section.id}`}
-                        >
-                          <StatusEmoji
-                            className="h-4 w-4"
-                            value={section.icon}
-                          />
-                        </span>
-                      ) : null}
-                      <span
-                        className="truncate"
-                        data-testid={`section-title-${section.id}`}
-                      >
-                        {section.name}
-                      </span>
-                      <span
+                      <GripVertical
+                        className={cn(
+                          "h-4 w-4 shrink-0 text-sidebar-foreground/30",
+                          SECTION_ACTION_VISIBILITY_CLASS,
+                        )}
                         aria-hidden="true"
-                        className={SECTION_LABEL_CHEVRON_CLASS}
-                      >
-                        <ChevronDown
-                          className={cn(
-                            SECTION_LABEL_CHEVRON_ICON_CLASS,
-                            isCollapsed ? "-rotate-90" : "rotate-0",
-                          )}
-                        />
-                      </span>
+                      />
+                      <span>{section.name}</span>
+                      <ChevronDown
+                        aria-hidden="true"
+                        className={cn(
+                          SECTION_LABEL_CHEVRON_CLASS,
+                          isCollapsed ? "-rotate-90" : "rotate-0",
+                        )}
+                      />
                     </button>
                   </SidebarGroupLabel>
-                  <div className="absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5">
-                    <CustomSectionActionsMenu
-                      sectionId={section.id}
-                      sectionName={section.name}
-                      hasUnread={hasUnread}
-                      isFirst={isFirst}
-                      isLast={isLast}
-                      onMarkSectionRead={onMarkSectionRead}
-                      onRenameSection={onRenameSection}
-                      onDeleteSection={onDeleteSection}
-                      onMoveSectionUp={onMoveSectionUp}
-                      onMoveSectionDown={onMoveSectionDown}
-                    />
+                  <div
+                    className={cn(
+                      "absolute right-1 top-1/2 z-10 flex -translate-y-1/2 items-center gap-0.5",
+                      SECTION_ACTION_VISIBILITY_CLASS,
+                    )}
+                  >
+                    {hasUnread ? (
+                      <button
+                        aria-label="Mark all as read"
+                        className={SECTION_ICON_BUTTON_CLASS}
+                        onClick={(e) => {
+                          e.stopPropagation();
+                          onMarkSectionRead();
+                        }}
+                        title="Mark all as read"
+                        type="button"
+                      >
+                        <CheckCheck className="h-4 w-4" />
+                      </button>
+                    ) : null}
+                    <button
+                      aria-label="Rename section"
+                      className={SECTION_ICON_BUTTON_CLASS}
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        onRenameSection();
+                      }}
+                      type="button"
+                    >
+                      <Pencil className="h-4 w-4" />
+                    </button>
+                    <button
+                      aria-label="Delete section"
+                      className={SECTION_ICON_BUTTON_CLASS}
+                      onClick={(e) => {
+                        e.stopPropagation();
+                        onDeleteSection();
+                      }}
+                      type="button"
+                    >
+                      <Trash2 className="h-4 w-4" />
+                    </button>
                   </div>
                 </div>
               </ContextMenuTrigger>
               <ContextMenuContent>
-                {hasUnread ? (
-                  <ContextMenuItem
-                    onSelect={() => deferContextMenuAction(onMarkSectionRead)}
-                  >
-                    <ContextMenuIconSlot>
-                      <CheckCheck className="h-4 w-4" />
-                    </ContextMenuIconSlot>
-                    <span>Mark all as read</span>
-                  </ContextMenuItem>
-                ) : null}
-                <ContextMenuItem
-                  onSelect={() => deferContextMenuAction(onRenameSection)}
-                >
-                  <ContextMenuIconSlot>
-                    <Pencil className="h-4 w-4" />
-                  </ContextMenuIconSlot>
-                  <span>Rename section</span>
+                <ContextMenuItem onClick={onRenameSection}>
+                  <Pencil className="h-4 w-4" />
+                  Rename section
                 </ContextMenuItem>
-                <ContextMenuItem
-                  disabled={isFirst}
-                  onSelect={() => deferContextMenuAction(onMoveSectionUp)}
-                >
-                  <ContextMenuIconSlot>
-                    <ArrowUp className="h-4 w-4" />
-                  </ContextMenuIconSlot>
-                  <span>Move up</span>
+                <ContextMenuItem disabled={isFirst} onClick={onMoveSectionUp}>
+                  <ArrowUp className="h-4 w-4" />
+                  Move up
                 </ContextMenuItem>
-                <ContextMenuItem
-                  disabled={isLast}
-                  onSelect={() => deferContextMenuAction(onMoveSectionDown)}
-                >
-                  <ContextMenuIconSlot>
-                    <ArrowDown className="h-4 w-4" />
-                  </ContextMenuIconSlot>
-                  <span>Move down</span>
+                <ContextMenuItem disabled={isLast} onClick={onMoveSectionDown}>
+                  <ArrowDown className="h-4 w-4" />
+                  Move down
                 </ContextMenuItem>
                 <ContextMenuSeparator />
                 <ContextMenuItem
                   className="text-destructive focus:text-destructive"
-                  onSelect={() => deferContextMenuAction(onDeleteSection)}
+                  onClick={onDeleteSection}
                 >
-                  <ContextMenuIconSlot>
-                    <Trash2 className="h-4 w-4" />
-                  </ContextMenuIconSlot>
-                  <span>Delete section</span>
+                  <Trash2 className="h-4 w-4" />
+                  Delete section
                 </ContextMenuItem>
               </ContextMenuContent>
             </ContextMenu>
diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.test.mjs b/desktop/src/features/sidebar/ui/WorkspaceRail.test.mjs
new file mode 100644
index 0000000000..d0e5292403
--- /dev/null
+++ b/desktop/src/features/sidebar/ui/WorkspaceRail.test.mjs
@@ -0,0 +1,71 @@
+import assert from "node:assert/strict";
+import { describe, it } from "node:test";
+
+import { workspaceRailIndicators } from "./WorkspaceRail.tsx";
+
+describe("workspaceRailIndicators", () => {
+  it("shows a dot for an observed workspace with unread", () => {
+    const r = workspaceRailIndicators({ hasUnread: true, state: "ready" });
+    assert.equal(r.showDot, true);
+    assert.equal(r.showBadge, false);
+    assert.equal(r.pending, false);
+  });
+
+  it("shows no dot for an observed workspace with no unread", () => {
+    const r = workspaceRailIndicators({ hasUnread: false, state: "ready" });
+    assert.equal(r.showDot, false);
+    assert.equal(r.showBadge, false);
+    assert.equal(r.pending, false);
+  });
+
+  it("shows a mention badge with the count when mentions are present", () => {
+    const r = workspaceRailIndicators({
+      hasUnread: true,
+      count: 3,
+      state: "ready",
+    });
+    assert.equal(r.showDot, true);
+    assert.equal(r.showBadge, true);
+    assert.equal(r.mentionCount, 3);
+    assert.equal(r.badgeLabel, "3");
+  });
+
+  it("caps the badge label at 99+", () => {
+    const r = workspaceRailIndicators({
+      hasUnread: true,
+      count: 250,
+      state: "ready",
+    });
+    assert.equal(r.badgeLabel, "99+");
+  });
+
+  it("never reports unread for an unobserved (unknown) workspace", () => {
+    const r = workspaceRailIndicators({
+      hasUnread: true,
+      count: 5,
+      state: "unknown",
+    });
+    assert.equal(r.showDot, false);
+    assert.equal(r.showBadge, false);
+    assert.equal(r.mentionCount, 0);
+    assert.equal(r.pending, true);
+  });
+
+  it("treats loading as pending, not as no-unread", () => {
+    const r = workspaceRailIndicators({ hasUnread: false, state: "loading" });
+    assert.equal(r.pending, true);
+    assert.equal(r.showDot, false);
+  });
+
+  it("never reports unread on an errored observation", () => {
+    const r = workspaceRailIndicators({
+      hasUnread: true,
+      count: 2,
+      state: "error",
+    });
+    assert.equal(r.showDot, false);
+    assert.equal(r.showBadge, false);
+    assert.equal(r.mentionCount, 0);
+    assert.equal(r.pending, false);
+  });
+});
diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
new file mode 100644
index 0000000000..47f1b3ef9e
--- /dev/null
+++ b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
@@ -0,0 +1,180 @@
+import { Plus } from "lucide-react";
+
+import type { Workspace } from "@/features/workspaces/types";
+import {
+  useWorkspaceUnread,
+  type WorkspaceUnreadState,
+} from "@/features/workspaces/useWorkspaceUnread";
+import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
+import { cn } from "@/shared/lib/cn";
+import { getInitials } from "@/shared/lib/initials";
+import { isMacPlatform } from "@/shared/lib/platform";
+import { useIsFullscreen } from "@/shared/lib/useIsFullscreen";
+
+type WorkspaceRailProps = {
+  workspaces: Workspace[];
+  activeWorkspaceId: string | null;
+  onSwitchWorkspace: (id: string) => void;
+  onAddWorkspace: () => void;
+};
+
+const MAX_BADGE = 99;
+
+/**
+ * Presentation decisions for one workspace button, derived from its observed
+ * unread state. Pure so it can be unit-tested without a DOM. The `state` guard
+ * ensures we NEVER render a "no unread" affordance for a relay we could not
+ * observe (`unknown`/`loading`/`error`) — only a `ready` observation is trusted.
+ */
+export function workspaceRailIndicators(unread: WorkspaceUnreadState): {
+  showDot: boolean;
+  mentionCount: number;
+  showBadge: boolean;
+  pending: boolean;
+  badgeLabel: string;
+} {
+  const observed = unread.state === "ready";
+  const mentionCount = observed ? (unread.count ?? 0) : 0;
+  const showBadge = mentionCount > 0;
+  return {
+    showDot: observed && unread.hasUnread,
+    mentionCount,
+    showBadge,
+    pending: unread.state === "unknown" || unread.state === "loading",
+    badgeLabel:
+      mentionCount > MAX_BADGE ? `${MAX_BADGE}+` : String(mentionCount),
+  };
+}
+
+function WorkspaceButton({
+  workspace,
+  isActive,
+  unread,
+  onSwitch,
+}: {
+  workspace: Workspace;
+  isActive: boolean;
+  unread: WorkspaceUnreadState;
+  onSwitch: () => void;
+}) {
+  const { showDot, mentionCount, showBadge, pending, badgeLabel } =
+    workspaceRailIndicators(unread);
+
+  const tooltipLabel = showBadge
+    ? `${workspace.name} — ${mentionCount} mention${mentionCount === 1 ? "" : "s"}`
+    : workspace.name;
+
+  return (
+    <Tooltip>
+      <TooltipTrigger asChild>
+        <button
+          aria-current={isActive ? "true" : undefined}
+          aria-label={tooltipLabel}
+          className="relative flex h-10 w-10 items-center justify-center outline-hidden focus:outline-none focus-visible:outline-none"
+          data-testid={`workspace-rail-button-${workspace.id}`}
+          onClick={onSwitch}
+          type="button"
+        >
+          {/* Active/unread indicator pill on the left edge (Discord-style). */}
+          <span
+            aria-hidden="true"
+            className={cn(
+              "absolute -left-2 w-1 rounded-r-full bg-sidebar-foreground transition-all",
+              isActive
+                ? "h-6 opacity-100"
+                : showDot
+                  ? "h-2 opacity-100"
+                  : "h-2 opacity-0",
+            )}
+          />
+          <span
+            className={cn(
+              "flex h-9 w-9 items-center justify-center rounded-2xl text-xs font-semibold transition-all",
+              isActive
+                ? "rounded-xl bg-primary text-primary-foreground"
+                : "bg-sidebar-accent/60 text-sidebar-foreground/80 hover:rounded-xl hover:bg-primary/80 hover:text-primary-foreground",
+              pending && "opacity-60",
+            )}
+          >
+            {getInitials(workspace.name) || "🐝"}
+          </span>
+          {showBadge ? (
+            <span
+              className="absolute -bottom-0.5 -right-0.5 flex h-4 min-w-4 items-center justify-center rounded-full bg-destructive px-1 text-2xs font-semibold text-destructive-foreground ring-2 ring-sidebar"
+              data-testid={`workspace-rail-mentions-${workspace.id}`}
+            >
+              {badgeLabel}
+            </span>
+          ) : null}
+        </button>
+      </TooltipTrigger>
+      <TooltipContent side="right">{tooltipLabel}</TooltipContent>
+    </Tooltip>
+  );
+}
+
+/**
+ * Discord/Slack-style vertical rail of workspaces on the far left of the app.
+ * Shows an unread dot and a mention-count badge for inactive workspaces
+ * (observed via `useWorkspaceUnread`) and switches relays on click.
+ *
+ * Hidden entirely with a single workspace — a rail of one adds no value.
+ */
+export function WorkspaceRail({
+  workspaces,
+  activeWorkspaceId,
+  onSwitchWorkspace,
+  onAddWorkspace,
+}: WorkspaceRailProps) {
+  const unreadByWorkspace = useWorkspaceUnread(workspaces, activeWorkspaceId);
+  const isFullscreen = useIsFullscreen();
+  if (workspaces.length <= 1) {
+    return null;
+  }
+
+  // macOS traffic lights overlay the top-left, so start buttons below them (they hide in fullscreen).
+  const topPaddingClass =
+    isMacPlatform() && !isFullscreen
+      ? "pt-(--buzz-top-chrome-height,2.5rem)"
+      : "pt-3";
+
+  return (
+    <nav
+      aria-label="Workspaces"
+      className={cn(
+        "flex w-14 shrink-0 flex-col items-center gap-2 overflow-y-auto bg-sidebar pb-3",
+        topPaddingClass,
+      )}
+      data-testid="workspace-rail"
+    >
+      {workspaces.map((workspace) => (
+        <WorkspaceButton
+          key={workspace.id}
+          isActive={workspace.id === activeWorkspaceId}
+          onSwitch={() => onSwitchWorkspace(workspace.id)}
+          unread={
+            unreadByWorkspace[workspace.id] ?? {
+              hasUnread: false,
+              state: "unknown",
+            }
+          }
+          workspace={workspace}
+        />
+      ))}
+      <Tooltip>
+        <TooltipTrigger asChild>
+          <button
+            aria-label="Add workspace"
+            className="flex h-9 w-9 items-center justify-center rounded-2xl bg-sidebar-accent/60 text-sidebar-foreground/70 outline-hidden transition-all hover:rounded-xl hover:bg-primary/80 hover:text-primary-foreground focus:outline-none focus-visible:outline-none"
+            data-testid="workspace-rail-add"
+            onClick={onAddWorkspace}
+            type="button"
+          >
+            <Plus className="h-4 w-4" />
+          </button>
+        </TooltipTrigger>
+        <TooltipContent side="right">Add workspace</TooltipContent>
+      </Tooltip>
+    </nav>
+  );
+}
diff --git a/desktop/src/features/workspaces/useWorkspaceUnread.ts b/desktop/src/features/workspaces/useWorkspaceUnread.ts
new file mode 100644
index 0000000000..7dafa5a455
--- /dev/null
+++ b/desktop/src/features/workspaces/useWorkspaceUnread.ts
@@ -0,0 +1,166 @@
+import * as React from "react";
+
+import { getIdentity } from "@/shared/api/tauri";
+import { pollWorkspaceUnread } from "@/features/workspaces/workspaceUnreadObserver";
+
+import type { Workspace } from "./types";
+
+const WORKSPACE_UNREAD_POLL_MS = 30_000;
+
+/**
+ * Per-workspace unread summary for the workspace rail.
+ *
+ * `state` distinguishes "observed, no unread" from "not observed yet" so the
+ * rail never renders a false "no unread" for a relay it could not reach:
+ * - `unknown`  — not yet observed (render dim, no unread affordance)
+ * - `loading`  — observation in flight (render dim/skeleton)
+ * - `ready`    — observed; trust `hasUnread` / `count`
+ * - `error`    — observation failed (render neutral, never "no unread")
+ *
+ * `count` carries the MENTION count (not total unread) — the rail shows a dot
+ * for any unread and a numeric badge only when mentions are present.
+ */
+export type WorkspaceUnreadState = {
+  hasUnread: boolean;
+  count?: number;
+  state: "unknown" | "loading" | "ready" | "error";
+};
+
+const unknownUnreadState: WorkspaceUnreadState = {
+  hasUnread: false,
+  state: "unknown",
+};
+
+function seedWorkspaceStates(
+  workspaces: Workspace[],
+  previous: Record<string, WorkspaceUnreadState>,
+): Record<string, WorkspaceUnreadState> {
+  const next: Record<string, WorkspaceUnreadState> = {};
+  for (const workspace of workspaces) {
+    next[workspace.id] = previous[workspace.id] ?? unknownUnreadState;
+  }
+  return next;
+}
+
+/**
+ * Observe unread activity for INACTIVE workspaces without touching the active
+ * relay singleton.
+ */
+export function useWorkspaceUnread(
+  workspaces: Workspace[],
+  activeWorkspaceId: string | null,
+): Record<string, WorkspaceUnreadState> {
+  const [unreadByWorkspace, setUnreadByWorkspace] = React.useState<
+    Record<string, WorkspaceUnreadState>
+  >(() => seedWorkspaceStates(workspaces, {}));
+
+  const workspacesKey = React.useMemo(
+    () =>
+      workspaces
+        .map((workspace) => `${workspace.id}:${workspace.relayUrl}`)
+        .sort()
+        .join("|"),
+    [workspaces],
+  );
+
+  React.useEffect(() => {
+    let cancelled = false;
+    let pollTimer: number | null = null;
+    const inactiveWorkspaces = workspaces.filter(
+      (workspace) => workspace.id !== activeWorkspaceId,
+    );
+
+    setUnreadByWorkspace((previous) =>
+      seedWorkspaceStates(workspaces, previous),
+    );
+
+    const markLoading = (workspaceId: string) => {
+      setUnreadByWorkspace((previous) => {
+        const current = previous[workspaceId] ?? unknownUnreadState;
+        if (current.state === "ready") {
+          return previous;
+        }
+        return {
+          ...previous,
+          [workspaceId]: { hasUnread: false, state: "loading" },
+        };
+      });
+    };
+
+    const markReady = (
+      workspaceId: string,
+      result: { hasUnread: boolean; mentionCount: number },
+    ) => {
+      setUnreadByWorkspace((previous) => ({
+        ...previous,
+        [workspaceId]: {
+          hasUnread: result.hasUnread,
+          count: result.mentionCount > 0 ? result.mentionCount : undefined,
+          state: "ready",
+        },
+      }));
+    };
+
+    const markError = (workspaceId: string) => {
+      setUnreadByWorkspace((previous) => ({
+        ...previous,
+        [workspaceId]: { hasUnread: false, state: "error" },
+      }));
+    };
+
+    const scheduleNextPoll = () => {
+      if (cancelled) return;
+      pollTimer = window.setTimeout(() => {
+        void pollInactiveWorkspaces();
+      }, WORKSPACE_UNREAD_POLL_MS);
+    };
+
+    const pollInactiveWorkspaces = async () => {
+      if (inactiveWorkspaces.length === 0) {
+        return;
+      }
+
+      let pubkey: string;
+      try {
+        pubkey = (await getIdentity()).pubkey;
+      } catch {
+        for (const workspace of inactiveWorkspaces) {
+          if (cancelled) return;
+          markError(workspace.id);
+        }
+        scheduleNextPoll();
+        return;
+      }
+
+      for (const workspace of inactiveWorkspaces) {
+        if (cancelled) return;
+        markLoading(workspace.id);
+        try {
+          const result = await pollWorkspaceUnread(workspace, pubkey);
+          if (cancelled) return;
+          markReady(workspace.id, result);
+        } catch (error) {
+          console.debug(
+            `[WorkspaceUnread] poll failed workspace=${workspace.id}:`,
+            error,
+          );
+          if (cancelled) return;
+          markError(workspace.id);
+        }
+      }
+
+      scheduleNextPoll();
+    };
+
+    void pollInactiveWorkspaces();
+
+    return () => {
+      cancelled = true;
+      if (pollTimer !== null) {
+        window.clearTimeout(pollTimer);
+      }
+    };
+  }, [activeWorkspaceId, workspaces, workspacesKey]);
+
+  return unreadByWorkspace;
+}
diff --git a/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs b/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs
new file mode 100644
index 0000000000..709d4aa396
--- /dev/null
+++ b/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs
@@ -0,0 +1,154 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  extractHiddenDmIds,
+  extractMemberChannelIds,
+  fetchWorkspaceUnread,
+  resolveObservedChannels,
+} from "./workspaceUnreadObserver.ts";
+
+const PUBKEY = "a".repeat(64);
+const OTHER = "b".repeat(64);
+const CHANNEL_ID = "channel-1";
+const THREAD_ROOT = "c".repeat(64);
+
+function event(overrides = {}) {
+  return {
+    id: overrides.id ?? `${Math.random()}`.padEnd(64, "0").slice(0, 64),
+    pubkey: overrides.pubkey ?? OTHER,
+    created_at: overrides.created_at ?? 100,
+    kind: overrides.kind ?? 9,
+    tags: overrides.tags ?? [],
+    content: overrides.content ?? "",
+    sig: overrides.sig ?? "sig",
+  };
+}
+
+function relayFor(filters) {
+  return {
+    requests: [],
+    async fetchEvents(filter) {
+      this.requests.push(filter);
+      return filters.shift()?.(filter) ?? [];
+    },
+  };
+}
+
+test("extractMemberChannelIds deduplicates d tags", () => {
+  assert.deepEqual(
+    extractMemberChannelIds([
+      event({ tags: [["d", "one"], ["d", "two"]] }),
+      event({ tags: [["d", "one"]] }),
+    ]),
+    ["one", "two"],
+  );
+});
+
+test("resolveObservedChannels uses latest metadata and archived flag", () => {
+  assert.deepEqual(
+    resolveObservedChannels(
+      ["stream", "dm", "missing"],
+      [
+        event({ created_at: 1, tags: [["d", "dm"], ["t", "stream"]] }),
+        event({ created_at: 2, tags: [["d", "dm"], ["t", "dm"]] }),
+        event({ tags: [["d", "stream"], ["archived", "true"]] }),
+      ],
+    ),
+    [
+      { id: "stream", channelType: "stream", archived: true },
+      { id: "dm", channelType: "dm", archived: false },
+      { id: "missing", channelType: "stream", archived: false },
+    ],
+  );
+});
+
+test("extractHiddenDmIds reads h tags from latest visibility snapshot", () => {
+  assert.deepEqual(
+    extractHiddenDmIds([
+      event({ created_at: 1, tags: [["h", "old"]] }),
+      event({ created_at: 2, tags: [["h", "new"], ["h", "other"]] }),
+    ]),
+    new Set(["new", "other"]),
+  );
+});
+
+test("fetchWorkspaceUnread returns dot and mention count without total unread count", async () => {
+  const relay = relayFor([
+    () => [event({ tags: [["d", CHANNEL_ID], ["p", PUBKEY]] })],
+    () => [event({ tags: [["d", CHANNEL_ID], ["t", "stream"]] })],
+    () => [],
+    () => [],
+    () => [
+      event({ id: "unread".padEnd(64, "0"), created_at: 20, tags: [["h", CHANNEL_ID]] }),
+    ],
+    () => [
+      event({
+        id: "mention".padEnd(64, "0"),
+        created_at: 30,
+        tags: [["h", CHANNEL_ID], ["p", PUBKEY]],
+      }),
+    ],
+  ]);
+
+  const result = await fetchWorkspaceUnread({
+    client: relay,
+    pubkey: PUBKEY,
+    nowSeconds: 100,
+    decryptReadState: async (value) => value,
+  });
+
+  assert.deepEqual(result, { hasUnread: true, mentionCount: 1 });
+  assert.equal(relay.requests.at(-1)["#p"][0], PUBKEY);
+});
+
+test("fetchWorkspaceUnread ignores self-authored and read thread/message events", async () => {
+  const threadReply = event({
+    id: "reply".padEnd(64, "0"),
+    created_at: 50,
+    tags: [
+      ["h", CHANNEL_ID],
+      ["e", THREAD_ROOT, "", "root"],
+      ["e", "parent".padEnd(64, "0"), "", "reply"],
+      ["p", PUBKEY],
+    ],
+  });
+  const selfMention = event({
+    id: "self".padEnd(64, "0"),
+    pubkey: PUBKEY,
+    created_at: 70,
+    tags: [["h", CHANNEL_ID], ["p", PUBKEY]],
+  });
+
+  const relay = relayFor([
+    () => [event({ tags: [["d", CHANNEL_ID], ["p", PUBKEY]] })],
+    () => [event({ tags: [["d", CHANNEL_ID], ["t", "stream"]] })],
+    () => [],
+    () => [
+      event({
+        pubkey: PUBKEY,
+        created_at: 80,
+        tags: [["d", "read-state:test"], ["t", "read-state"]],
+        content: JSON.stringify({
+          v: 1,
+          client_id: "client",
+          contexts: {
+            [CHANNEL_ID]: 10,
+            [`thread:${THREAD_ROOT}`]: 60,
+          },
+        }),
+      }),
+    ],
+    () => [threadReply, selfMention],
+    () => [threadReply, selfMention],
+  ]);
+
+  const result = await fetchWorkspaceUnread({
+    client: relay,
+    pubkey: PUBKEY,
+    nowSeconds: 100,
+    decryptReadState: async (value) => value,
+  });
+
+  assert.deepEqual(result, { hasUnread: false, mentionCount: 0 });
+});
diff --git a/desktop/src/features/workspaces/workspaceUnreadObserver.ts b/desktop/src/features/workspaces/workspaceUnreadObserver.ts
new file mode 100644
index 0000000000..e41ddee47e
--- /dev/null
+++ b/desktop/src/features/workspaces/workspaceUnreadObserver.ts
@@ -0,0 +1,236 @@
+import { DM_NOTIFIABLE_EVENT_KINDS } from "@/features/channels/isDmNotifiableKind";
+import { mergeReadStateEvents } from "@/features/channels/readState/readStateSnapshot";
+import {
+  maxReadAt,
+  msgContextKey,
+} from "@/features/channels/readState/readStateFormat";
+import {
+  getThreadReference,
+  isBroadcastReply,
+} from "@/features/messages/lib/threading";
+import type { Workspace } from "@/features/workspaces/types";
+import { withReadOnlyRelayClient } from "@/shared/api/readOnlyRelayClient";
+import type { RelaySubscriptionFilter } from "@/shared/api/relayClientShared";
+import type { ChannelType, RelayEvent } from "@/shared/api/types";
+import {
+  CHANNEL_MESSAGE_EVENT_KINDS,
+  HOME_MENTION_EVENT_KINDS,
+  KIND_DM_VISIBILITY,
+  KIND_READ_STATE,
+} from "@/shared/constants/kinds";
+
+const KIND_NIP29_GROUP_METADATA = 39000;
+const KIND_NIP29_GROUP_MEMBERS = 39002;
+
+const MEMBER_CHANNEL_LIMIT = 1000;
+const METADATA_LIMIT = 1000;
+const UNREAD_EXISTENCE_LIMIT = 50;
+const MENTION_COUNT_LIMIT = 100;
+const READ_STATE_FETCH_LIMIT = 500;
+const READ_STATE_HORIZON_SECONDS = 7 * 24 * 60 * 60;
+
+export type WorkspaceUnreadObserverResult = {
+  hasUnread: boolean;
+  mentionCount: number;
+};
+
+type WorkspaceUnreadRelay = {
+  fetchEvents(filter: RelaySubscriptionFilter): Promise<RelayEvent[]>;
+};
+
+type ObservedChannel = {
+  id: string;
+  channelType: ChannelType;
+  archived: boolean;
+};
+
+export async function pollWorkspaceUnread(
+  workspace: Workspace,
+  pubkey: string,
+): Promise<WorkspaceUnreadObserverResult> {
+  return withReadOnlyRelayClient(workspace.relayUrl, (client) =>
+    fetchWorkspaceUnread({ client, pubkey }),
+  );
+}
+
+export async function fetchWorkspaceUnread(args: {
+  client: WorkspaceUnreadRelay;
+  pubkey: string;
+  nowSeconds?: number;
+  decryptReadState?: (ciphertext: string) => Promise<string>;
+}): Promise<WorkspaceUnreadObserverResult> {
+  const { client, pubkey } = args;
+  const normalizedPubkey = pubkey.toLowerCase();
+  const nowSeconds = args.nowSeconds ?? Math.floor(Date.now() / 1_000);
+
+  const memberEvents = await client.fetchEvents({
+    kinds: [KIND_NIP29_GROUP_MEMBERS],
+    "#p": [pubkey],
+    limit: MEMBER_CHANNEL_LIMIT,
+  });
+  const channelIds = extractMemberChannelIds(memberEvents);
+  if (channelIds.length === 0) {
+    return { hasUnread: false, mentionCount: 0 };
+  }
+
+  const [metadataEvents, visibilityEvents, readStateEvents] = await Promise.all([
+    client.fetchEvents({
+      kinds: [KIND_NIP29_GROUP_METADATA],
+      "#d": channelIds,
+      limit: METADATA_LIMIT,
+    }),
+    client.fetchEvents({
+      kinds: [KIND_DM_VISIBILITY],
+      "#p": [pubkey],
+      limit: 1,
+    }),
+    client.fetchEvents({
+      kinds: [KIND_READ_STATE],
+      authors: [pubkey],
+      "#t": ["read-state"],
+      since: nowSeconds - READ_STATE_HORIZON_SECONDS,
+      limit: READ_STATE_FETCH_LIMIT,
+    }),
+  ]);
+
+  const hiddenDmIds = extractHiddenDmIds(visibilityEvents);
+  const channels = resolveObservedChannels(channelIds, metadataEvents).filter(
+    (channel) =>
+      !channel.archived &&
+      (channel.channelType !== "dm" || !hiddenDmIds.has(channel.id)),
+  );
+  const readState = await mergeReadStateEvents(
+    readStateEvents,
+    pubkey,
+    args.decryptReadState,
+  );
+
+  let hasUnread = false;
+  let mentionCount = 0;
+
+  for (const channel of channels) {
+    const readAt = readState.get(channel.id) ?? null;
+    const since = readAt === null ? 0 : readAt + 1;
+    const kinds = unreadKindsForChannel(channel.channelType);
+
+    const unreadEventsPromise: Promise<RelayEvent[]> = hasUnread
+      ? Promise.resolve([])
+      : client.fetchEvents({
+          kinds,
+          "#h": [channel.id],
+          since,
+          limit: UNREAD_EXISTENCE_LIMIT,
+        });
+    const mentionEventsPromise: Promise<RelayEvent[]> = client.fetchEvents({
+      kinds: [...HOME_MENTION_EVENT_KINDS],
+      "#h": [channel.id],
+      "#p": [pubkey],
+      since,
+      limit: MENTION_COUNT_LIMIT,
+    });
+
+    const [unreadEvents, mentionEvents] = await Promise.all([
+      unreadEventsPromise,
+      mentionEventsPromise,
+    ]);
+
+    if (!hasUnread) {
+      hasUnread = unreadEvents.some((event) =>
+        isUnreadExternalEvent(event, readState, readAt, normalizedPubkey),
+      );
+    }
+
+    mentionCount += mentionEvents.filter((event) =>
+      isUnreadExternalEvent(event, readState, readAt, normalizedPubkey),
+    ).length;
+  }
+
+  return { hasUnread: hasUnread || mentionCount > 0, mentionCount };
+}
+
+export function extractMemberChannelIds(events: RelayEvent[]): string[] {
+  const ids = new Set<string>();
+  for (const event of events) {
+    for (const tag of event.tags) {
+      if (tag[0] === "d" && tag[1]) {
+        ids.add(tag[1]);
+      }
+    }
+  }
+  return [...ids];
+}
+
+export function resolveObservedChannels(
+  channelIds: string[],
+  metadataEvents: RelayEvent[],
+): ObservedChannel[] {
+  const latestMetadata = new Map<string, RelayEvent>();
+  for (const event of metadataEvents) {
+    const channelId = tagValue(event, "d");
+    if (!channelId) continue;
+    const existing = latestMetadata.get(channelId);
+    if (!existing || event.created_at > existing.created_at) {
+      latestMetadata.set(channelId, event);
+    }
+  }
+
+  return channelIds.map((id) => {
+    const metadata = latestMetadata.get(id);
+    const typeTag = metadata ? tagValue(metadata, "t") : null;
+    return {
+      id,
+      channelType: toChannelType(typeTag),
+      archived:
+        metadata?.tags.some(
+          (tag) => tag[0] === "archived" && tag[1] === "true",
+        ) ?? false,
+    };
+  });
+}
+
+export function extractHiddenDmIds(events: RelayEvent[]): Set<string> {
+  const latest = events.reduce<RelayEvent | null>(
+    (current, event) =>
+      current === null || event.created_at > current.created_at ? event : current,
+    null,
+  );
+  return new Set(
+    (latest?.tags ?? [])
+      .filter((tag) => tag[0] === "h" && tag[1])
+      .map((tag) => tag[1]),
+  );
+}
+
+function unreadKindsForChannel(channelType: ChannelType): number[] {
+  return channelType === "dm"
+    ? [...DM_NOTIFIABLE_EVENT_KINDS]
+    : [...CHANNEL_MESSAGE_EVENT_KINDS];
+}
+
+function isUnreadExternalEvent(
+  event: RelayEvent,
+  readState: ReadonlyMap<string, number>,
+  channelReadAt: number | null,
+  normalizedPubkey: string,
+): boolean {
+  if (event.pubkey.toLowerCase() === normalizedPubkey) return false;
+
+  const rootId = isBroadcastReply(event.tags)
+    ? null
+    : getThreadReference(event.tags).rootId;
+  const readAt = maxReadAt(
+    channelReadAt,
+    readState.get(msgContextKey(event.id)) ?? null,
+    rootId === null ? null : (readState.get(`thread:${rootId}`) ?? null),
+  );
+
+  return readAt === null || event.created_at > readAt;
+}
+
+function tagValue(event: RelayEvent, name: string): string | null {
+  return event.tags.find((tag) => tag[0] === name)?.[1] ?? null;
+}
+
+function toChannelType(value: string | null): ChannelType {
+  return value === "forum" || value === "dm" ? value : "stream";
+}
diff --git a/desktop/src/shared/api/readOnlyRelayClient.ts b/desktop/src/shared/api/readOnlyRelayClient.ts
new file mode 100644
index 0000000000..febfe5fe12
--- /dev/null
+++ b/desktop/src/shared/api/readOnlyRelayClient.ts
@@ -0,0 +1,263 @@
+import { Channel, invoke } from "@tauri-apps/api/core";
+
+import { createAuthEvent } from "@/shared/api/tauri";
+import type { RelayEvent } from "@/shared/api/types";
+import {
+  getTextPayload,
+  sortEvents,
+  type RelaySubscriptionFilter,
+} from "@/shared/api/relayClientShared";
+
+const AUTH_TIMEOUT_MS = 8_000;
+const HISTORY_TIMEOUT_MS = 8_000;
+
+type PendingHistory = {
+  events: RelayEvent[];
+  resolve: (events: RelayEvent[]) => void;
+  reject: (error: Error) => void;
+  timeout: number;
+};
+
+/**
+ * Minimal read-only relay session for inactive-workspace observation.
+ * It never reads or mutates the active workspace backend relay URL; callers pass
+ * an explicit URL and should disconnect as soon as their polling batch finishes.
+ */
+export class ReadOnlyRelayClient {
+  private wsId: number | null = null;
+  private onMessageChannel: Channel<unknown> | null = null;
+  private connectPromise: Promise<void> | null = null;
+  private authRequest: {
+    pendingEventId: string;
+    resolve: () => void;
+    reject: (error: Error) => void;
+    timeout: number;
+  } | null = null;
+  private histories = new Map<string, PendingHistory>();
+  private generation = 0;
+
+  private readonly relayUrl: string;
+
+  constructor(relayUrl: string) {
+    this.relayUrl = relayUrl;
+  }
+
+  async connect(): Promise<void> {
+    if (this.wsId !== null) return;
+    if (this.connectPromise) return this.connectPromise;
+
+    const promise = this.openConnection();
+    this.connectPromise = promise;
+    try {
+      await promise;
+    } finally {
+      if (this.connectPromise === promise) {
+        this.connectPromise = null;
+      }
+    }
+  }
+
+  disconnect(): void {
+    const error = new Error("Read-only relay observer disconnected.");
+    this.generation++;
+
+    if (this.wsId !== null) {
+      void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch(
+        () => {},
+      );
+      this.wsId = null;
+    }
+
+    if (this.authRequest) {
+      window.clearTimeout(this.authRequest.timeout);
+      this.authRequest.reject(error);
+      this.authRequest = null;
+    }
+
+    for (const [subId, pending] of this.histories) {
+      window.clearTimeout(pending.timeout);
+      pending.reject(error);
+      this.histories.delete(subId);
+    }
+
+    this.onMessageChannel = null;
+    this.connectPromise = null;
+  }
+
+  async fetchEvents(filter: RelaySubscriptionFilter): Promise<RelayEvent[]> {
+    await this.connect();
+    return this.requestHistory(filter);
+  }
+
+  private async openConnection(): Promise<void> {
+    const generation = ++this.generation;
+    this.onMessageChannel = new Channel<unknown>((message) => {
+      void this.handleWsMessage(message, generation);
+    });
+
+    this.wsId = await invoke<number>("plugin:websocket|connect", {
+      url: this.relayUrl,
+      onMessage: this.onMessageChannel,
+      config: {},
+    });
+
+    await new Promise<void>((resolve, reject) => {
+      const timeout = window.setTimeout(() => {
+        this.authRequest = null;
+        this.disconnect();
+        reject(new Error("Timed out while authenticating observer relay."));
+      }, AUTH_TIMEOUT_MS);
+
+      this.authRequest = {
+        pendingEventId: "",
+        resolve,
+        reject,
+        timeout,
+      };
+    });
+  }
+
+  private requestHistory(filter: RelaySubscriptionFilter): Promise<RelayEvent[]> {
+    if (this.wsId === null) {
+      return Promise.reject(new Error("Read-only relay socket is not connected."));
+    }
+
+    return new Promise<RelayEvent[]>((resolve, reject) => {
+      const subId = `observer-history-${crypto.randomUUID()}`;
+      const timeout = window.setTimeout(() => {
+        this.histories.delete(subId);
+        void this.sendRaw(["CLOSE", subId]).catch(() => {});
+        reject(new Error("Timed out while loading observer relay history."));
+      }, HISTORY_TIMEOUT_MS);
+
+      this.histories.set(subId, { events: [], resolve, reject, timeout });
+
+      void this.sendRaw(["REQ", subId, filter]).catch((error) => {
+        window.clearTimeout(timeout);
+        this.histories.delete(subId);
+        reject(
+          error instanceof Error
+            ? error
+            : new Error("Failed to request observer relay history."),
+        );
+      });
+    });
+  }
+
+  private async sendRaw(payload: unknown[]): Promise<void> {
+    if (this.wsId === null) {
+      throw new Error("Read-only relay socket is not connected.");
+    }
+
+    await invoke("plugin:websocket|send", {
+      id: this.wsId,
+      message: {
+        type: "Text",
+        data: JSON.stringify(payload),
+      },
+    });
+  }
+
+  private async handleWsMessage(
+    message: unknown,
+    generation: number,
+  ): Promise<void> {
+    if (generation !== this.generation) return;
+
+    if (
+      typeof message === "object" &&
+      message !== null &&
+      "type" in message &&
+      (message.type === "Close" || message.type === "Error")
+    ) {
+      this.disconnect();
+      return;
+    }
+
+    const payload = getTextPayload(message);
+    if (!payload) return;
+
+    let data: unknown;
+    try {
+      data = JSON.parse(payload);
+    } catch {
+      return;
+    }
+    if (!Array.isArray(data) || data.length === 0) return;
+
+    const [type, ...rest] = data;
+    if (type === "AUTH" && typeof rest[0] === "string") {
+      await this.handleAuthChallenge(rest[0], generation);
+      return;
+    }
+    if (type === "EVENT" && typeof rest[0] === "string" && rest[1]) {
+      this.histories.get(rest[0])?.events.push(rest[1] as RelayEvent);
+      return;
+    }
+    if (
+      type === "OK" &&
+      typeof rest[0] === "string" &&
+      typeof rest[1] === "boolean"
+    ) {
+      this.handleOk(rest[0], rest[1], typeof rest[2] === "string" ? rest[2] : "");
+      return;
+    }
+    if (type === "EOSE" && typeof rest[0] === "string") {
+      this.handleEose(rest[0]);
+    }
+  }
+
+  private async handleAuthChallenge(
+    challenge: string,
+    generation: number,
+  ): Promise<void> {
+    const event = await createAuthEvent({
+      challenge,
+      relayUrl: this.relayUrl,
+    });
+
+    if (generation !== this.generation || !this.authRequest) return;
+    this.authRequest.pendingEventId = event.id;
+    await this.sendRaw(["AUTH", event]);
+  }
+
+  private handleOk(eventId: string, success: boolean, message: string): void {
+    if (!this.authRequest || this.authRequest.pendingEventId !== eventId) {
+      return;
+    }
+
+    window.clearTimeout(this.authRequest.timeout);
+    const authRequest = this.authRequest;
+    this.authRequest = null;
+
+    if (success) {
+      authRequest.resolve();
+      return;
+    }
+
+    authRequest.reject(message ? new Error(message) : new Error("Observer relay authentication rejected."));
+    this.disconnect();
+  }
+
+  private handleEose(subId: string): void {
+    const pending = this.histories.get(subId);
+    if (!pending) return;
+
+    window.clearTimeout(pending.timeout);
+    this.histories.delete(subId);
+    void this.sendRaw(["CLOSE", subId]).catch(() => {});
+    pending.resolve(sortEvents(pending.events));
+  }
+}
+
+export async function withReadOnlyRelayClient<T>(
+  relayUrl: string,
+  callback: (client: ReadOnlyRelayClient) => Promise<T>,
+): Promise<T> {
+  const client = new ReadOnlyRelayClient(relayUrl);
+  try {
+    return await callback(client);
+  } finally {
+    client.disconnect();
+  }
+}
diff --git a/desktop/src/shared/ui/popover.tsx b/desktop/src/shared/ui/popover.tsx
index 6632047eeb..eb67c959e6 100644
--- a/desktop/src/shared/ui/popover.tsx
+++ b/desktop/src/shared/ui/popover.tsx
@@ -15,51 +15,27 @@ const PopoverTrigger = PopoverPrimitive.Trigger;
 
 const PopoverAnchor = PopoverPrimitive.Anchor;
 
-type PopoverContentProps = React.ComponentPropsWithoutRef<
-  typeof PopoverPrimitive.Content
-> & {
-  portalled?: boolean;
-};
-
 const PopoverContent = React.forwardRef<
   React.ElementRef<typeof PopoverPrimitive.Content>,
-  PopoverContentProps
->(
-  (
-    {
-      className,
-      align = "center",
-      portalled = true,
-      sideOffset = 4,
-      style,
-      ...props
-    },
-    ref,
-  ) => {
-    const content = (
-      <PopoverPrimitive.Content
-        ref={ref}
-        align={align}
-        sideOffset={sideOffset}
-        className={cn(
-          "z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-xl p-4 outline-hidden",
-          POPOVER_RADIX_MOTION_CLASS,
-          POPOVER_RADIX_SIDE_MOTION_CLASS,
-          POPOVER_SURFACE_CLASS,
-          className,
-        )}
-        style={{ ...POPOVER_SHADOW_STYLE, ...style }}
-        {...props}
-      />
-    );
-
-    if (!portalled) {
-      return content;
-    }
-
-    return <PopoverPrimitive.Portal>{content}</PopoverPrimitive.Portal>;
-  },
-);
+  React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
+>(({ className, align = "center", sideOffset = 4, style, ...props }, ref) => (
+  <PopoverPrimitive.Portal>
+    <PopoverPrimitive.Content
+      ref={ref}
+      align={align}
+      sideOffset={sideOffset}
+      className={cn(
+        "z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-xl p-4 outline-hidden",
+        POPOVER_RADIX_MOTION_CLASS,
+        POPOVER_RADIX_SIDE_MOTION_CLASS,
+        POPOVER_SURFACE_CLASS,
+        className,
+      )}
+      style={{ ...POPOVER_SHADOW_STYLE, ...style }}
+      {...props}
+    />
+  </PopoverPrimitive.Portal>
+));
 PopoverContent.displayName = PopoverPrimitive.Content.displayName;
 
 export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor };
diff --git a/desktop/tests/e2e/sidebar.spec.ts b/desktop/tests/e2e/sidebar.spec.ts
index a241b05954..60c70ae166 100644
--- a/desktop/tests/e2e/sidebar.spec.ts
+++ b/desktop/tests/e2e/sidebar.spec.ts
@@ -1,17 +1,13 @@
-import { expect, test, type Locator, type Page } from "@playwright/test";
+import { expect, test, type Page } from "@playwright/test";
 
 import { installMockBridge } from "../helpers/bridge";
 
 const SIDEBAR_WIDTH_STORAGE_KEY = "buzz-sidebar-width";
 const DEFAULT_SIDEBAR_WIDTH = 300;
-const MOCK_PUBKEY = "deadbeef".repeat(8);
-const RANDOM_CHANNEL_ID = "9dae0116-799b-5071-a0a8-fdd30a91a35d";
-const PRIORITY_SECTION = {
-  id: "sec-priority",
-  name: "Priority",
-  icon: "\u2728",
-  order: 0,
-};
+
+test.beforeEach(async ({ page }) => {
+  await installMockBridge(page);
+});
 
 async function sidebarWidth(page: Page) {
   return page.getByTestId("app-sidebar").evaluate((element) => {
@@ -45,324 +41,7 @@ async function dragSidebarRail(page: Page, deltaX: number) {
   await page.mouse.up();
 }
 
-async function seedChannelSections(
-  page: Page,
-  assignments: Record<string, string> = {},
-) {
-  await page.addInitScript(
-    ({ pubkey, section, seededAssignments }) => {
-      window.localStorage.setItem(
-        `buzz-channel-sections.v1:${pubkey}`,
-        JSON.stringify({
-          version: 1,
-          sections: [section],
-          assignments: seededAssignments,
-        }),
-      );
-    },
-    {
-      pubkey: MOCK_PUBKEY,
-      section: PRIORITY_SECTION,
-      seededAssignments: assignments,
-    },
-  );
-}
-
-async function visibleContextMenuRows(menu: Locator) {
-  return menu.locator('[role="menuitem"]').evaluateAll((elements) => {
-    return elements
-      .filter((element): element is HTMLElement => {
-        if (!(element instanceof HTMLElement)) return false;
-        const rect = element.getBoundingClientRect();
-        return rect.width > 0 && rect.height > 0;
-      })
-      .map((element) => {
-        const iconSlot = element.querySelector<HTMLElement>(
-          "[data-sidebar-context-icon-slot]",
-        );
-        const label = Array.from(element.querySelectorAll("span")).find(
-          (span) =>
-            !span.hasAttribute("data-sidebar-context-icon-slot") &&
-            span.textContent?.trim(),
-        );
-
-        return {
-          iconLeft: iconSlot?.getBoundingClientRect().left ?? null,
-          text: element.textContent?.trim() ?? "",
-          textLeft: label?.getBoundingClientRect().left ?? null,
-        };
-      });
-  });
-}
-
-async function expectMenuTextAligned(menu: Locator) {
-  const rows = await visibleContextMenuRows(menu);
-  const textLefts = rows
-    .map((row) => row.textLeft)
-    .filter((left): left is number => typeof left === "number");
-  expect(textLefts.length).toBeGreaterThan(1);
-  const firstTextLeft = textLefts[0];
-  for (const textLeft of textLefts) {
-    expect(Math.abs(textLeft - firstTextLeft)).toBeLessThanOrEqual(1);
-  }
-  for (const row of rows) {
-    expect(row.iconLeft, row.text).not.toBeNull();
-  }
-}
-
-async function expectAppClickable(page: Page) {
-  await expect
-    .poll(() =>
-      page.evaluate(() => getComputedStyle(document.body).pointerEvents),
-    )
-    .not.toBe("none");
-  await page.getByTestId("channel-general").click();
-  await expect(page.getByTestId("chat-title")).toHaveText("general");
-}
-
-async function emojiPickerScrollTop(emojiPicker: Locator) {
-  return emojiPicker.evaluate((element) => {
-    const scroll = element.shadowRoot?.querySelector<HTMLElement>(".scroll");
-    return scroll?.scrollTop ?? null;
-  });
-}
-
-test("aligns custom section headers with the built-in sidebar sections", async ({
-  page,
-}) => {
-  await seedChannelSections(page, {
-    [RANDOM_CHANNEL_ID]: PRIORITY_SECTION.id,
-  });
-  await installMockBridge(page);
-  await page.goto("/");
-  await expect(page.getByTestId("app-sidebar")).toBeVisible();
-
-  const customSectionButton = page.locator(
-    `[aria-controls="sidebar-section-${PRIORITY_SECTION.id}"]`,
-  );
-  await expect(customSectionButton).toBeVisible();
-  await expect(customSectionButton).toContainText(PRIORITY_SECTION.name);
-  await expect(customSectionButton).toContainText(PRIORITY_SECTION.icon);
-  await expect(
-    customSectionButton.locator(".lucide-grip-vertical"),
-  ).toHaveCount(0);
-  await expect(customSectionButton.locator(".lucide-chevron-down")).toHaveCount(
-    1,
-  );
-
-  const inlineGaps = await page.evaluate(
-    ({ sectionId }) => {
-      const sectionIcon = document.querySelector<HTMLElement>(
-        `[data-testid="section-icon-${sectionId}"]`,
-      );
-      const sectionTitle = document.querySelector<HTMLElement>(
-        `[data-testid="section-title-${sectionId}"]`,
-      );
-      const channelRow = document.querySelector<HTMLElement>(
-        '[data-testid="channel-random"]',
-      );
-      const channelIcon = channelRow?.querySelector<HTMLElement>("svg");
-      const channelTitle = Array.from(
-        channelRow?.querySelectorAll<HTMLElement>("span") ?? [],
-      ).find((span) => span.textContent?.trim() === "random");
-
-      if (!sectionIcon || !sectionTitle || !channelIcon || !channelTitle) {
-        throw new Error("Expected section and channel inline elements");
-      }
-
-      return {
-        channel: Math.round(
-          channelTitle.getBoundingClientRect().left -
-            channelIcon.getBoundingClientRect().right,
-        ),
-        section: Math.round(
-          sectionTitle.getBoundingClientRect().left -
-            sectionIcon.getBoundingClientRect().right,
-        ),
-      };
-    },
-    { sectionId: PRIORITY_SECTION.id },
-  );
-
-  expect(inlineGaps.section).toBe(inlineGaps.channel);
-
-  const sectionActions = page.getByTestId(
-    `section-actions-${PRIORITY_SECTION.id}`,
-  );
-  await expect
-    .poll(() =>
-      sectionActions.evaluate((element) => getComputedStyle(element).opacity),
-    )
-    .toBe("0");
-
-  await page.getByTestId("channel-random").hover();
-  await expect
-    .poll(() =>
-      sectionActions.evaluate((element) => getComputedStyle(element).opacity),
-    )
-    .toBe("1");
-
-  await page.mouse.move(500, 500);
-  await expect
-    .poll(() =>
-      sectionActions.evaluate((element) => getComputedStyle(element).opacity),
-    )
-    .toBe("0");
-
-  await customSectionButton.hover();
-  await expect(sectionActions).toBeVisible();
-  await expect(sectionActions.locator(".lucide-ellipsis-vertical")).toHaveCount(
-    1,
-  );
-  await expect
-    .poll(() =>
-      sectionActions.evaluate((element) => getComputedStyle(element).opacity),
-    )
-    .toBe("1");
-  await sectionActions.click();
-  await expect(
-    page.getByRole("menuitem", { name: "Rename section" }),
-  ).toBeVisible();
-  await expect
-    .poll(() =>
-      sectionActions.evaluate((element) => getComputedStyle(element).opacity),
-    )
-    .toBe("1");
-  await expect(page.getByRole("menuitem", { name: "Move up" })).toBeVisible();
-  await expect(page.getByRole("menuitem", { name: "Move down" })).toBeVisible();
-  await expect(
-    page.getByRole("menuitem", { name: "Delete section" }),
-  ).toBeVisible();
-  await page.mouse.click(500, 500);
-  await expect
-    .poll(() =>
-      sectionActions.evaluate((element) => getComputedStyle(element).opacity),
-    )
-    .toBe("0");
-});
-
-test("custom section emoji picker scrolls", async ({ page }) => {
-  await seedChannelSections(page);
-  await installMockBridge(page);
-  await page.goto("/");
-  await expect(page.getByTestId("app-sidebar")).toBeVisible();
-
-  await page
-    .locator(`[aria-controls="sidebar-section-${PRIORITY_SECTION.id}"]`)
-    .hover();
-  await page.getByTestId(`section-actions-${PRIORITY_SECTION.id}`).hover();
-  await page.getByTestId(`section-actions-${PRIORITY_SECTION.id}`).click();
-  await page.getByRole("menuitem", { name: "Rename section" }).click();
-
-  await expect(
-    page.getByRole("dialog", { name: "Rename section" }),
-  ).toBeVisible();
-  await page.getByRole("button", { name: "Choose section icon" }).click();
-  const emojiPicker = page.locator("em-emoji-picker").first();
-  await expect(emojiPicker).toBeVisible();
-
-  await expect.poll(() => emojiPickerScrollTop(emojiPicker)).not.toBeNull();
-  const beforeScrollTop = await emojiPickerScrollTop(emojiPicker);
-  expect(beforeScrollTop).not.toBeNull();
-
-  await emojiPicker.hover();
-  await page.mouse.wheel(0, 700);
-
-  await expect
-    .poll(() => emojiPickerScrollTop(emojiPicker))
-    .toBeGreaterThan(beforeScrollTop ?? 0);
-});
-
-test("orders and aligns channel and direct-message context menu items", async ({
-  page,
-}) => {
-  await seedChannelSections(page);
-  await installMockBridge(page);
-  await page.goto("/");
-  await expect(page.getByTestId("app-sidebar")).toBeVisible();
-
-  await page.getByTestId("channel-random").click({ button: "right" });
-  await expect(
-    page.getByRole("menuitem", { name: "Mark unread" }),
-  ).toBeVisible();
-  const channelMenu = page.getByRole("menu").first();
-  await expectMenuTextAligned(channelMenu);
-
-  const channelRows = await visibleContextMenuRows(channelMenu);
-  expect(channelRows.map((row) => row.text)).toEqual([
-    "Copy",
-    "Move to section",
-    "Mark unread",
-    "Mute channel",
-    "Star channel",
-    "Leave channel",
-  ]);
-
-  const channelMenuBox = await channelMenu.boundingBox();
-  expect(channelMenuBox?.width ?? 0).toBeGreaterThanOrEqual(238);
-
-  await page.getByRole("menuitem", { name: "Copy" }).hover();
-  await expect(
-    page.getByRole("menuitem", { name: "Copy channel name" }),
-  ).toBeVisible();
-  await expect(
-    page.getByRole("menuitem", { name: "Copy channel ID" }),
-  ).toBeVisible();
-
-  await page.keyboard.press("Escape");
-  await page.keyboard.press("Escape");
-  await expect(page.getByRole("menu")).toHaveCount(0);
-  await page.getByTestId("channel-alice-tyler").click({ button: "right" });
-  await expect(page.getByRole("menuitem", { name: "Copy" })).toBeVisible();
-  const dmMenu = page.getByRole("menu").first();
-  await expectMenuTextAligned(dmMenu);
-
-  const dmRows = await visibleContextMenuRows(dmMenu);
-  expect(dmRows.map((row) => row.text)).toEqual([
-    "Copy",
-    "Mark unread",
-    "Mute channel",
-  ]);
-});
-
-test("channel context menu move and leave actions do not freeze the app", async ({
-  page,
-}) => {
-  await seedChannelSections(page);
-  await installMockBridge(page);
-  await page.goto("/");
-  await expect(page.getByTestId("app-sidebar")).toBeVisible();
-
-  await page.getByTestId("channel-random").click({ button: "right" });
-  await page.getByRole("menuitem", { name: "Move to section" }).hover();
-  await page.getByRole("menuitem", { name: PRIORITY_SECTION.name }).click();
-  await expect(page.getByRole("menu")).toHaveCount(0);
-  await expect
-    .poll(() =>
-      page.evaluate(
-        ({ pubkey, channelId }) => {
-          const raw = window.localStorage.getItem(
-            `buzz-channel-sections.v1:${pubkey}`,
-          );
-          const parsed = raw ? JSON.parse(raw) : null;
-          return parsed?.assignments?.[channelId] ?? null;
-        },
-        { pubkey: MOCK_PUBKEY, channelId: RANDOM_CHANNEL_ID },
-      ),
-    )
-    .toBe(PRIORITY_SECTION.id);
-  await expectAppClickable(page);
-
-  await page.getByTestId("channel-random").click({ button: "right" });
-  await page.getByRole("menuitem", { name: "Leave channel" }).click();
-  await expect(page.getByRole("alertdialog")).toBeVisible();
-  await page.getByRole("button", { name: "Cancel" }).click();
-  await expect(page.getByRole("alertdialog")).toHaveCount(0);
-  await expectAppClickable(page);
-});
-
 test("fades the pinned sidebar chrome edges", async ({ page }) => {
-  await installMockBridge(page);
   await page.goto("/");
 
   const pinnedHeader = page.getByTestId("sidebar-pinned-header");
@@ -468,7 +147,6 @@ test("fades the pinned sidebar chrome edges", async ({ page }) => {
 test("resizes, persists, and snaps to the default sidebar width", async ({
   page,
 }) => {
-  await installMockBridge(page);
   await page.goto("/");
   await expect(page.getByTestId("app-sidebar")).toBeVisible();
 
@@ -495,7 +173,6 @@ test("resizes, persists, and snaps to the default sidebar width", async ({
 test("shows a sidebar update card when an update is ready", async ({
   page,
 }) => {
-  await installMockBridge(page);
   await page.goto("/");
   await expect(page.getByTestId("app-sidebar")).toBeVisible();
 
diff --git a/desktop/tests/e2e/workspace-rail.spec.ts b/desktop/tests/e2e/workspace-rail.spec.ts
new file mode 100644
index 0000000000..e57eaa5bf0
--- /dev/null
+++ b/desktop/tests/e2e/workspace-rail.spec.ts
@@ -0,0 +1,148 @@
+import { expect, test } from "@playwright/test";
+
+import { installMockBridge } from "../helpers/bridge";
+
+const RELAY_URL = "ws://localhost:3000";
+
+const WORKSPACE_A = {
+  id: "ws-a",
+  name: "Alpha",
+  relayUrl: RELAY_URL,
+  addedAt: "2026-01-01T00:00:00.000Z",
+};
+const WORKSPACE_B = {
+  id: "ws-b",
+  name: "Bravo",
+  relayUrl: "ws://localhost:3001",
+  addedAt: "2026-01-02T00:00:00.000Z",
+};
+
+async function seedWorkspaces(
+  page: import("@playwright/test").Page,
+  workspaces: Array<Record<string, unknown>>,
+  activeId: string,
+) {
+  await page.addInitScript(
+    ({ list, active }) => {
+      window.localStorage.setItem("buzz-workspaces", JSON.stringify(list));
+      window.localStorage.setItem("buzz-active-workspace-id", active);
+    },
+    { list: workspaces, active: activeId },
+  );
+}
+
+test.describe("workspace rail", () => {
+  test("shows a button per workspace and highlights the active one", async ({
+    page,
+  }) => {
+    await installMockBridge(page, undefined, { skipWorkspaceSeed: true });
+    await seedWorkspaces(page, [WORKSPACE_A, WORKSPACE_B], WORKSPACE_A.id);
+    await page.goto("/");
+
+    const rail = page.getByTestId("workspace-rail");
+    await expect(rail).toBeVisible();
+
+    const buttonA = page.getByTestId(`workspace-rail-button-${WORKSPACE_A.id}`);
+    const buttonB = page.getByTestId(`workspace-rail-button-${WORKSPACE_B.id}`);
+    await expect(buttonA).toBeVisible();
+    await expect(buttonB).toBeVisible();
+
+    // The active workspace is marked via aria-current.
+    await expect(buttonA).toHaveAttribute("aria-current", "true");
+    await expect(buttonB).not.toHaveAttribute("aria-current", "true");
+
+    // The add-workspace affordance lives at the bottom of the rail.
+    await expect(page.getByTestId("workspace-rail-add")).toBeVisible();
+  });
+
+  test("switches the active workspace on click", async ({ page }) => {
+    await installMockBridge(page, undefined, { skipWorkspaceSeed: true });
+    await seedWorkspaces(page, [WORKSPACE_A, WORKSPACE_B], WORKSPACE_A.id);
+    await page.goto("/");
+
+    await page.getByTestId(`workspace-rail-button-${WORKSPACE_B.id}`).click();
+
+    // Switching persists the newly active workspace id (the app then remounts
+    // against that relay via the existing workspace-init path).
+    await expect
+      .poll(() =>
+        page.evaluate(() =>
+          window.localStorage.getItem("buzz-active-workspace-id"),
+        ),
+      )
+      .toBe(WORKSPACE_B.id);
+  });
+
+  test("hides the rail with a single workspace", async ({ page }) => {
+    await installMockBridge(page, undefined, { skipWorkspaceSeed: true });
+    await seedWorkspaces(page, [WORKSPACE_A], WORKSPACE_A.id);
+    await page.goto("/");
+
+    // The channel sidebar still renders; the rail is omitted (a rail of one
+    // adds nothing).
+    await expect(page.getByTestId("app-sidebar")).toBeVisible();
+    await expect(page.getByTestId("workspace-rail")).toHaveCount(0);
+  });
+
+  test("keeps the rail visible when the sidebar is collapsed", async ({
+    page,
+  }) => {
+    await installMockBridge(page, undefined, { skipWorkspaceSeed: true });
+    await seedWorkspaces(page, [WORKSPACE_A, WORKSPACE_B], WORKSPACE_A.id);
+    await page.goto("/");
+
+    const rail = page.getByTestId("workspace-rail");
+    await expect(rail).toBeVisible();
+
+    // Collapse the sidebar via its keyboard shortcut. The rail is a sibling of
+    // the sidebar, not inside it, so it must stay fully visible and unshifted.
+    await page.evaluate(() => {
+      const isMac = /mac|iphone|ipad|ipod/i.test(navigator.platform);
+      window.dispatchEvent(
+        new KeyboardEvent("keydown", {
+          bubbles: true,
+          cancelable: true,
+          key: "s",
+          ctrlKey: !isMac,
+          metaKey: isMac,
+        }),
+      );
+    });
+
+    await expect(rail).toBeVisible();
+    await expect(
+      page.getByTestId(`workspace-rail-button-${WORKSPACE_B.id}`),
+    ).toBeVisible();
+    await expect(page.getByTestId("workspace-rail-add")).toBeVisible();
+  });
+
+  test("clears the macOS traffic lights", async ({ page }) => {
+    // Spoof macOS so the rail applies its traffic-light top inset.
+    await page.addInitScript(() => {
+      Object.defineProperty(navigator, "platform", { get: () => "MacIntel" });
+    });
+    await installMockBridge(page, undefined, { skipWorkspaceSeed: true });
+    await seedWorkspaces(page, [WORKSPACE_A, WORKSPACE_B], WORKSPACE_A.id);
+    await page.goto("/");
+
+    // The first workspace button must start below the traffic-light band
+    // (native controls sit around y<=31 with trafficLightPosition y:24).
+    const firstButton = page.getByTestId(
+      `workspace-rail-button-${WORKSPACE_A.id}`,
+    );
+    await expect(firstButton).toBeVisible();
+    const box = await firstButton.boundingBox();
+    expect(box).not.toBeNull();
+    expect(box?.y ?? 0).toBeGreaterThanOrEqual(32);
+
+    // With the rail visible, the top-chrome controls (sidebar toggle, back/
+    // forward) sit just past the traffic lights near the rail edge — not
+    // shifted far right by a redundant traffic-light offset.
+    const toggle = page
+      .locator('[data-testid="app-top-chrome"] button')
+      .first();
+    const toggleBox = await toggle.boundingBox();
+    expect(toggleBox).not.toBeNull();
+    expect(toggleBox?.x ?? 0).toBeLessThan(120);
+  });
+});

From 9c72c06c2bc8823372d8be46284f72730e826e45 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Wed, 1 Jul 2026 09:55:43 -0600
Subject: [PATCH 02/11] fix(desktop): top-align sidebar search and clean
 workspace initials

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>
---
 .../sidebar/ui/AppSidebarPinnedHeader.tsx     |  2 +-
 .../sidebar/ui/WorkspaceRail.test.mjs         | 20 ++++++++++++++++++-
 .../src/features/sidebar/ui/WorkspaceRail.tsx | 11 +++++++++-
 3 files changed, 30 insertions(+), 3 deletions(-)

diff --git a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
index 5d87190d2a..2e9dbbd7f4 100644
--- a/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
+++ b/desktop/src/features/sidebar/ui/AppSidebarPinnedHeader.tsx
@@ -59,7 +59,7 @@ export function AppSidebarPinnedHeader({
   suggestionChannels,
 }: AppSidebarPinnedHeaderProps) {
   return (
-    <div className="shrink-0 px-2 pt-2.5" data-testid="sidebar-pinned-header">
+    <div className="shrink-0 px-2" data-testid="sidebar-pinned-header">
       <TopbarSearch
         channelLabels={channelLabels}
         channels={searchChannels}
diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.test.mjs b/desktop/src/features/sidebar/ui/WorkspaceRail.test.mjs
index d0e5292403..910e3a4ad4 100644
--- a/desktop/src/features/sidebar/ui/WorkspaceRail.test.mjs
+++ b/desktop/src/features/sidebar/ui/WorkspaceRail.test.mjs
@@ -1,7 +1,25 @@
 import assert from "node:assert/strict";
 import { describe, it } from "node:test";
 
-import { workspaceRailIndicators } from "./WorkspaceRail.tsx";
+import {
+  workspaceInitials,
+  workspaceRailIndicators,
+} from "./WorkspaceRail.tsx";
+
+describe("workspaceInitials", () => {
+  it("filters punctuation before deriving initials", () => {
+    assert.equal(workspaceInitials("B (relay)"), "BR");
+  });
+  it("handles a leading symbol on a single word", () => {
+    assert.equal(workspaceInitials("(staging)"), "S");
+  });
+  it("still returns plain initials for normal names", () => {
+    assert.equal(workspaceInitials("Bravo Beta"), "BB");
+  });
+  it("returns empty for a symbol-only name (caller falls back)", () => {
+    assert.equal(workspaceInitials("()"), "");
+  });
+});
 
 describe("workspaceRailIndicators", () => {
   it("shows a dot for an observed workspace with unread", () => {
diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
index 47f1b3ef9e..f4edac6e0f 100644
--- a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
+++ b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
@@ -20,6 +20,15 @@ type WorkspaceRailProps = {
 
 const MAX_BADGE = 99;
 
+/**
+ * Workspace-button initials. Strips punctuation/symbols first so a name like
+ * "B (relay)" yields "BR", not "B(".
+ */
+export function workspaceInitials(name: string): string {
+  const cleaned = name.replace(/[^\p{L}\p{N}\s]/gu, " ");
+  return getInitials(cleaned);
+}
+
 /**
  * Presentation decisions for one workspace button, derived from its observed
  * unread state. Pure so it can be unit-tested without a DOM. The `state` guard
@@ -96,7 +105,7 @@ function WorkspaceButton({
               pending && "opacity-60",
             )}
           >
-            {getInitials(workspace.name) || "🐝"}
+            {workspaceInitials(workspace.name) || "🐝"}
           </span>
           {showBadge ? (
             <span

From 6a18e02a73c847cf0053536922ed3b3b5c146590 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Wed, 1 Jul 2026 09:59:09 -0600
Subject: [PATCH 03/11] fix(desktop): tighten workspace rail width

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>
---
 desktop/src/features/sidebar/ui/WorkspaceRail.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
index f4edac6e0f..067e179730 100644
--- a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
+++ b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
@@ -151,7 +151,7 @@ export function WorkspaceRail({
     <nav
       aria-label="Workspaces"
       className={cn(
-        "flex w-14 shrink-0 flex-col items-center gap-2 overflow-y-auto bg-sidebar pb-3",
+        "flex w-12 shrink-0 flex-col items-center gap-2 overflow-y-auto bg-sidebar pb-3",
         topPaddingClass,
       )}
       data-testid="workspace-rail"

From aa60f1883ffd23984f60f38ed30e138ae419b266 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Wed, 1 Jul 2026 10:07:00 -0600
Subject: [PATCH 04/11] fix(desktop): tighten workspace rail buttons toward the
 sidebar

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>
---
 desktop/src/features/sidebar/ui/WorkspaceRail.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
index 067e179730..0a6ff163ec 100644
--- a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
+++ b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
@@ -151,7 +151,7 @@ export function WorkspaceRail({
     <nav
       aria-label="Workspaces"
       className={cn(
-        "flex w-12 shrink-0 flex-col items-center gap-2 overflow-y-auto bg-sidebar pb-3",
+        "flex w-12 shrink-0 flex-col items-end gap-2 overflow-y-auto bg-sidebar pb-3",
         topPaddingClass,
       )}
       data-testid="workspace-rail"

From e43802143e8df1472c2db442a95a7904bcacd71e Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Wed, 1 Jul 2026 10:14:28 -0600
Subject: [PATCH 05/11] fix(desktop): center workspace rail buttons for
 consistent spacing
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

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>
---
 desktop/src/features/sidebar/ui/WorkspaceRail.tsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
index 0a6ff163ec..ee2cc120aa 100644
--- a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
+++ b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
@@ -88,7 +88,7 @@ function WorkspaceButton({
           <span
             aria-hidden="true"
             className={cn(
-              "absolute -left-2 w-1 rounded-r-full bg-sidebar-foreground transition-all",
+              "absolute left-[-4px] w-1 rounded-r-full bg-sidebar-foreground transition-all",
               isActive
                 ? "h-6 opacity-100"
                 : showDot
@@ -151,7 +151,7 @@ export function WorkspaceRail({
     <nav
       aria-label="Workspaces"
       className={cn(
-        "flex w-12 shrink-0 flex-col items-end gap-2 overflow-y-auto bg-sidebar pb-3",
+        "flex w-12 shrink-0 flex-col items-center gap-2 overflow-y-auto bg-sidebar pb-3",
         topPaddingClass,
       )}
       data-testid="workspace-rail"

From 533a8598fe46fe07d120c00d74c505f9f4b0e4fb Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Wed, 1 Jul 2026 10:19:38 -0600
Subject: [PATCH 06/11] chore(desktop): trim workspaceInitials comment to one
 line

Co-authored-by: Brain <21994759fc7a6fa6b965551d35cfd7897d262f2495467f2d78694ddcfa6a5c7e@sprout-oss.stage.blox.sqprod.co>
Signed-off-by: Wes <wesbillman@users.noreply.github.com>
---
 desktop/src/features/sidebar/ui/WorkspaceRail.tsx | 5 +----
 1 file changed, 1 insertion(+), 4 deletions(-)

diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
index ee2cc120aa..b3337f2e87 100644
--- a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
+++ b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
@@ -20,10 +20,7 @@ type WorkspaceRailProps = {
 
 const MAX_BADGE = 99;
 
-/**
- * Workspace-button initials. Strips punctuation/symbols first so a name like
- * "B (relay)" yields "BR", not "B(".
- */
+// Strip punctuation before initials so "B (relay)" yields "BR", not "B(".
 export function workspaceInitials(name: string): string {
   const cleaned = name.replace(/[^\p{L}\p{N}\s]/gu, " ");
   return getInitials(cleaned);

From 6392464662dcf8b52d9674216975bbdc3d27dab4 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Wed, 1 Jul 2026 10:24:27 -0600
Subject: [PATCH 07/11] fix(desktop): top-align workspace buttons and accent
 the rail indicator

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>
---
 desktop/src/features/sidebar/ui/WorkspaceRail.tsx | 4 ++--
 1 file changed, 2 insertions(+), 2 deletions(-)

diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
index b3337f2e87..e63a3b0706 100644
--- a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
+++ b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
@@ -76,7 +76,7 @@ function WorkspaceButton({
         <button
           aria-current={isActive ? "true" : undefined}
           aria-label={tooltipLabel}
-          className="relative flex h-10 w-10 items-center justify-center outline-hidden focus:outline-none focus-visible:outline-none"
+          className="relative flex h-9 w-9 items-center justify-center outline-hidden focus:outline-none focus-visible:outline-none"
           data-testid={`workspace-rail-button-${workspace.id}`}
           onClick={onSwitch}
           type="button"
@@ -85,7 +85,7 @@ function WorkspaceButton({
           <span
             aria-hidden="true"
             className={cn(
-              "absolute left-[-4px] w-1 rounded-r-full bg-sidebar-foreground transition-all",
+              "absolute left-[-4px] w-1 rounded-r-full bg-primary transition-all",
               isActive
                 ? "h-6 opacity-100"
                 : showDot

From 737a9a40e06f5784ee5eb9ed200ee2a78aa29338 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Wed, 1 Jul 2026 10:27:59 -0600
Subject: [PATCH 08/11] fix(desktop): restore gap between rail indicator and
 workspace button

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>
---
 desktop/src/features/sidebar/ui/WorkspaceRail.tsx | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
index e63a3b0706..753aabe5c1 100644
--- a/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
+++ b/desktop/src/features/sidebar/ui/WorkspaceRail.tsx
@@ -85,7 +85,7 @@ function WorkspaceButton({
           <span
             aria-hidden="true"
             className={cn(
-              "absolute left-[-4px] w-1 rounded-r-full bg-primary transition-all",
+              "absolute left-[-6px] w-1 rounded-r-full bg-primary transition-all",
               isActive
                 ? "h-6 opacity-100"
                 : showDot

From 070f9552a3f93fb3a299c26d527b017250412aa6 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Wed, 1 Jul 2026 10:42:39 -0600
Subject: [PATCH 09/11] feat(desktop): gate workspace rail behind an experiment
 flag

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>
---
 desktop/src/app/AppShell.tsx | 25 ++++++++++++++++---------
 preview-features.json        |  8 ++++++++
 2 files changed, 24 insertions(+), 9 deletions(-)

diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx
index 7639c884fc..f3cef21a27 100644
--- a/desktop/src/app/AppShell.tsx
+++ b/desktop/src/app/AppShell.tsx
@@ -66,6 +66,7 @@ import { useChannelStars } from "@/features/sidebar/lib/useChannelStars";
 import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
 import { useApplyTemplate } from "@/features/channel-templates/useApplyTemplate";
 import { relayClient } from "@/shared/api/relayClient";
+import { useFeatureEnabled } from "@/shared/features";
 import { useIdentityQuery } from "@/shared/api/hooks";
 import { useRelayAutoHeal } from "@/shared/api/useRelayAutoHeal";
 import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup";
@@ -92,6 +93,7 @@ export function AppShell() {
   useWebviewScrollBoundaryLock();
 
   const workspacesHook = useWorkspaces();
+  const workspaceRailEnabled = useFeatureEnabled("workspaceRail");
   const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false);
   const [isChannelManagementOpen, setIsChannelManagementOpen] =
     React.useState(false);
@@ -611,20 +613,25 @@ export function AppShell() {
                     isHuddleDrawerOpen && "buzz-huddle-app-surface-open",
                   )}
                 >
-                  <WorkspaceRail
-                    activeWorkspaceId={
-                      workspacesHook.activeWorkspace?.id ?? null
-                    }
-                    onAddWorkspace={() => setIsAddWorkspaceOpen(true)}
-                    onSwitchWorkspace={workspacesHook.switchWorkspace}
-                    workspaces={workspacesHook.workspaces}
-                  />
+                  {workspaceRailEnabled ? (
+                    <WorkspaceRail
+                      activeWorkspaceId={
+                        workspacesHook.activeWorkspace?.id ?? null
+                      }
+                      onAddWorkspace={() => setIsAddWorkspaceOpen(true)}
+                      onSwitchWorkspace={workspacesHook.switchWorkspace}
+                      workspaces={workspacesHook.workspaces}
+                    />
+                  ) : null}
                   <SidebarProvider className="min-h-0 flex-1 flex-col overflow-hidden">
                     {!settingsOpen ? (
                       <AppTopChrome
                         canGoBack={canGoBack}
                         canGoForward={canGoForward}
-                        hasWorkspaceRail={workspacesHook.workspaces.length > 1}
+                        hasWorkspaceRail={
+                          workspaceRailEnabled &&
+                          workspacesHook.workspaces.length > 1
+                        }
                         onGoBack={goBack}
                         onGoForward={goForward}
                       />
diff --git a/preview-features.json b/preview-features.json
index 38ea181bb3..bc5f6d16c0 100644
--- a/preview-features.json
+++ b/preview-features.json
@@ -25,6 +25,14 @@
         "desktop"
       ]
     },
+    {
+      "id": "workspaceRail",
+      "name": "Workspace Rail",
+      "description": "Far-left rail for switching relays with cross-relay unread indicators",
+      "platforms": [
+        "desktop"
+      ]
+    },
     {
       "id": "forum",
       "name": "Forum Channels",

From 6194c334dd77dafb107262cdfd36225b1193b37a Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Wed, 1 Jul 2026 11:20:09 -0600
Subject: [PATCH 10/11] refactor(desktop): format rail/observer files and drop
 stale unread dep

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>
---
 .../channels/readState/readStateManager.ts    |  5 +-
 .../channels/readState/readStateSnapshot.ts   |  4 +-
 .../features/workspaces/useWorkspaceUnread.ts | 11 +--
 .../workspaceUnreadObserver.test.mjs          | 95 ++++++++++++++++---
 .../workspaces/workspaceUnreadObserver.ts     | 44 +++++----
 desktop/src/shared/api/readOnlyRelayClient.ts | 20 +++-
 6 files changed, 125 insertions(+), 54 deletions(-)

diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts
index 6d0d41c326..af8d8e7462 100644
--- a/desktop/src/features/channels/readState/readStateManager.ts
+++ b/desktop/src/features/channels/readState/readStateManager.ts
@@ -1,7 +1,4 @@
-import {
-  nip44EncryptToSelf,
-  signRelayEvent,
-} from "@/shared/api/tauri";
+import { nip44EncryptToSelf, signRelayEvent } from "@/shared/api/tauri";
 import type { RelayClient } from "@/shared/api/relayClientSession";
 import type { RelayEvent } from "@/shared/api/types";
 import { KIND_READ_STATE } from "@/shared/constants/kinds";
diff --git a/desktop/src/features/channels/readState/readStateSnapshot.ts b/desktop/src/features/channels/readState/readStateSnapshot.ts
index 38a6c87999..c20e1d9f20 100644
--- a/desktop/src/features/channels/readState/readStateSnapshot.ts
+++ b/desktop/src/features/channels/readState/readStateSnapshot.ts
@@ -65,9 +65,7 @@ export async function mergeReadStateEvents(
     const parsed = await parseReadStateEvent(event, pubkey, decrypt);
     if (!parsed) continue;
 
-    for (const [contextId, timestamp] of Object.entries(
-      parsed.blob.contexts,
-    )) {
+    for (const [contextId, timestamp] of Object.entries(parsed.blob.contexts)) {
       const current = contexts.get(contextId) ?? 0;
       if (timestamp > current) {
         contexts.set(contextId, timestamp);
diff --git a/desktop/src/features/workspaces/useWorkspaceUnread.ts b/desktop/src/features/workspaces/useWorkspaceUnread.ts
index 7dafa5a455..03461eb08d 100644
--- a/desktop/src/features/workspaces/useWorkspaceUnread.ts
+++ b/desktop/src/features/workspaces/useWorkspaceUnread.ts
@@ -54,15 +54,6 @@ export function useWorkspaceUnread(
     Record<string, WorkspaceUnreadState>
   >(() => seedWorkspaceStates(workspaces, {}));
 
-  const workspacesKey = React.useMemo(
-    () =>
-      workspaces
-        .map((workspace) => `${workspace.id}:${workspace.relayUrl}`)
-        .sort()
-        .join("|"),
-    [workspaces],
-  );
-
   React.useEffect(() => {
     let cancelled = false;
     let pollTimer: number | null = null;
@@ -160,7 +151,7 @@ export function useWorkspaceUnread(
         window.clearTimeout(pollTimer);
       }
     };
-  }, [activeWorkspaceId, workspaces, workspacesKey]);
+  }, [activeWorkspaceId, workspaces]);
 
   return unreadByWorkspace;
 }
diff --git a/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs b/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs
index 709d4aa396..4788bedbf2 100644
--- a/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs
+++ b/desktop/src/features/workspaces/workspaceUnreadObserver.test.mjs
@@ -38,7 +38,12 @@ function relayFor(filters) {
 test("extractMemberChannelIds deduplicates d tags", () => {
   assert.deepEqual(
     extractMemberChannelIds([
-      event({ tags: [["d", "one"], ["d", "two"]] }),
+      event({
+        tags: [
+          ["d", "one"],
+          ["d", "two"],
+        ],
+      }),
       event({ tags: [["d", "one"]] }),
     ]),
     ["one", "two"],
@@ -50,9 +55,26 @@ test("resolveObservedChannels uses latest metadata and archived flag", () => {
     resolveObservedChannels(
       ["stream", "dm", "missing"],
       [
-        event({ created_at: 1, tags: [["d", "dm"], ["t", "stream"]] }),
-        event({ created_at: 2, tags: [["d", "dm"], ["t", "dm"]] }),
-        event({ tags: [["d", "stream"], ["archived", "true"]] }),
+        event({
+          created_at: 1,
+          tags: [
+            ["d", "dm"],
+            ["t", "stream"],
+          ],
+        }),
+        event({
+          created_at: 2,
+          tags: [
+            ["d", "dm"],
+            ["t", "dm"],
+          ],
+        }),
+        event({
+          tags: [
+            ["d", "stream"],
+            ["archived", "true"],
+          ],
+        }),
       ],
     ),
     [
@@ -67,7 +89,13 @@ test("extractHiddenDmIds reads h tags from latest visibility snapshot", () => {
   assert.deepEqual(
     extractHiddenDmIds([
       event({ created_at: 1, tags: [["h", "old"]] }),
-      event({ created_at: 2, tags: [["h", "new"], ["h", "other"]] }),
+      event({
+        created_at: 2,
+        tags: [
+          ["h", "new"],
+          ["h", "other"],
+        ],
+      }),
     ]),
     new Set(["new", "other"]),
   );
@@ -75,18 +103,39 @@ test("extractHiddenDmIds reads h tags from latest visibility snapshot", () => {
 
 test("fetchWorkspaceUnread returns dot and mention count without total unread count", async () => {
   const relay = relayFor([
-    () => [event({ tags: [["d", CHANNEL_ID], ["p", PUBKEY]] })],
-    () => [event({ tags: [["d", CHANNEL_ID], ["t", "stream"]] })],
+    () => [
+      event({
+        tags: [
+          ["d", CHANNEL_ID],
+          ["p", PUBKEY],
+        ],
+      }),
+    ],
+    () => [
+      event({
+        tags: [
+          ["d", CHANNEL_ID],
+          ["t", "stream"],
+        ],
+      }),
+    ],
     () => [],
     () => [],
     () => [
-      event({ id: "unread".padEnd(64, "0"), created_at: 20, tags: [["h", CHANNEL_ID]] }),
+      event({
+        id: "unread".padEnd(64, "0"),
+        created_at: 20,
+        tags: [["h", CHANNEL_ID]],
+      }),
     ],
     () => [
       event({
         id: "mention".padEnd(64, "0"),
         created_at: 30,
-        tags: [["h", CHANNEL_ID], ["p", PUBKEY]],
+        tags: [
+          ["h", CHANNEL_ID],
+          ["p", PUBKEY],
+        ],
       }),
     ],
   ]);
@@ -117,18 +166,38 @@ test("fetchWorkspaceUnread ignores self-authored and read thread/message events"
     id: "self".padEnd(64, "0"),
     pubkey: PUBKEY,
     created_at: 70,
-    tags: [["h", CHANNEL_ID], ["p", PUBKEY]],
+    tags: [
+      ["h", CHANNEL_ID],
+      ["p", PUBKEY],
+    ],
   });
 
   const relay = relayFor([
-    () => [event({ tags: [["d", CHANNEL_ID], ["p", PUBKEY]] })],
-    () => [event({ tags: [["d", CHANNEL_ID], ["t", "stream"]] })],
+    () => [
+      event({
+        tags: [
+          ["d", CHANNEL_ID],
+          ["p", PUBKEY],
+        ],
+      }),
+    ],
+    () => [
+      event({
+        tags: [
+          ["d", CHANNEL_ID],
+          ["t", "stream"],
+        ],
+      }),
+    ],
     () => [],
     () => [
       event({
         pubkey: PUBKEY,
         created_at: 80,
-        tags: [["d", "read-state:test"], ["t", "read-state"]],
+        tags: [
+          ["d", "read-state:test"],
+          ["t", "read-state"],
+        ],
         content: JSON.stringify({
           v: 1,
           client_id: "client",
diff --git a/desktop/src/features/workspaces/workspaceUnreadObserver.ts b/desktop/src/features/workspaces/workspaceUnreadObserver.ts
index e41ddee47e..223d14fe2b 100644
--- a/desktop/src/features/workspaces/workspaceUnreadObserver.ts
+++ b/desktop/src/features/workspaces/workspaceUnreadObserver.ts
@@ -73,25 +73,27 @@ export async function fetchWorkspaceUnread(args: {
     return { hasUnread: false, mentionCount: 0 };
   }
 
-  const [metadataEvents, visibilityEvents, readStateEvents] = await Promise.all([
-    client.fetchEvents({
-      kinds: [KIND_NIP29_GROUP_METADATA],
-      "#d": channelIds,
-      limit: METADATA_LIMIT,
-    }),
-    client.fetchEvents({
-      kinds: [KIND_DM_VISIBILITY],
-      "#p": [pubkey],
-      limit: 1,
-    }),
-    client.fetchEvents({
-      kinds: [KIND_READ_STATE],
-      authors: [pubkey],
-      "#t": ["read-state"],
-      since: nowSeconds - READ_STATE_HORIZON_SECONDS,
-      limit: READ_STATE_FETCH_LIMIT,
-    }),
-  ]);
+  const [metadataEvents, visibilityEvents, readStateEvents] = await Promise.all(
+    [
+      client.fetchEvents({
+        kinds: [KIND_NIP29_GROUP_METADATA],
+        "#d": channelIds,
+        limit: METADATA_LIMIT,
+      }),
+      client.fetchEvents({
+        kinds: [KIND_DM_VISIBILITY],
+        "#p": [pubkey],
+        limit: 1,
+      }),
+      client.fetchEvents({
+        kinds: [KIND_READ_STATE],
+        authors: [pubkey],
+        "#t": ["read-state"],
+        since: nowSeconds - READ_STATE_HORIZON_SECONDS,
+        limit: READ_STATE_FETCH_LIMIT,
+      }),
+    ],
+  );
 
   const hiddenDmIds = extractHiddenDmIds(visibilityEvents);
   const channels = resolveObservedChannels(channelIds, metadataEvents).filter(
@@ -191,7 +193,9 @@ export function resolveObservedChannels(
 export function extractHiddenDmIds(events: RelayEvent[]): Set<string> {
   const latest = events.reduce<RelayEvent | null>(
     (current, event) =>
-      current === null || event.created_at > current.created_at ? event : current,
+      current === null || event.created_at > current.created_at
+        ? event
+        : current,
     null,
   );
   return new Set(
diff --git a/desktop/src/shared/api/readOnlyRelayClient.ts b/desktop/src/shared/api/readOnlyRelayClient.ts
index febfe5fe12..9d6d0fd176 100644
--- a/desktop/src/shared/api/readOnlyRelayClient.ts
+++ b/desktop/src/shared/api/readOnlyRelayClient.ts
@@ -117,9 +117,13 @@ export class ReadOnlyRelayClient {
     });
   }
 
-  private requestHistory(filter: RelaySubscriptionFilter): Promise<RelayEvent[]> {
+  private requestHistory(
+    filter: RelaySubscriptionFilter,
+  ): Promise<RelayEvent[]> {
     if (this.wsId === null) {
-      return Promise.reject(new Error("Read-only relay socket is not connected."));
+      return Promise.reject(
+        new Error("Read-only relay socket is not connected."),
+      );
     }
 
     return new Promise<RelayEvent[]>((resolve, reject) => {
@@ -199,7 +203,11 @@ export class ReadOnlyRelayClient {
       typeof rest[0] === "string" &&
       typeof rest[1] === "boolean"
     ) {
-      this.handleOk(rest[0], rest[1], typeof rest[2] === "string" ? rest[2] : "");
+      this.handleOk(
+        rest[0],
+        rest[1],
+        typeof rest[2] === "string" ? rest[2] : "",
+      );
       return;
     }
     if (type === "EOSE" && typeof rest[0] === "string") {
@@ -235,7 +243,11 @@ export class ReadOnlyRelayClient {
       return;
     }
 
-    authRequest.reject(message ? new Error(message) : new Error("Observer relay authentication rejected."));
+    authRequest.reject(
+      message
+        ? new Error(message)
+        : new Error("Observer relay authentication rejected."),
+    );
     this.disconnect();
   }
 

From 321c555cdf3d3b6f2b48688d2793781230210da0 Mon Sep 17 00:00:00 2001
From: Wes <wesbillman@users.noreply.github.com>
Date: Wed, 1 Jul 2026 11:20:20 -0600
Subject: [PATCH 11/11] feat(desktop): cache channel list per relay for instant
 sidebar paint

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>
---
 .../channels/channelSnapshot.test.mjs         | 100 ++++++++++++++++++
 .../src/features/channels/channelSnapshot.ts  |  71 +++++++++++++
 desktop/src/features/channels/hooks.ts        |  26 ++++-
 .../src/features/workspaces/useWorkspaces.tsx |   2 +
 4 files changed, 198 insertions(+), 1 deletion(-)
 create mode 100644 desktop/src/features/channels/channelSnapshot.test.mjs
 create mode 100644 desktop/src/features/channels/channelSnapshot.ts

diff --git a/desktop/src/features/channels/channelSnapshot.test.mjs b/desktop/src/features/channels/channelSnapshot.test.mjs
new file mode 100644
index 0000000000..37b69f1eb2
--- /dev/null
+++ b/desktop/src/features/channels/channelSnapshot.test.mjs
@@ -0,0 +1,100 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+  channelSnapshotKey,
+  readChannelSnapshot,
+  removeChannelSnapshotForRelay,
+  writeChannelSnapshot,
+} from "./channelSnapshot.ts";
+
+if (typeof globalThis.window === "undefined") {
+  const storage = new Map();
+  globalThis.window = {
+    localStorage: {
+      getItem: (key) => storage.get(key) ?? null,
+      setItem: (key, value) => storage.set(key, value),
+      removeItem: (key) => storage.delete(key),
+    },
+  };
+}
+
+function makeChannel(overrides = {}) {
+  return {
+    id: "chan-1",
+    name: "General",
+    channelType: "stream",
+    visibility: "public",
+    description: "",
+    topic: null,
+    purpose: null,
+    memberCount: 3,
+    memberPubkeys: [],
+    lastMessageAt: null,
+    archivedAt: null,
+    participants: [],
+    participantPubkeys: [],
+    isMember: true,
+    ttlSeconds: null,
+    ttlDeadline: null,
+    ...overrides,
+  };
+}
+
+const RELAY = "wss://relay.example.com";
+
+test("channelSnapshotKey: normalizes trailing slash and case", () => {
+  assert.equal(
+    channelSnapshotKey("WSS://Relay.Example.com/"),
+    channelSnapshotKey("wss://relay.example.com"),
+  );
+});
+
+test("read after write returns the persisted channels", () => {
+  const channels = [makeChannel(), makeChannel({ id: "chan-2", name: "Dev" })];
+  writeChannelSnapshot(RELAY, channels);
+  assert.deepEqual(readChannelSnapshot(RELAY), channels);
+});
+
+test("read for an unknown relay returns null", () => {
+  assert.equal(readChannelSnapshot("wss://never-written.example.com"), null);
+});
+
+test("read returns null for malformed JSON", () => {
+  window.localStorage.setItem(channelSnapshotKey(RELAY), "not-json{{{");
+  assert.equal(readChannelSnapshot(RELAY), null);
+});
+
+test("read returns null for a wrong-version payload", () => {
+  window.localStorage.setItem(
+    channelSnapshotKey(RELAY),
+    JSON.stringify({ version: 2, channels: [makeChannel()] }),
+  );
+  assert.equal(readChannelSnapshot(RELAY), null);
+});
+
+test("read returns null when channels is not an array", () => {
+  window.localStorage.setItem(
+    channelSnapshotKey(RELAY),
+    JSON.stringify({ version: 1, channels: "nope" }),
+  );
+  assert.equal(readChannelSnapshot(RELAY), null);
+});
+
+test("remove clears the snapshot for that relay", () => {
+  writeChannelSnapshot(RELAY, [makeChannel()]);
+  removeChannelSnapshotForRelay(RELAY);
+  assert.equal(readChannelSnapshot(RELAY), null);
+});
+
+test("write is tolerant of storage failures", () => {
+  const original = window.localStorage.setItem;
+  window.localStorage.setItem = () => {
+    throw new Error("quota exceeded");
+  };
+  try {
+    assert.doesNotThrow(() => writeChannelSnapshot(RELAY, [makeChannel()]));
+  } finally {
+    window.localStorage.setItem = original;
+  }
+});
diff --git a/desktop/src/features/channels/channelSnapshot.ts b/desktop/src/features/channels/channelSnapshot.ts
new file mode 100644
index 0000000000..3f062b579b
--- /dev/null
+++ b/desktop/src/features/channels/channelSnapshot.ts
@@ -0,0 +1,71 @@
+/**
+ * Per-relay cache of the last successfully fetched channel list.
+ *
+ * Each workspace mounts a fresh React-Query client, so switching workspaces
+ * (or switching back to one just visited) starts cold and blocks the sidebar
+ * on a multi-round-trip `get_channels()`. This module persists the last-known
+ * channel list per relay so the sidebar can paint instantly from the snapshot
+ * while the live fetch revalidates in the background.
+ *
+ * Keyed per relay URL (not workspace id) so equivalent URL formatting maps to
+ * one slot and one relay's list never bleeds into another.
+ */
+
+import type { Channel } from "@/shared/api/types";
+import { normalizeRelayUrl } from "@/features/profile/lib/selfProfileStorage";
+
+const STORAGE_KEY_PREFIX = "buzz-channels.v1";
+
+export function channelSnapshotKey(relayUrl: string): string {
+  return `${STORAGE_KEY_PREFIX}:${normalizeRelayUrl(relayUrl)}`;
+}
+
+function parseChannelSnapshot(json: unknown): Channel[] | null {
+  if (typeof json !== "object" || json === null) return null;
+  const obj = json as Record<string, unknown>;
+  if (obj.version !== 1 || !Array.isArray(obj.channels)) return null;
+  return obj.channels as Channel[];
+}
+
+/**
+ * Reads the cached channel list for a relay, or null when absent or malformed.
+ */
+export function readChannelSnapshot(relayUrl: string): Channel[] | null {
+  try {
+    const raw = window.localStorage.getItem(channelSnapshotKey(relayUrl));
+    if (!raw) return null;
+    return parseChannelSnapshot(JSON.parse(raw));
+  } catch {
+    return null;
+  }
+}
+
+/**
+ * Persists the channel list for a relay. Skips the write when unchanged so the
+ * 60s background refetch does not re-serialize an identical list. Non-fatal on
+ * storage failure (e.g. quota exceeded).
+ */
+export function writeChannelSnapshot(
+  relayUrl: string,
+  channels: Channel[],
+): void {
+  try {
+    const key = channelSnapshotKey(relayUrl);
+    const serialized = JSON.stringify({ version: 1, channels });
+    if (window.localStorage.getItem(key) === serialized) return;
+    window.localStorage.setItem(key, serialized);
+  } catch {
+    // Storage access failures are non-fatal.
+  }
+}
+
+/**
+ * Removes the channel snapshot for a relay. Called when a workspace is removed.
+ */
+export function removeChannelSnapshotForRelay(relayUrl: string): void {
+  try {
+    window.localStorage.removeItem(channelSnapshotKey(relayUrl));
+  } catch {
+    // Storage access failures are non-fatal.
+  }
+}
diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts
index 9885b6fb20..c5cb3572bf 100644
--- a/desktop/src/features/channels/hooks.ts
+++ b/desktop/src/features/channels/hooks.ts
@@ -31,6 +31,11 @@ import type {
   SetChannelTopicInput,
   UpdateChannelInput,
 } from "@/shared/api/types";
+import { useWorkspaces } from "@/features/workspaces/useWorkspaces";
+import {
+  readChannelSnapshot,
+  writeChannelSnapshot,
+} from "@/features/channels/channelSnapshot";
 
 export const channelsQueryKey = ["channels"] as const;
 const channelDetailQueryKey = (channelId: string) =>
@@ -102,10 +107,29 @@ function setChannelArchivedState(
 }
 
 export function useChannelsQuery(options?: { enabled?: boolean }) {
+  const { activeWorkspace } = useWorkspaces();
+  const relayUrl = activeWorkspace?.relayUrl ?? null;
+
   return useQuery({
     enabled: options?.enabled ?? true,
     queryKey: channelsQueryKey,
-    queryFn: async () => sortChannels(await getChannels()),
+    queryFn: async () => {
+      const channels = sortChannels(await getChannels());
+      if (relayUrl) {
+        writeChannelSnapshot(relayUrl, channels);
+      }
+      return channels;
+    },
+    // Paint the sidebar instantly from the last-known list for this relay, then
+    // revalidate. initialDataUpdatedAt:0 marks the seed as already-stale so the
+    // background refetch still fires immediately.
+    initialData: relayUrl
+      ? () => {
+          const snapshot = readChannelSnapshot(relayUrl);
+          return snapshot ? sortChannels(snapshot) : undefined;
+        }
+      : undefined,
+    initialDataUpdatedAt: 0,
     staleTime: 60_000,
     refetchInterval: 60_000,
     refetchIntervalInBackground: false,
diff --git a/desktop/src/features/workspaces/useWorkspaces.tsx b/desktop/src/features/workspaces/useWorkspaces.tsx
index a0a8c3bad1..d44b0bd70d 100644
--- a/desktop/src/features/workspaces/useWorkspaces.tsx
+++ b/desktop/src/features/workspaces/useWorkspaces.tsx
@@ -17,6 +17,7 @@ import {
   saveWorkspaces,
 } from "./workspaceStorage";
 import { removeSelfProfileCachesForRelay } from "@/features/profile/lib/selfProfileStorage";
+import { removeChannelSnapshotForRelay } from "@/features/channels/channelSnapshot";
 
 export type UseWorkspacesReturn = {
   workspaces: Workspace[];
@@ -116,6 +117,7 @@ function useWorkspacesInternal(): UseWorkspacesReturn {
         const removed = workspaces.find((w) => w.id === id);
         if (removed) {
           removeSelfProfileCachesForRelay(removed.relayUrl);
+          removeChannelSnapshotForRelay(removed.relayUrl);
         }
       }
 
