feat: show unread count in macOS Dock (#1641)

This commit is contained in:
Neil 2026-05-09 18:28:56 -07:00 committed by GitHub
parent 7c51f07858
commit 3b87505a97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 136 additions and 1 deletions

View File

@ -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)
}

View File

@ -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,

View File

@ -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)
})
}

View File

@ -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<string | null>
/** Updates the macOS Dock unread badge. No-op on Windows/Linux. */
setUnreadDockBadgeCount: (count: number) => Promise<void>
}
export type PreloadApi = {

View File

@ -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<string | null> =>
ipcRenderer.invoke('app:getKeyboardInputSourceId')
ipcRenderer.invoke('app:getKeyboardInputSourceId'),
setUnreadDockBadgeCount: (count: number): Promise<void> =>
ipcRenderer.invoke('app:setUnreadDockBadgeCount', count)
},
wsl: {

View File

@ -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

View File

@ -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(() => {})
}
}, [])
}

View File

@ -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)
})
})

View File

@ -0,0 +1,39 @@
import type { TerminalTab, Worktree } from '../../../shared/types'
export function getUnreadBadgeCount({
worktreesByRepo,
tabsByWorktree,
unreadTerminalTabs
}: {
worktreesByRepo: Record<string, Worktree[]>
tabsByWorktree: Record<string, TerminalTab[]>
unreadTerminalTabs: Record<string, true>
}): number {
const unreadWorktreeIds = new Set<string>()
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
}