feat(desktop): add macOS dock badge for chats with unseen activity

Show a count of chats (root sessions) with unseen activity on the macOS
dock icon. The count is computed in the existing tray snapshot (full
cross-project list, not the capped tray view; a subtask's unseen rolls up
to its root only when subtask notifications are enabled) and pushed to the
main process over the existing desktop_tray_update IPC, which calls
app.setBadgeCount (0 clears it). The badge clears as sessions are marked
seen on window focus.

Add a Dock badge toggle in Appearance settings (default on, persisted,
darwin desktop only), localized across all dictionaries, with a matching
settings-search entry whose availability mirrors the render guard exactly.
This commit is contained in:
Bohdan Triapitsyn
2026-06-28 01:07:52 +03:00
parent 00efd8cc4b
commit 84d7303346
16 changed files with 129 additions and 3 deletions
+28 -1
View File
@@ -19,6 +19,7 @@ import { QUOTA_PROVIDERS, formatWindowLabel, formatQuotaValueLabel } from '@/lib
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useGitStore } from '@/stores/useGitStore';
import { useUIStore } from '@/stores/useUIStore';
import { resolveProjectForSessionDirectory, normalizeProjectPath } from '@/lib/projectResolution';
import type { ProjectEntry } from '@/lib/api/types';
import type { WorktreeMetadata } from '@/types/worktree';
@@ -80,6 +81,9 @@ type TraySnapshot = {
// dropdown (same "configured to show" rule as the header/mobile). Empty
// groups → the tray omits the Usage submenu entirely.
usage: TrayUsage;
// Number of chats (root sessions) with unseen activity, for the macOS dock
// badge. 0 when the user disabled the badge — the main process clears it.
dockBadgeCount: number;
};
// focus-session / new-session are routed natively by the main process through
@@ -389,7 +393,26 @@ const buildSnapshot = (instanceName: string): TraySnapshot => {
const approvals = live.approvals.map((a) => ({ ...a, sessionTitle: titleById.get(a.sessionId) || '' }));
return { sessions, approvals, instanceName, usage: buildUsage() };
// Dock badge: count chats (root sessions) with unseen activity over the FULL
// cross-project list — not the MAX_SESSIONS-capped `sessions` above — so the
// number is accurate even with many projects. A subtask's unseen rolls up to
// its root only when the user opted into subtask notifications, matching the
// sidebar's needs-attention rule.
const ui = useUIStore.getState();
let dockBadgeCount = 0;
if (ui.dockBadgeEnabled) {
for (const session of allSessions) {
if (!session?.id || session.parentID) continue; // roots only
let familyUnseen = notif.unseenCount[session.id] ?? 0;
if (familyUnseen === 0 && ui.notifyOnSubtasks) {
familyUnseen = collectDescendants(session.id)
.reduce((sum, id) => sum + (notif.unseenCount[id] ?? 0), 0);
}
if (familyUnseen > 0) dockBadgeCount += 1;
}
}
return { sessions, approvals, instanceName, usage: buildUsage(), dockBadgeCount };
};
export const useTraySync = (): void => {
@@ -491,6 +514,9 @@ export const useTraySync = (): void => {
const unsubscribeProjects = useProjectsStore.subscribe(() => scheduleFlush());
const unsubscribeWorktrees = useSessionUIStore.subscribe(() => scheduleFlush());
const unsubscribeGit = useGitStore.subscribe(() => scheduleFlush());
// The dock-badge toggle and subtask-notification preference live here; a
// change must re-push the snapshot so the badge appears/clears immediately.
const unsubscribeUI = useUIStore.subscribe(() => scheduleFlush());
// Cross-project status map: fed live by the sync dispatcher from the global
// event stream, and seeded/reconciled by the poll below.
const unsubscribeGlobalStatus = useGlobalSessionStatusStore.subscribe(() => scheduleFlush());
@@ -541,6 +567,7 @@ export const useTraySync = (): void => {
unsubscribeProjects();
unsubscribeWorktrees();
unsubscribeGit();
unsubscribeUI();
unsubscribeGlobalStatus();
unsubscribeQuota();
unsubscribeRegistry?.();