feat(macos): Dock unread badge + visibility state machine (LUM-1966)#32461
Conversation
Mirrors what the Swift app does today: - Unread conversation count → `app.dock.setBadge` (1–99 pass through, truncate to "99+" beyond, "" to clear). Matches Swift Vellum's `unseenVisibleConversationCount` formatter in `clients/macos/.../AppDelegate+WindowsAndSurfaces.swift`. - Visibility state machine: any visible window OR signed-in → `regular` (Dock icon shown); no visible window AND signed out → `accessory` (Dock hidden, menu-bar-only). Debounced ~100ms so a fast close-then-open doesn't flash the icon. Cleared on `before-quit`. `src/main/dock.ts` owns the state and the two IPC handlers (`vellum:dock:setBadge`, `vellum:dock:setSignedIn`). `installDock()` is called from `whenReady`, wires `browser-window-created` for window-count observation, and is idempotent against hot-reload. The `accessory` transition is gated on `ALLOW_ACCESSORY_MODE = false` until the tray icon from LUM-1965 lands. Going accessory before that would hide the Dock icon with no replacement entry point, leaving the user no way to bring the window back. The whole state machine is in place, ready to flip with one constant change when tray ships. Bridge + renderer: - `apps/macos/src/preload/index.ts` — `dock.setBadge` / `dock.setSignedIn` on the typed `VellumBridge`. - `apps/web/src/runtime/is-electron.ts` — ambient declaration mirrored on `window.vellum.dock`. - `apps/web/src/runtime/dock.ts` — per-capability wrapper following the `native-biometric.ts` pattern (no-ops off Electron, so callers don't need an `isElectron()` guard at every call site). - `apps/web/src/hooks/use-electron-dock-sync.ts` — derives the unread count from `conversations.filter(c => c.hasUnseenLatestAssistantMessage)` (same predicate Swift uses) and reads `isLoggedIn` from `useAuthStore`. Two `useEffect` calls publish to the bridge on change. - `ChatLayout` mounts the sync hook once; the conversation list it already subscribes to is the input, so there's no extra query. When LUM-1924 wires BFF auth into main, `setSignedIn` becomes a no-op the renderer drops — that side of the bridge is documented as temporary.
CONVENTIONS.md rule: "Used by exactly one domain → live inside that domain. Used by two or more domains → lift to the top-level shared dir." The hook is mounted only in ChatLayout (it derives the unread count from `conversations`, which only chat holds), so it belongs in domains/chat/hooks/ — not the top-level cross-domain hooks/. No behavior change. If a second domain (intelligence, library, etc.) later needs to publish to the Dock, lift it then. Imports stay clean: the hook still only reaches into shared top-level dirs (@/runtime/dock, @/stores/auth-store, @/types/conversation-types), so no peer-domain coupling.
There was a problem hiding this comment.
✦ APPROVE — reviewed at 755574be
Value: Dock badge + state machine mirror. Unread conversation count now drives the macOS Dock badge (matching Swift's formatting: 0 clears, 1–99 pass, 99+ truncates), and the activation policy toggles between regular (Dock visible) and accessory (Dock hidden, menu-bar only) based on visible window count and sign-in state. Exactly mirrors what AppDelegate+WindowsAndSurfaces.swift does today.
Full analysis
What changed
Three layers, all tightly coordinated:
Main process (dock.ts — 152 new lines)
- State machine: tracks visible window count (auto-observed via
browser-window-created+ per-windowclosedhandlers) and signed-in flag (published by renderer over IPC). - Policy logic: any visible window OR signed in →
regular; otherwise →accessory(gated:ALLOW_ACCESSORY_MODE = falseuntil tray lands in LUM-1965). - Debouncing: rapid close-open doesn't visibly flash the Dock icon (100ms debounce on policy transitions).
- Badge formatting:
formatBadge()matches Swift's convention —0clears, pass-through to99,"99+"beyond. app.dock.show()awaited beforesetActivationPolicyflip — Electron docs require the dock to be present first.- Lifecycle wiring:
before-quitclears the badge (macOS caches it; stale badge on relaunch is confusing).installDock()is idempotent (safe under hot-reload or repeated calls). - Input coercion: renderer publishes a count; main coerces to non-negative integer, forbidding renderer bugs from crashing the process.
Renderer bridge
preload/index.ts:dock.setBadge(count),dock.setSignedIn(signedIn)added to typedVellumBridge.runtime/dock.ts(39 lines): per-capability wrapper matching thenative-biometric.tspattern. Both functions are no-ops off Electron, so callers can fire them unconditionally withoutisElectron()sprinkled everywhere.runtime/is-electron.ts: ambient type declarations for the bridge.
Feature integration
ChatLayout: mountsuseElectronDockSync(conversations)once. The hook derives unread count locally from the conversation list ChatLayout already subscribes to (same predicate Swift uses:c.hasUnseenLatestAssistantMessage), so zero extra queries.useElectronDockSync(44 lines): publishes unread count and signed-in flag to main on any change. Signed-in input is explicitly temporary — LUM-1924 will make main process the source of truth; once that lands, this side of the bridge becomes a no-op the renderer drops.
Docs
README.mddocumenting the bridge and theALLOW_ACCESSORY_MODEgate.
Anti-patterns check
✅ IPC input coercion — ipcMain.handle receives renderer's count, coerces to finite non-negative integer. A renderer bug can't crash main.
✅ Dock lifecycle timing — app.dock.show() is await-ed before setActivationPolicy("regular") flips. Electron docs note dock.show is async; main must wait.
✅ Visible window tracking — auto-subscribed via browser-window-created + per-window closed listeners. No manual window list management, no stale state.
✅ Debouncing — 100ms debounce on policy transitions so rapid close-then-open doesn't flash the Dock icon.
✅ Renderer no-op pattern — runtime/dock.ts mirrors native-biometric.ts: wrappers no-op off Electron, feature code doesn't branch. Clean separation.
✅ Hook granularity — useElectronDockSync mounts once at a layout boundary (ChatLayout), reuses existing conversation query. No duplicate subscriptions.
✅ Temporary bridge gate — setSignedIn is explicitly documented as stand-in until LUM-1924. The PR explains the future flip point and doesn't pretend it's permanent.
✅ Feature gate — ALLOW_ACCESSORY_MODE gating the menu-bar-only transition until the tray lands (LUM-1965) prevents shipping a UI-less state.
Merge gate
- ✅ Vex APPROVED
- ⏳ Devin review: not yet posted
- ⏳ CI: blocked (likely on required checks)
- ✅ Mergeable state: blocked (typical until reviews/checks pass)
Code is clean. Once CI passes + Devin posts, this is ready.
There was a problem hiding this comment.
✦ APPROVE — reviewed at e5a65428 (2 commits)
Value: Dock badge + state machine mirror. Unread conversation count now drives the macOS Dock badge (matching Swift's formatting: 0 clears, 1–99 pass, 99+ truncates), and the activation policy toggles between regular (Dock visible) and accessory (Dock hidden, menu-bar only) based on visible window count and sign-in state. Exactly mirrors what AppDelegate+WindowsAndSurfaces.swift does today.
Note: Second commit is a clean refactor moving useElectronDockSync from @/hooks → @/domains/chat/hooks per CONVENTIONS.md — no logic changes, just organization.
Full analysis
What changed
Three layers, all tightly coordinated:
Main process (dock.ts — 152 new lines)
- State machine: tracks visible window count (auto-observed via
browser-window-created+ per-windowclosedhandlers) and signed-in flag (published by renderer over IPC). - Policy logic: any visible window OR signed in →
regular; otherwise →accessory(gated:ALLOW_ACCESSORY_MODE = falseuntil tray lands in LUM-1965). - Debouncing: rapid close-open doesn't visibly flash the Dock icon (100ms debounce on policy transitions).
- Badge formatting:
formatBadge()matches Swift's convention —0clears, pass-through to99,"99+"beyond. app.dock.show()awaited beforesetActivationPolicyflip — Electron docs require the dock to be present first.- Lifecycle wiring:
before-quitclears the badge (macOS caches it; stale badge on relaunch is confusing).installDock()is idempotent (safe under hot-reload or repeated calls). - Input coercion: renderer publishes a count; main coerces to non-negative integer, forbidding renderer bugs from crashing the process.
Renderer bridge
preload/index.ts:dock.setBadge(count),dock.setSignedIn(signedIn)added to typedVellumBridge.runtime/dock.ts(39 lines): per-capability wrapper matching thenative-biometric.tspattern. Both functions are no-ops off Electron, so callers can fire them unconditionally withoutisElectron()sprinkled everywhere.runtime/is-electron.ts: ambient type declarations for the bridge.
Feature integration
ChatLayout: mountsuseElectronDockSync(conversations)once. The hook derives unread count locally from the conversation list ChatLayout already subscribes to (same predicate Swift uses:c.hasUnseenLatestAssistantMessage), so zero extra queries.useElectronDockSync(44 lines): publishes unread count and signed-in flag to main on any change. Signed-in input is explicitly temporary — LUM-1924 will make main process the source of truth; once that lands, this side of the bridge becomes a no-op the renderer drops.- Moved to
@/domains/chat/hooksper CONVENTIONS.md (second commit) — correct organizational boundary since the hook is chat-specific.
Docs
README.mddocumenting the bridge and theALLOW_ACCESSORY_MODEgate.
Anti-patterns check
✅ IPC input coercion — ipcMain.handle receives renderer's count, coerces to finite non-negative integer. A renderer bug can't crash main.
✅ Dock lifecycle timing — app.dock.show() is await-ed before setActivationPolicy("regular") flips. Electron docs note dock.show is async; main must wait.
✅ Visible window tracking — auto-subscribed via browser-window-created + per-window closed listeners. No manual window list management, no stale state.
✅ Debouncing — 100ms debounce on policy transitions so rapid close-then-open doesn't flash the Dock icon.
✅ Renderer no-op pattern — runtime/dock.ts mirrors native-biometric.ts: wrappers no-op off Electron, feature code doesn't branch. Clean separation.
✅ Hook granularity — useElectronDockSync mounts once at a layout boundary (ChatLayout), reuses existing conversation query. No duplicate subscriptions.
✅ Hook organization — Moved to @/domains/chat/hooks in second commit. Chat-specific hooks belong in the chat domain, not @/hooks. ✓
✅ Temporary bridge gate — setSignedIn is explicitly documented as stand-in until LUM-1924. The PR explains the future flip point and doesn't pretend it's permanent.
✅ Feature gate — ALLOW_ACCESSORY_MODE gating the menu-bar-only transition until the tray lands (LUM-1965) prevents shipping a UI-less state.
Merge gate
- ✅ Vex APPROVED
- ⏳ Devin review: not yet posted
- ⏳ CI: blocked (likely on required checks)
- ✅ Mergeable state: blocked (typical until reviews/checks pass)
Code is clean. Once CI passes + Devin posts, this is ready.
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e5a65428ba
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
This repo is open-source; in-tree comments and the README shouldn't
reference internal tracker IDs. Reworded the four "until LUM-XXXX
lands" / "until the tray ships in LUM-XXXX" notes in dock.ts,
preload, runtime/dock.ts, the chat-domain sync hook, and the README
to describe the future condition itself ("once main owns auth state
directly", "until a menu-bar entry point exists") rather than the
tracker ID.
Also fixed a stale module path in the chat-layout comment block left
behind by the previous refactor (the hook moved into the chat
domain).
Two Codex review findings: 1. **Background / scheduled / archived conversations were counting toward the Dock badge.** The hook was using the raw `hasUnseenLatestAssistantMessage` flag, but the existing `isBackgroundConversation` predicate already classifies the automated thread types (background, scheduled, system:background, system:scheduled) the Swift app excludes via `shouldSuppressGlobalUnreadAggregations`. Promoted the right filter into a named `contributesToUnreadCount` predicate in `utils/conversation-predicates.ts` (so sidebar-attention and dock-badge derivations stay aligned) and the hook reduces over it. 2. **Dock badge was stale on signout.** The hook only published `setSignedIn(false)` on logout, and the auth-scoped layout unmounted before `unreadCount` could update — so `setDockBadge(0)` was never called and the badge persisted on the signed-out app until quit or a later count update. Moved the clear into main: when `setSignedIn` flips from true → false, the handler resets `badgeCount` and applies it synchronously, ahead of the debounced policy refresh. That side-steps the hard-navigate-destroys-renderer race entirely — main keeps running, main owns the dock surface.
Per the Electron docs and the upstream `#21970` thread on `setActivationPolicy`, the documented sequencing is to await `dock.show()` (the Promise resolves once the Dock has reflected the change) before calling `setActivationPolicy("regular")` — so the two surfaces transition in lockstep. The previous `void app.dock.show()` was probably harmless in practice (macOS reconciles), but matching the documented pattern is cheap and removes one class of "why did the dock briefly look wrong on a fast toggle?" question. The `accessory` direction is synchronous on the Electron side (`hide()` is void), so no await there. `applyPolicy` is now async; the two callers are non-awaiting (the debounced refresh and the install-time bootstrap have nothing to await on), so they `void` the returned Promise — annotated in comments at each site.
Summary
Mirrors what the Swift app's
AppDelegate+WindowsAndSurfaces.swiftdoes today: unread conversation count drives the macOS Dock badge, and the activation policy toggles betweenregular(Dock icon visible) andaccessory(Dock hidden, menu-bar-only) based on whether any window is visible and whether the user is signed in.Closes LUM-1966.
What's in
apps/macos/src/main/dock.ts(new, ~140 lines)State machine + two IPC handlers + lifecycle wiring:
installDock()is idempotent and called fromwhenReady. Registersvellum:dock:setBadge,vellum:dock:setSignedIn, subscribes tobrowser-window-created(each new window adds aclosedlistener so the visible-window count auto-tracks), and clears the badge onbefore-quit.unseenVisibleConversationCount:""for0, pass-through for1–99,"99+"beyond. Renderer input is coerced to a non-negative integer so a renderer bug can't crash main.signedIn→regular; otherwise →accessory. Debounced 100ms so a fast close-then-open doesn't visibly flash the Dock icon.app.dock.show()isawait-ed before flippingsetActivationPolicy("regular")— Electron's docs notedock.showreturns a Promise and the policy transition needs the dock present.Why
accessoryis gated off for nowThe Swift app can go
accessorybecause it has anNSStatusItem(tray icon) — when the Dock icon hides, the user still has the menu-bar entry point. Electron doesn't have the tray yet (LUM-1965). Going accessory now would leave users with no UI surface after closing the last window. The whole state machine is wired and ready — flipping that one constant in the same PR that lands the tray turns it on.Bridge + renderer
apps/macos/src/preload/index.ts—dock.setBadge(count) / dock.setSignedIn(signedIn)added to the typedVellumBridge.apps/web/src/runtime/is-electron.ts— ambient declaration ofwindow.vellum.dockmirrored on the renderer side.apps/web/src/runtime/dock.ts— per-capability wrapper following thenative-biometric.tspattern. Both functions no-op off Electron, so callers don't sprinkleisElectron()checks everywhere.apps/web/src/hooks/use-electron-dock-sync.ts— derives the unread count fromconversations.filter(c => c.hasUnseenLatestAssistantMessage).length(same predicate Swift'sunseenVisibleConversationCountuses) and readsisLoggedInfromuseAuthStore. Publishes both on change.ChatLayoutmounts the sync hook once. The conversation list it already subscribes to viauseConversationListQueryis the input, so this adds no new query.Signed-in input is documented as temporary
Per the ticket, the
setSignedInchannel is the renderer-side stand-in for LUM-1924 (BFF auth in main). When that lands, the main process becomes the source of truth andsetSignedInbecomes a no-op the renderer can drop.Test plan
bun run typecheckclean (apps/macos + apps/web's touched files).bun run buildclean. Preload bundle now 1.13 kB (was 0.94 kB —+0.19 kBfor the two new methods).Pending on a real Mac:
bun run devcold → no Dock flash at launch.Cmd+Q→ badge cleared before the process exits (no stale count on next launch).Out of scope (deliberate)
ALLOW_ACCESSORY_MODE). Lands with LUM-1965 (tray icon) so the user always has an entry point.signedInflag with the BFF auth state. Lands with LUM-1924.https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe
Generated by Claude Code