-
Notifications
You must be signed in to change notification settings - Fork 77
feat(macos): Dock unread badge + visibility state machine (LUM-1966) #32461
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
755574b
feat(macos): Dock unread badge + visibility state machine (LUM-1966)
claude e5a6542
refactor(web): move use-electron-dock-sync into chat domain
claude 9d928f3
docs(macos): scrub internal-tracker refs from dock comments + docs
claude 89fb9d4
fix(macos): exclude background threads + clear Dock badge on signout
claude 267a0ca
fix(macos): await app.dock.show() before flipping activation policy
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,170 @@ | ||
| import { app, BrowserWindow, ipcMain } from "electron"; | ||
|
|
||
| /** | ||
| * Dock integration: unread-count badge + visibility state machine. | ||
| * | ||
| * Mirrors what the Swift app does today (`AppDelegate+WindowsAndSurfaces.swift` | ||
| * → `NSApp.dockTile.badgeLabel`, `NSApplication.ActivationPolicy.regular` | ||
| * ⇄ `.accessory`) so users see no regression when they cut over to | ||
| * Electron. | ||
| * | ||
| * The state machine has two inputs: | ||
| * | ||
| * 1. **Visible window count**, observed via the `browser-window-created` | ||
| * / per-window `closed` events. No renderer involvement. | ||
| * 2. **Signed-in flag**, published by the renderer over the | ||
| * `vellum:dock:setSignedIn` IPC channel. Renderer is the source of | ||
| * truth today; this side of the bridge becomes a no-op once the | ||
| * main-process auth state is the canonical signal. | ||
| * | ||
| * Policy: | ||
| * | ||
| * - Any visible window OR signed in → `regular` (Dock icon visible). | ||
| * We keep the icon visible while signed in so the user can re-open | ||
| * the window from the Dock after closing the last one. | ||
| * - No visible window AND signed out → `accessory` (Dock icon hidden, | ||
| * menu-bar-only). | ||
| * | ||
| * Transitions are debounced ~100ms so a fast close-then-open (e.g. | ||
| * keyboard shortcut chord) doesn't visibly flash the Dock icon. | ||
| */ | ||
|
|
||
| // Format the badge string per macOS Dock conventions: "" clears, | ||
| // "1"–"99" pass through, anything beyond becomes "99+" (the Slack-style | ||
| // truncation Swift Vellum already uses — `\"99+\"` shows up at | ||
| // `clients/macos/.../AppDelegate+WindowsAndSurfaces.swift:660-691`). | ||
| // | ||
| // `> 999 → "999+"` is what we'd want if we ever exposed a triple-digit | ||
| // counter, but macOS truncates very long strings and Swift caps at 99 | ||
| // today; we match Swift. | ||
| const formatBadge = (count: number): string => { | ||
| if (!Number.isFinite(count) || count <= 0) return ""; | ||
| if (count > 99) return "99+"; | ||
| return String(Math.floor(count)); | ||
| }; | ||
|
|
||
| const POLICY_DEBOUNCE_MS = 100; | ||
|
|
||
| // Gate the `accessory` (menu-bar-only) transition until a menu-bar | ||
| // (tray) entry point exists. Going accessory before then would hide | ||
| // the Dock icon with no replacement, leaving the user no way to re-open | ||
| // the window. Flip to `true` in the same change that lands the tray. | ||
| const ALLOW_ACCESSORY_MODE = false; | ||
|
|
||
| interface DockState { | ||
| signedIn: boolean; | ||
| badgeCount: number; | ||
| policy: "regular" | "accessory"; | ||
| } | ||
|
|
||
| const state: DockState = { | ||
| signedIn: false, | ||
| badgeCount: 0, | ||
| policy: "regular", | ||
| }; | ||
|
|
||
| let refreshTimer: NodeJS.Timeout | null = null; | ||
|
|
||
| const visibleWindowCount = (): number => | ||
| BrowserWindow.getAllWindows().filter((win) => !win.isDestroyed()).length; | ||
|
|
||
| const computePolicy = (): DockState["policy"] => { | ||
| if (visibleWindowCount() > 0) return "regular"; | ||
| if (state.signedIn) return "regular"; | ||
| return ALLOW_ACCESSORY_MODE ? "accessory" : "regular"; | ||
| }; | ||
|
|
||
| // `app.dock.show()` returns a Promise that resolves once the Dock has | ||
| // reflected the change; `setActivationPolicy("regular")` after it | ||
| // keeps the two surfaces in sync (await sequencing is the documented | ||
| // pattern). The accessory transition is synchronous on the Electron | ||
| // side — `hide()` returns void — so no await there. | ||
| const applyPolicy = async (next: DockState["policy"]): Promise<void> => { | ||
| if (next === state.policy) return; | ||
| state.policy = next; | ||
| if (!app.dock) return; | ||
| if (next === "regular") { | ||
| await app.dock.show(); | ||
| app.setActivationPolicy("regular"); | ||
| } else { | ||
| app.dock.hide(); | ||
| app.setActivationPolicy("accessory"); | ||
| } | ||
| }; | ||
|
|
||
| const scheduleRefresh = (): void => { | ||
| if (refreshTimer) clearTimeout(refreshTimer); | ||
| refreshTimer = setTimeout(() => { | ||
| refreshTimer = null; | ||
| void applyPolicy(computePolicy()); | ||
| }, POLICY_DEBOUNCE_MS); | ||
| }; | ||
|
|
||
| const applyBadge = (): void => { | ||
| if (!app.dock) return; | ||
| app.dock.setBadge(formatBadge(state.badgeCount)); | ||
| }; | ||
|
|
||
| /** | ||
| * Wire the dock state machine. Call once from `whenReady`. Idempotent | ||
| * — repeated calls are no-ops, so it's safe under hot-reload of the | ||
| * main bundle in dev. | ||
| */ | ||
| let installed = false; | ||
| export const installDock = (): void => { | ||
| if (installed) return; | ||
| installed = true; | ||
|
|
||
| // Renderer publishes the unread count whenever it changes. Coerce to | ||
| // a finite non-negative integer so a renderer bug can't crash main. | ||
| ipcMain.handle("vellum:dock:setBadge", (_event, count: unknown) => { | ||
| const n = typeof count === "number" && Number.isFinite(count) ? count : 0; | ||
| state.badgeCount = Math.max(0, Math.floor(n)); | ||
| applyBadge(); | ||
| }); | ||
|
|
||
| // Renderer-published signed-in flag. Becomes redundant once main | ||
| // owns the auth state directly — at that point the source of truth | ||
| // flips and this handler can be replaced with a subscription. | ||
| // | ||
| // On a flip to signed-out we also clear the badge synchronously | ||
| // (here, ahead of the debounced policy refresh). Otherwise a logout | ||
| // that destroys the renderer's JS context (hard navigate) can leave | ||
| // a stale count on the Dock — the renderer never gets to publish | ||
| // `setDockBadge(0)` because the layout unmounts first. | ||
| ipcMain.handle("vellum:dock:setSignedIn", (_event, signedIn: unknown) => { | ||
| const next = Boolean(signedIn); | ||
| if (state.signedIn && !next) { | ||
| state.badgeCount = 0; | ||
| applyBadge(); | ||
| } | ||
| state.signedIn = next; | ||
| scheduleRefresh(); | ||
| }); | ||
|
|
||
| // Observe the visible-window count so closing the last window can | ||
| // transition us into accessory mode (once `ALLOW_ACCESSORY_MODE` is | ||
| // flipped), and opening the first one transitions us back to regular. | ||
| app.on("browser-window-created", (_event, win) => { | ||
| scheduleRefresh(); | ||
| win.once("closed", scheduleRefresh); | ||
| }); | ||
|
|
||
| // macOS convention: clear the Dock badge before the process exits so | ||
| // a relaunch doesn't briefly show a stale count from the OS's cache. | ||
| app.on("before-quit", () => { | ||
| if (refreshTimer) { | ||
| clearTimeout(refreshTimer); | ||
| refreshTimer = null; | ||
| } | ||
| if (app.dock) app.dock.setBadge(""); | ||
| }); | ||
|
|
||
| // Apply the initial policy + (empty) badge so we don't briefly show | ||
| // the wrong state before the first event fires. The policy update is | ||
| // fire-and-forget — its `dock.show()` Promise just sequences the | ||
| // following `setActivationPolicy` call inside `applyPolicy`; the | ||
| // caller has nothing to await on. | ||
| void applyPolicy(computePolicy()); | ||
| applyBadge(); | ||
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| import { useEffect, useMemo } from "react"; | ||
|
|
||
| import { setDockBadge, setDockSignedIn } from "@/runtime/dock"; | ||
| import { useAuthStore } from "@/stores/auth-store"; | ||
| import type { Conversation } from "@/types/conversation-types"; | ||
| import { contributesToUnreadCount } from "@/utils/conversation-predicates"; | ||
|
|
||
| /** | ||
| * Publish the data the Electron Dock cares about — unread conversation | ||
| * count and signed-in state — to the main process via the | ||
| * `window.vellum.dock.*` bridge. Both wrappers no-op on non-Electron | ||
| * hosts (see `@/runtime/dock`), so this hook is safe to mount | ||
| * unconditionally inside `ChatLayout`. | ||
| * | ||
| * Mount the hook once at a layout that already has the conversation | ||
| * list in hand (currently `ChatLayout`, which subscribes to | ||
| * `useConversationListQuery` at the route root). The count is derived | ||
| * locally via `contributesToUnreadCount` (the same predicate that | ||
| * drives sidebar attention indicators) so we don't fetch twice and | ||
| * automated background / scheduled / archived threads don't contribute | ||
| * to the badge. | ||
| * | ||
| * The signed-in input is temporary: once main owns auth state | ||
| * directly, main becomes the source of truth and this side of the | ||
| * bridge becomes a no-op. (Main also clears the badge when signed-in | ||
| * flips to false so a logout-driven remount of this layout can't leave | ||
| * a stale count on the Dock.) | ||
| */ | ||
| export function useElectronDockSync(conversations: Conversation[]): void { | ||
| const isLoggedIn = useAuthStore.use.isLoggedIn(); | ||
|
|
||
| const unreadCount = useMemo( | ||
| () => | ||
| conversations.reduce( | ||
| (n, c) => (contributesToUnreadCount(c) ? n + 1 : n), | ||
| 0, | ||
| ), | ||
| [conversations], | ||
| ); | ||
|
|
||
| useEffect(() => { | ||
| void setDockBadge(unreadCount); | ||
| }, [unreadCount]); | ||
|
|
||
| useEffect(() => { | ||
| void setDockSignedIn(isLoggedIn); | ||
| }, [isLoggedIn]); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| import { isElectron } from "@/runtime/is-electron"; | ||
|
|
||
| /** | ||
| * Per-capability wrapper for the Electron host's Dock integration. Matches | ||
| * the pattern in `native-biometric.ts`: the renderer never touches | ||
| * `window.vellum.*` directly — feature code calls these named functions | ||
| * and the cross-platform branch lives here. | ||
| * | ||
| * On non-Electron hosts (web, Capacitor iOS) both functions are no-ops | ||
| * that resolve immediately, so callers can fire them unconditionally on | ||
| * state change without an `isElectron()` check at every call site. | ||
| */ | ||
|
|
||
| /** | ||
| * Publish the unread conversation count to the Electron Dock badge. | ||
| * Main formats per Swift Vellum's convention (1–99 pass through, 99+ | ||
| * truncates). Pass `0` to clear. | ||
| * | ||
| * Safe to call from any host — no-op off Electron. | ||
| */ | ||
| export async function setDockBadge(count: number): Promise<void> { | ||
| if (!isElectron()) return; | ||
| await window.vellum?.dock.setBadge(count); | ||
| } | ||
|
|
||
| /** | ||
| * Publish the user's signed-in state. The main process uses this to | ||
| * decide whether to keep the Dock icon visible after the last window | ||
| * closes (so the user can re-open from the Dock vs. having to relaunch | ||
| * from /Applications). | ||
| * | ||
| * Temporary — once main owns auth state directly, main becomes the | ||
| * source of truth and this becomes a no-op the renderer drops. Safe to | ||
| * call from any host — no-op off Electron. | ||
| */ | ||
| export async function setDockSignedIn(signedIn: boolean): Promise<void> { | ||
| if (!isElectron()) return; | ||
| await window.vellum?.dock.setSignedIn(signedIn); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.