Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/main/ipc/appIpc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,17 @@ const getCachedAppVersion = (): Promise<string> => {
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);
Comment on lines +203 to +207
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Validate and normalize badge-count IPC input in main process.

Line 203 currently trusts renderer input. Please coerce to a finite non-negative integer before dedupe/update to avoid invalid values and unstable dedupe behavior.

🔧 Proposed hardening patch
-  ipcMain.on('app:set-badge-count', (_event, count: number) => {
+  ipcMain.on('app:set-badge-count', (_event, rawCount: unknown) => {
+    const count =
+      typeof rawCount === 'number' && Number.isFinite(rawCount)
+        ? Math.max(0, Math.floor(rawCount))
+        : 0;
     if (count === lastBadgeCount) return;
     lastBadgeCount = count;
     try {
       app.setBadgeCount(count);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
ipcMain.on('app:set-badge-count', (_event, count: number) => {
if (count === lastBadgeCount) return;
lastBadgeCount = count;
try {
app.setBadgeCount(count);
ipcMain.on('app:set-badge-count', (_event, rawCount: unknown) => {
const count =
typeof rawCount === 'number' && Number.isFinite(rawCount)
? Math.max(0, Math.floor(rawCount))
: 0;
if (count === lastBadgeCount) return;
lastBadgeCount = count;
try {
app.setBadgeCount(count);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/main/ipc/appIpc.ts` around lines 203 - 207, The IPC handler for
'app:set-badge-count' currently trusts the renderer-provided count; coerce and
validate that input to a finite non-negative integer before comparing against
lastBadgeCount and calling app.setBadgeCount. Inside the ipcMain.on callback
(the handler that receives the count parameter), convert the incoming value to a
number, verify Number.isFinite, floor it (or use Math.trunc) and clamp to zero
for negatives, treat invalid/non-finite inputs as 0 (or ignore), then use this
normalized value for the dedupe check against lastBadgeCount and for the
subsequent app.setBadgeCount call (update lastBadgeCount only after successful
normalization).

} catch {
// setBadgeCount is unsupported on some Linux desktop environments
}
});

ipcMain.handle('app:undo', async (event) => {
try {
event.sender.undo();
Expand Down
2 changes: 2 additions & 0 deletions src/main/preload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'),
Expand Down
4 changes: 4 additions & 0 deletions src/main/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,7 @@ export interface AppSettings {
enabled: boolean;
sound: boolean;
osNotifications: boolean;
appBadge: boolean;
soundFocusMode: 'always' | 'unfocused';
soundProfile: NotificationSoundProfile;
};
Expand Down Expand Up @@ -166,6 +167,7 @@ const DEFAULT_SETTINGS: AppSettings = {
enabled: true,
sound: true,
osNotifications: true,
appBadge: false,
soundFocusMode: 'always',
soundProfile: 'default',
},
Expand Down Expand Up @@ -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,
},
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/components/NotificationSettingsCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,19 @@ const NotificationSettingsCard: React.FC = () => {
</Select>
</div>

{/* App badge toggle */}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-1 flex-col gap-0.5">
<p className="text-sm font-medium text-foreground">App badge</p>
<p className="text-sm text-muted-foreground">Show unread count on the app icon.</p>
</div>
<Switch
checked={notifications?.appBadge ?? false}
disabled={loading}
onCheckedChange={(next) => updateSettings({ notifications: { appBadge: next } })}
/>
</div>

{/* OS notifications toggle */}
<div className="flex items-center justify-between gap-4">
<div className="flex flex-1 flex-col gap-0.5">
Expand Down
28 changes: 25 additions & 3 deletions src/renderer/lib/agentStatusStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class AgentStatusStore {
private readonly unreadListeners = new Map<string, Set<UnreadListener>>();
private readonly statusById = new Map<string, AgentStatusSnapshot>();
private readonly unreadById = new Map<string, boolean>();
private readonly globalUnreadListeners = new Set<() => void>();
private activeView: { taskId: string | null; statusId: string | null } = {
taskId: null,
statusId: null,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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 {}
}
}
Expand Down
1 change: 1 addition & 0 deletions src/renderer/types/electron-api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
9 changes: 9 additions & 0 deletions src/renderer/views/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<SettingsPageTab>('general');
Expand Down
1 change: 1 addition & 0 deletions src/test/main/appIpc.openIn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
2 changes: 2 additions & 0 deletions src/test/main/settings.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ describe('normalizeSettings - notification sound profile', () => {
enabled: true,
sound: true,
osNotifications: true,
appBadge: false,
soundFocusMode: 'always',
soundProfile: 'gilfoyle',
},
Expand All @@ -183,6 +184,7 @@ describe('normalizeSettings - notification sound profile', () => {
enabled: true,
sound: true,
osNotifications: true,
appBadge: false,
soundFocusMode: 'always',
soundProfile: 'unknown' as any,
},
Expand Down
71 changes: 71 additions & 0 deletions src/test/renderer/agentStatusStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading