Skip to content

feat(macos): Dock unread badge + visibility state machine (LUM-1966)#32461

Merged
ashleeradka merged 5 commits into
mainfrom
claude/lum-1966-dock
May 28, 2026
Merged

feat(macos): Dock unread badge + visibility state machine (LUM-1966)#32461
ashleeradka merged 5 commits into
mainfrom
claude/lum-1966-dock

Conversation

@ashleeradka
Copy link
Copy Markdown
Contributor

Summary

Mirrors what the Swift app's AppDelegate+WindowsAndSurfaces.swift does today: unread conversation count drives the macOS Dock badge, and the activation policy toggles between regular (Dock icon visible) and accessory (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 from whenReady. Registers vellum:dock:setBadge, vellum:dock:setSignedIn, subscribes to browser-window-created (each new window adds a closed listener so the visible-window count auto-tracks), and clears the badge on before-quit.
  • Badge formatting matches Swift's unseenVisibleConversationCount: "" for 0, pass-through for 199, "99+" beyond. Renderer input is coerced to a non-negative integer so a renderer bug can't crash main.
  • Policy computation: any visible window OR signedInregular; otherwise → accessory. Debounced 100ms so a fast close-then-open doesn't visibly flash the Dock icon.
  • app.dock.show() is await-ed before flipping setActivationPolicy("regular") — Electron's docs note dock.show returns a Promise and the policy transition needs the dock present.

Why accessory is gated off for now

const ALLOW_ACCESSORY_MODE = false;

The Swift app can go accessory because it has an NSStatusItem (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.tsdock.setBadge(count) / dock.setSignedIn(signedIn) added to the typed VellumBridge.
  • apps/web/src/runtime/is-electron.ts — ambient declaration of window.vellum.dock mirrored on the renderer side.
  • apps/web/src/runtime/dock.ts — per-capability wrapper following the native-biometric.ts pattern. Both functions no-op off Electron, so callers don't sprinkle isElectron() checks everywhere.
  • apps/web/src/hooks/use-electron-dock-sync.ts — derives the unread count from conversations.filter(c => c.hasUnseenLatestAssistantMessage).length (same predicate Swift's unseenVisibleConversationCount uses) and reads isLoggedIn from useAuthStore. Publishes both on change.
  • ChatLayout mounts the sync hook once. The conversation list it already subscribes to via useConversationListQuery is the input, so this adds no new query.

Signed-in input is documented as temporary

Per the ticket, the setSignedIn channel is the renderer-side stand-in for LUM-1924 (BFF auth in main). When that lands, the main process becomes the source of truth and setSignedIn becomes a no-op the renderer can drop.

Test plan

  • bun run typecheck clean (apps/macos + apps/web's touched files).
  • bun run build clean. Preload bundle now 1.13 kB (was 0.94 kB — +0.19 kB for the two new methods).

Pending on a real Mac:

  • Mark three conversations unread → "3" on the Dock icon.
  • Clear those marks → badge clears.
  • Close the main window with the user signed in → Dock icon stays visible (so user can re-open).
  • bun run dev cold → no Dock flash at launch.
  • Cmd+Q → badge cleared before the process exits (no stale count on next launch).

Out of scope (deliberate)

  • Actually hiding the Dock icon (ALLOW_ACCESSORY_MODE). Lands with LUM-1965 (tray icon) so the user always has an entry point.
  • Replacing the renderer-published signedIn flag with the BFF auth state. Lands with LUM-1924.

https://claude.ai/code/session_011sZ4p8AkqDQMSavHvWfioe


Generated by Claude Code

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.
@linear
Copy link
Copy Markdown

linear Bot commented May 28, 2026

LUM-1966

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.
@vex-assistant-bot
Copy link
Copy Markdown
Contributor

@devin-ai review this PR
@codex review

Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-window closed handlers) and signed-in flag (published by renderer over IPC).
  • Policy logic: any visible window OR signed in → regular; otherwise → accessory (gated: ALLOW_ACCESSORY_MODE = false until 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 — 0 clears, pass-through to 99, "99+" beyond.
  • app.dock.show() awaited before setActivationPolicy flip — Electron docs require the dock to be present first.
  • Lifecycle wiring: before-quit clears 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 typed VellumBridge.
  • runtime/dock.ts (39 lines): per-capability wrapper matching the native-biometric.ts pattern. Both functions are no-ops off Electron, so callers can fire them unconditionally without isElectron() sprinkled everywhere.
  • runtime/is-electron.ts: ambient type declarations for the bridge.

Feature integration

  • ChatLayout: mounts useElectronDockSync(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.md documenting the bridge and the ALLOW_ACCESSORY_MODE gate.

Anti-patterns check

IPC input coercionipcMain.handle receives renderer's count, coerces to finite non-negative integer. A renderer bug can't crash main.

Dock lifecycle timingapp.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 patternruntime/dock.ts mirrors native-biometric.ts: wrappers no-op off Electron, feature code doesn't branch. Clean separation.

Hook granularityuseElectronDockSync mounts once at a layout boundary (ChatLayout), reuses existing conversation query. No duplicate subscriptions.

Temporary bridge gatesetSignedIn is explicitly documented as stand-in until LUM-1924. The PR explains the future flip point and doesn't pretend it's permanent.

Feature gateALLOW_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.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no potential bugs to report.

View in Devin Review to see 4 additional findings.

Open in Devin Review

vex-assistant-bot[bot]
vex-assistant-bot Bot previously approved these changes May 28, 2026
Copy link
Copy Markdown
Contributor

@vex-assistant-bot vex-assistant-bot Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-window closed handlers) and signed-in flag (published by renderer over IPC).
  • Policy logic: any visible window OR signed in → regular; otherwise → accessory (gated: ALLOW_ACCESSORY_MODE = false until 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 — 0 clears, pass-through to 99, "99+" beyond.
  • app.dock.show() awaited before setActivationPolicy flip — Electron docs require the dock to be present first.
  • Lifecycle wiring: before-quit clears 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 typed VellumBridge.
  • runtime/dock.ts (39 lines): per-capability wrapper matching the native-biometric.ts pattern. Both functions are no-ops off Electron, so callers can fire them unconditionally without isElectron() sprinkled everywhere.
  • runtime/is-electron.ts: ambient type declarations for the bridge.

Feature integration

  • ChatLayout: mounts useElectronDockSync(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/hooks per CONVENTIONS.md (second commit) — correct organizational boundary since the hook is chat-specific.

Docs

  • README.md documenting the bridge and the ALLOW_ACCESSORY_MODE gate.

Anti-patterns check

IPC input coercionipcMain.handle receives renderer's count, coerces to finite non-negative integer. A renderer bug can't crash main.

Dock lifecycle timingapp.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 patternruntime/dock.ts mirrors native-biometric.ts: wrappers no-op off Electron, feature code doesn't branch. Clean separation.

Hook granularityuseElectronDockSync 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 gatesetSignedIn is explicitly documented as stand-in until LUM-1924. The PR explains the future flip point and doesn't pretend it's permanent.

Feature gateALLOW_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.

Copy link
Copy Markdown

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 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".

Comment thread apps/web/src/domains/chat/hooks/use-electron-dock-sync.ts Outdated
Comment thread apps/web/src/domains/chat/hooks/use-electron-dock-sync.ts
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).
claude added 2 commits May 28, 2026 20:14
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.
@ashleeradka ashleeradka merged commit 93bb1f7 into main May 28, 2026
10 checks passed
@ashleeradka ashleeradka deleted the claude/lum-1966-dock branch May 28, 2026 20:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants