feat: show unread count in macOS Dock (#1641)
This commit is contained in:
parent
7c51f07858
commit
3b87505a97
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(() => {})
|
||||
}
|
||||
}, [])
|
||||
}
|
||||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
}
|
||||
Loading…
Reference in New Issue