diff --git a/src/main/dock/unread-badge.ts b/src/main/dock/unread-badge.ts new file mode 100644 index 000000000..e97860228 --- /dev/null +++ b/src/main/dock/unread-badge.ts @@ -0,0 +1,14 @@ +import { app } from 'electron' + +export function setUnreadDockBadgeCount(count: number): void { + if (process.platform !== 'darwin') { + return + } + + const normalizedCount = Number.isFinite(count) ? Math.max(0, Math.trunc(count)) : 0 + const label = normalizedCount === 0 ? '' : normalizedCount > 99 ? '99+' : String(normalizedCount) + + // Why: unread counts belong on the native Dock tile on macOS. + // Windows/Linux are skipped until we define the right platform behavior. + app.dock?.setBadge(label) +} diff --git a/src/main/index.ts b/src/main/index.ts index 354b35fe2..ac53966d0 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -51,6 +51,7 @@ import { cursorHookService } from './cursor/hook-service' import { getPtyIdForPaneKey, registerPaneKeyTeardownListener, getLocalPtyProvider } from './ipc/pty' import { AgentBrowserBridge } from './browser/agent-browser-bridge' import { browserManager } from './browser/browser-manager' +import { setUnreadDockBadgeCount } from './dock/unread-badge' let mainWindow: BrowserWindow | null = null /** Whether a manual app.quit() (Cmd+Q, etc.) is in progress. Shared with the @@ -659,6 +660,7 @@ app.on('will-quit', (e) => { // so without this ordering, running agents would produce orphaned // agent_start events with no matching stops. starNag?.stop() + setUnreadDockBadgeCount(0) agentHookServer.stop() stats?.flush() // Why: agent-browser daemon processes would otherwise linger after Orca quits, diff --git a/src/main/ipc/app.ts b/src/main/ipc/app.ts index da5d57cd9..f283d428a 100644 --- a/src/main/ipc/app.ts +++ b/src/main/ipc/app.ts @@ -3,6 +3,7 @@ import { promisify } from 'node:util' import { app, ipcMain } from 'electron' import { isPwshAvailable } from '../pwsh' import { isWslAvailable } from '../wsl' +import { setUnreadDockBadgeCount } from '../dock/unread-badge' const execFileAsync = promisify(execFile) @@ -66,4 +67,8 @@ export function registerAppHandlers(): void { app.exit(0) }, 150) }) + + ipcMain.handle('app:setUnreadDockBadgeCount', (_event, count: number) => { + setUnreadDockBadgeCount(Number.isFinite(count) ? count : 0) + }) } diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index f41144e0c..bcb0c2143 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -361,6 +361,8 @@ export type AppApi = { * US QWERTY but whose Option layer composes characters (issue #1205). * Returns null on non-Darwin platforms or when the defaults read fails. */ getKeyboardInputSourceId: () => Promise + /** Updates the macOS Dock unread badge. No-op on Windows/Linux. */ + setUnreadDockBadgeCount: (count: number) => Promise } export type PreloadApi = { diff --git a/src/preload/index.ts b/src/preload/index.ts index 45a81564f..a72438a29 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -252,7 +252,9 @@ const api = { // src/renderer/src/lib/keyboard-layout/input-source-id.ts, issue #1205). // Returns null on non-Darwin or when the defaults read fails. getKeyboardInputSourceId: (): Promise => - ipcRenderer.invoke('app:getKeyboardInputSourceId') + ipcRenderer.invoke('app:getKeyboardInputSourceId'), + setUnreadDockBadgeCount: (count: number): Promise => + ipcRenderer.invoke('app:setUnreadDockBadgeCount', count) }, wsl: { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 5d1efdf4e..f8bdda416 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -36,6 +36,7 @@ import { SshPassphraseDialog } from './components/settings/SshPassphraseDialog' import { useGitStatusPolling } from './components/right-sidebar/useGitStatusPolling' import { useEditorExternalWatch } from './hooks/useEditorExternalWatch' import { useAutoAckViewedAgent } from './hooks/useAutoAckViewedAgent' +import { useUnreadDockBadge } from './hooks/useUnreadDockBadge' import { setRuntimeGraphStoreStateGetter, setRuntimeGraphSyncEnabled @@ -139,6 +140,8 @@ const PetOverlay = lazy(() => import('./components/pet/PetOverlay')) const OnboardingFlow = lazy(() => import('./components/onboarding/OnboardingFlow')) function App(): React.JSX.Element { + useUnreadDockBadge() + // Why: Zustand actions are referentially stable, but each individual // useAppStore(s => s.someAction) still registers a subscription that React // must check on every store mutation. Consolidating 19 action refs into one diff --git a/src/renderer/src/hooks/useUnreadDockBadge.ts b/src/renderer/src/hooks/useUnreadDockBadge.ts new file mode 100644 index 000000000..1f6674a98 --- /dev/null +++ b/src/renderer/src/hooks/useUnreadDockBadge.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react' +import { getUnreadBadgeCount } from '@/lib/unread-badge-count' +import { useAppStore } from '@/store' + +export function useUnreadDockBadge(): void { + const unreadCount = useAppStore((state) => + getUnreadBadgeCount({ + worktreesByRepo: state.worktreesByRepo, + tabsByWorktree: state.tabsByWorktree, + unreadTerminalTabs: state.unreadTerminalTabs + }) + ) + + useEffect(() => { + void window.api.app.setUnreadDockBadgeCount(unreadCount).catch(() => { + // Dock sync is best-effort chrome; stale badge state should not affect app use. + }) + }, [unreadCount]) + + useEffect(() => { + return () => { + void window.api.app.setUnreadDockBadgeCount(0).catch(() => {}) + } + }, []) +} diff --git a/src/renderer/src/lib/unread-badge-count.test.ts b/src/renderer/src/lib/unread-badge-count.test.ts new file mode 100644 index 000000000..fdf20dfab --- /dev/null +++ b/src/renderer/src/lib/unread-badge-count.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from 'vitest' +import type { TerminalTab, Worktree } from '../../../shared/types' +import { getUnreadBadgeCount } from './unread-badge-count' + +function worktree(id: string, isUnread: boolean): Worktree { + return { id, isUnread } as Worktree +} + +function tab(id: string): TerminalTab { + return { id } as TerminalTab +} + +describe('getUnreadBadgeCount', () => { + it('counts unread worktrees', () => { + expect( + getUnreadBadgeCount({ + worktreesByRepo: { repo: [worktree('wt-1', true), worktree('wt-2', false)] }, + tabsByWorktree: {}, + unreadTerminalTabs: {} + }) + ).toBe(1) + }) + + it('dedupes unread terminal tabs against their worktree', () => { + expect( + getUnreadBadgeCount({ + worktreesByRepo: { repo: [worktree('wt-1', true)] }, + tabsByWorktree: { 'wt-1': [tab('tab-1'), tab('tab-2')] }, + unreadTerminalTabs: { 'tab-1': true, 'tab-2': true } + }) + ).toBe(1) + }) + + it('counts tab-only unread activity by owning worktree', () => { + expect( + getUnreadBadgeCount({ + worktreesByRepo: { repo: [worktree('wt-1', false), worktree('wt-2', false)] }, + tabsByWorktree: { 'wt-1': [tab('tab-1')], 'wt-2': [tab('tab-2')] }, + unreadTerminalTabs: { 'tab-1': true, 'tab-2': true } + }) + ).toBe(2) + }) +}) diff --git a/src/renderer/src/lib/unread-badge-count.ts b/src/renderer/src/lib/unread-badge-count.ts new file mode 100644 index 000000000..79cd2ffa3 --- /dev/null +++ b/src/renderer/src/lib/unread-badge-count.ts @@ -0,0 +1,39 @@ +import type { TerminalTab, Worktree } from '../../../shared/types' + +export function getUnreadBadgeCount({ + worktreesByRepo, + tabsByWorktree, + unreadTerminalTabs +}: { + worktreesByRepo: Record + tabsByWorktree: Record + unreadTerminalTabs: Record +}): number { + const unreadWorktreeIds = new Set() + + for (const worktrees of Object.values(worktreesByRepo)) { + for (const worktree of worktrees) { + if (worktree.isUnread) { + unreadWorktreeIds.add(worktree.id) + } + } + } + + const unreadTabIds = new Set(Object.keys(unreadTerminalTabs)) + if (unreadTabIds.size === 0) { + return unreadWorktreeIds.size + } + + for (const [worktreeId, tabs] of Object.entries(tabsByWorktree)) { + for (const tab of tabs) { + if (!unreadTabIds.delete(tab.id)) { + continue + } + unreadWorktreeIds.add(worktreeId) + } + } + + // Why: tab unread state should normally map to a live worktree, but counting + // unmatched entries keeps the Dock badge honest during hydration races. + return unreadWorktreeIds.size + unreadTabIds.size +}