From 033a69079ad6e297786d1fcea74bb0382860872d Mon Sep 17 00:00:00 2001 From: Elliot Jordan Date: Tue, 14 Apr 2026 06:11:46 -0400 Subject: [PATCH] feat: add app badge count for unread tasks (#1722) Show the native OS badge on the app icon with the count of tasks that have unread activity. Controlled by a new "App badge" toggle in notification settings, off by default. --- src/main/ipc/appIpc.ts | 11 +++ src/main/preload.ts | 2 + src/main/settings.ts | 4 ++ .../components/NotificationSettingsCard.tsx | 13 ++++ src/renderer/lib/agentStatusStore.ts | 28 +++++++- src/renderer/types/electron-api.d.ts | 1 + src/renderer/views/Workspace.tsx | 9 +++ src/test/main/appIpc.openIn.test.ts | 1 + src/test/main/settings.test.ts | 2 + src/test/renderer/agentStatusStore.test.ts | 71 +++++++++++++++++++ 10 files changed, 139 insertions(+), 3 deletions(-) diff --git a/src/main/ipc/appIpc.ts b/src/main/ipc/appIpc.ts index 70ead3c32..fb445fb62 100644 --- a/src/main/ipc/appIpc.ts +++ b/src/main/ipc/appIpc.ts @@ -199,6 +199,17 @@ const getCachedAppVersion = (): Promise => { export function registerAppIpc() { void getCachedAppVersion(); + let lastBadgeCount = 0; + ipcMain.on('app:set-badge-count', (_event, count: number) => { + if (count === lastBadgeCount) return; + lastBadgeCount = count; + try { + app.setBadgeCount(count); + } catch { + // setBadgeCount is unsupported on some Linux desktop environments + } + }); + ipcMain.handle('app:undo', async (event) => { try { event.sender.undo(); diff --git a/src/main/preload.ts b/src/main/preload.ts index 83dc4ec6d..09f0790ce 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -491,6 +491,8 @@ contextBridge.exposeInMainWorld('electronAPI', { accountCheckServerHealth: () => ipcRenderer.invoke('account:checkServerHealth'), accountValidateSession: () => ipcRenderer.invoke('account:validateSession'), + setBadgeCount: (count: number) => ipcRenderer.send('app:set-badge-count', count), + // GitHub integration githubAuth: () => ipcRenderer.invoke('github:auth'), githubAuthOAuth: () => ipcRenderer.invoke('github:auth:oauth'), diff --git a/src/main/settings.ts b/src/main/settings.ts index 82cd0cc3d..9c1f9b5cd 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -102,6 +102,7 @@ export interface AppSettings { enabled: boolean; sound: boolean; osNotifications: boolean; + appBadge: boolean; soundFocusMode: 'always' | 'unfocused'; soundProfile: NotificationSoundProfile; }; @@ -166,6 +167,7 @@ const DEFAULT_SETTINGS: AppSettings = { enabled: true, sound: true, osNotifications: true, + appBadge: false, soundFocusMode: 'always', soundProfile: 'default', }, @@ -385,6 +387,7 @@ export function normalizeSettings(input: AppSettings): AppSettings { enabled: DEFAULT_SETTINGS.notifications!.enabled, sound: DEFAULT_SETTINGS.notifications!.sound, osNotifications: DEFAULT_SETTINGS.notifications!.osNotifications, + appBadge: DEFAULT_SETTINGS.notifications!.appBadge, soundFocusMode: DEFAULT_SETTINGS.notifications!.soundFocusMode, soundProfile: DEFAULT_SETTINGS.notifications!.soundProfile, }, @@ -425,6 +428,7 @@ export function normalizeSettings(input: AppSettings): AppSettings { osNotifications: Boolean( notif?.osNotifications ?? DEFAULT_SETTINGS.notifications!.osNotifications ), + appBadge: Boolean(notif?.appBadge ?? DEFAULT_SETTINGS.notifications!.appBadge), soundFocusMode: rawFocusMode === 'always' || rawFocusMode === 'unfocused' ? rawFocusMode diff --git a/src/renderer/components/NotificationSettingsCard.tsx b/src/renderer/components/NotificationSettingsCard.tsx index 7c61d7ec2..1f6e3d16f 100644 --- a/src/renderer/components/NotificationSettingsCard.tsx +++ b/src/renderer/components/NotificationSettingsCard.tsx @@ -119,6 +119,19 @@ const NotificationSettingsCard: React.FC = () => { + {/* App badge toggle */} +
+
+

App badge

+

Show unread count on the app icon.

+
+ updateSettings({ notifications: { appBadge: next } })} + /> +
+ {/* OS notifications toggle */}
diff --git a/src/renderer/lib/agentStatusStore.ts b/src/renderer/lib/agentStatusStore.ts index 8ee30a5f0..9b27cba16 100644 --- a/src/renderer/lib/agentStatusStore.ts +++ b/src/renderer/lib/agentStatusStore.ts @@ -19,6 +19,7 @@ export class AgentStatusStore { private readonly unreadListeners = new Map>(); private readonly statusById = new Map(); private readonly unreadById = new Map(); + private readonly globalUnreadListeners = new Set<() => void>(); private activeView: { taskId: string | null; statusId: string | null } = { taskId: null, statusId: null, @@ -121,6 +122,21 @@ export class AgentStatusStore { this.setUnread(id, false); } + getUnreadCount(): number { + let count = 0; + for (const unread of this.unreadById.values()) { + if (unread) count++; + } + return count; + } + + onUnreadCountChange(listener: () => void): () => void { + this.globalUnreadListeners.add(listener); + return () => { + this.globalUnreadListeners.delete(listener); + }; + } + setActiveView(view: { taskId: string | null; statusId: string | null }): void { this.activeView = view; } @@ -167,10 +183,16 @@ export class AgentStatusStore { if (current === unread) return; this.unreadById.set(id, unread); const listeners = this.unreadListeners.get(id); - if (!listeners) return; - for (const listener of listeners) { + if (listeners) { + for (const listener of listeners) { + try { + listener(unread); + } catch {} + } + } + for (const listener of this.globalUnreadListeners) { try { - listener(unread); + listener(); } catch {} } } diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index b565a91f9..712a5782a 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -642,6 +642,7 @@ declare global { openExternal: (url: string) => Promise<{ success: boolean; error?: string }>; clipboardWriteText: (text: string) => Promise<{ success: boolean; error?: string }>; paste: () => Promise<{ success: boolean; error?: string }>; + setBadgeCount: (count: number) => void; openIn: (args: { app: OpenInAppId; path: string; diff --git a/src/renderer/views/Workspace.tsx b/src/renderer/views/Workspace.tsx index e8ba67908..73c9b4307 100644 --- a/src/renderer/views/Workspace.tsx +++ b/src/renderer/views/Workspace.tsx @@ -100,6 +100,15 @@ export function Workspace() { soundPlayer.setProfile(notif?.soundProfile ?? 'default'); }, [settings?.notifications]); + useEffect(() => { + const enabled = + (settings?.notifications?.enabled ?? true) && (settings?.notifications?.appBadge ?? false); + const sync = () => + window.electronAPI.setBadgeCount(enabled ? agentStatusStore.getUnreadCount() : 0); + sync(); + return agentStatusStore.onUnreadCountChange(sync); + }, [settings?.notifications?.enabled, settings?.notifications?.appBadge]); + // --- View-mode / UI visibility state (inlined from former useModalState) --- const [showSettingsPage, setShowSettingsPage] = useState(false); const [settingsPageInitialTab, setSettingsPageInitialTab] = useState('general'); diff --git a/src/test/main/appIpc.openIn.test.ts b/src/test/main/appIpc.openIn.test.ts index d08266d17..07c006157 100644 --- a/src/test/main/appIpc.openIn.test.ts +++ b/src/test/main/appIpc.openIn.test.ts @@ -21,6 +21,7 @@ vi.mock('electron', () => ({ handle: vi.fn((channel: string, cb: IpcHandler) => { ipcHandleHandlers.set(channel, cb); }), + on: vi.fn(), }, shell: { openExternal: (url: string) => shellOpenExternalMock(url), diff --git a/src/test/main/settings.test.ts b/src/test/main/settings.test.ts index 5456eeab2..8c1025ed8 100644 --- a/src/test/main/settings.test.ts +++ b/src/test/main/settings.test.ts @@ -167,6 +167,7 @@ describe('normalizeSettings - notification sound profile', () => { enabled: true, sound: true, osNotifications: true, + appBadge: false, soundFocusMode: 'always', soundProfile: 'gilfoyle', }, @@ -183,6 +184,7 @@ describe('normalizeSettings - notification sound profile', () => { enabled: true, sound: true, osNotifications: true, + appBadge: false, soundFocusMode: 'always', soundProfile: 'unknown' as any, }, diff --git a/src/test/renderer/agentStatusStore.test.ts b/src/test/renderer/agentStatusStore.test.ts index f4ced3b77..4724b61fb 100644 --- a/src/test/renderer/agentStatusStore.test.ts +++ b/src/test/renderer/agentStatusStore.test.ts @@ -190,6 +190,77 @@ describe('AgentStatusStore', () => { expect(store.getUnread('task-1')).toBe(false); }); + + it('tracks unread count across multiple tasks', () => { + const store = new AgentStatusStore(); + expect(store.getUnreadCount()).toBe(0); + + store.handleAgentEvent( + makeEvent(makePtyId('claude', 'main', 'task-1'), { + taskId: 'task-1', + payload: { notificationType: 'idle_prompt' }, + }) + ); + expect(store.getUnreadCount()).toBe(1); + + store.handleAgentEvent( + makeEvent(makePtyId('claude', 'main', 'task-2'), { + type: 'stop', + taskId: 'task-2', + }) + ); + expect(store.getUnreadCount()).toBe(2); + + store.markSeen('task-1'); + expect(store.getUnreadCount()).toBe(1); + + store.markSeen('task-2'); + expect(store.getUnreadCount()).toBe(0); + }); + + it('fires global unread listeners only when count changes', () => { + const store = new AgentStatusStore(); + const calls: number[] = []; + store.onUnreadCountChange(() => calls.push(store.getUnreadCount())); + + const ptyId = makePtyId('claude', 'main', 'task-1'); + store.handleAgentEvent( + makeEvent(ptyId, { + taskId: 'task-1', + payload: { notificationType: 'idle_prompt' }, + }) + ); + // Duplicate event should not fire again (setUnread early-returns) + store.handleAgentEvent( + makeEvent(ptyId, { + taskId: 'task-1', + payload: { notificationType: 'idle_prompt' }, + }) + ); + + store.markSeen('task-1'); + + expect(calls).toEqual([1, 0]); + }); + + it('cleans up global unread listener on unsubscribe', () => { + const store = new AgentStatusStore(); + const calls: number[] = []; + const unsub = store.onUnreadCountChange(() => calls.push(store.getUnreadCount())); + + store.handleAgentEvent( + makeEvent(makePtyId('claude', 'main', 'task-1'), { + taskId: 'task-1', + payload: { notificationType: 'idle_prompt' }, + }) + ); + expect(calls).toEqual([1]); + + unsub(); + + store.markSeen('task-1'); + expect(calls).toEqual([1]); // no further calls after unsubscribe + }); }); describe('deriveTaskStatus', () => {